From bd6f513554a8a832580ea756ad3a4289cbcf5284 Mon Sep 17 00:00:00 2001 From: Mathieu Colmon Date: Mon, 31 Aug 2026 14:45:51 +0200 Subject: [PATCH 1/2] feat: replace legacy client with coordinator SDK v4 --- .eslintrc.js | 16 - .github/workflows/ci.yml | 25 + .gitignore | 4 + .npmignore | 1 - LICENSE | 15 + README.md | 203 ++ bun.lock | 79 + contracts/miakapp-v3.json | 6 + main.js | 305 --- miakode.js | 56 - package-lock.json | 3529 ------------------------------- package.json | 51 +- scripts/check-contract.mjs | 106 + src/api.ts | 347 +++ src/coordinator.ts | 621 ++++++ src/index.ts | 2 + src/internal/calls.ts | 606 ++++++ src/internal/declarations.ts | 582 +++++ src/internal/errors.ts | 109 + src/internal/events.ts | 250 +++ src/internal/presence.ts | 112 + src/internal/resources.ts | 155 ++ src/internal/runtime.ts | 52 + src/internal/session.ts | 243 +++ src/internal/socket.ts | 190 ++ src/internal/state.ts | 166 ++ src/internal/validation.ts | 573 +++++ src/protocol/codec.ts | 1114 ++++++++++ src/protocol/session.ts | 147 ++ test.js | 45 - test/calls-presence.test.ts | 411 ++++ test/contract/recorder.ts | 272 +++ test/contract/subject.ts | 1038 +++++++++ test/declarations.test.ts | 261 +++ test/fakes/relay.ts | 351 +++ test/fakes/runtime.ts | 104 + test/helpers.ts | 88 + test/lifecycle.test.ts | 289 +++ test/node-smoke.mjs | 40 + test/protocol.test.ts | 70 + test/public-api.test.ts | 179 ++ test/resources-security.test.ts | 292 +++ test/socket.test.ts | 188 ++ test/state-events.test.ts | 250 +++ test/type-contract.ts | 34 + tsconfig.build.json | 18 + tsconfig.contract.json | 16 + tsconfig.json | 32 + 48 files changed, 9682 insertions(+), 3961 deletions(-) delete mode 100644 .eslintrc.js create mode 100644 .github/workflows/ci.yml delete mode 100644 .npmignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 bun.lock create mode 100644 contracts/miakapp-v3.json delete mode 100644 main.js delete mode 100644 miakode.js delete mode 100644 package-lock.json create mode 100644 scripts/check-contract.mjs create mode 100644 src/api.ts create mode 100644 src/coordinator.ts create mode 100644 src/index.ts create mode 100644 src/internal/calls.ts create mode 100644 src/internal/declarations.ts create mode 100644 src/internal/errors.ts create mode 100644 src/internal/events.ts create mode 100644 src/internal/presence.ts create mode 100644 src/internal/resources.ts create mode 100644 src/internal/runtime.ts create mode 100644 src/internal/session.ts create mode 100644 src/internal/socket.ts create mode 100644 src/internal/state.ts create mode 100644 src/internal/validation.ts create mode 100644 src/protocol/codec.ts create mode 100644 src/protocol/session.ts delete mode 100644 test.js create mode 100644 test/calls-presence.test.ts create mode 100644 test/contract/recorder.ts create mode 100644 test/contract/subject.ts create mode 100644 test/declarations.test.ts create mode 100644 test/fakes/relay.ts create mode 100644 test/fakes/runtime.ts create mode 100644 test/helpers.ts create mode 100644 test/lifecycle.test.ts create mode 100644 test/node-smoke.mjs create mode 100644 test/protocol.test.ts create mode 100644 test/public-api.test.ts create mode 100644 test/resources-security.test.ts create mode 100644 test/socket.test.ts create mode 100644 test/state-events.test.ts create mode 100644 test/type-contract.ts create mode 100644 tsconfig.build.json create mode 100644 tsconfig.contract.json create mode 100644 tsconfig.json diff --git a/.eslintrc.js b/.eslintrc.js deleted file mode 100644 index 79fa088..0000000 --- a/.eslintrc.js +++ /dev/null @@ -1,16 +0,0 @@ -module.exports = { - env: { - commonjs: true, - es2021: true, - node: true, - }, - extends: [ - 'airbnb-base', - ], - parserOptions: { - ecmaVersion: 12, - }, - rules: { - 'no-console': 'off', - }, -}; diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..4bd2223 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,25 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +jobs: + check: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + bun-version: 1.2.23 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: 22.22.0 + registry-url: https://registry.npmjs.org + - run: bun install --frozen-lockfile + - run: bun run check diff --git a/.gitignore b/.gitignore index a19f602..c1f387e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,6 @@ node_modules/ miakapiCredentials.json +dist/ +.contract-dist/ +.contract/ +coverage/ diff --git a/.npmignore b/.npmignore deleted file mode 100644 index ed2d542..0000000 --- a/.npmignore +++ /dev/null @@ -1 +0,0 @@ -miakapiCredentials.json diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..c5247b7 --- /dev/null +++ b/LICENSE @@ -0,0 +1,15 @@ +ISC License + +Copyright (c) 2026 Mathieu Colmon + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..481e636 --- /dev/null +++ b/README.md @@ -0,0 +1,203 @@ +# MiakAPI + +MiakAPI is the typed Node.js SDK for running a trusted Miakapp coordinator. A +coordinator owns complete state, access, event, and function declarations for one +integration and exchanges canonical MessagePack frames with the Miakapp relay. + +Version 4 is a complete replacement for the legacy callback-based MiakAPI 3 +client. It is currently an alpha while the Miakapp 3.5 relay is being deployed. + +## Requirements + +- Node.js 22.9 or newer +- An application backend able to issue short-lived coordinator access tokens +- A Miakapp relay implementing wire protocol 1.0 + +MiakAPI is server-side software. Do not ship coordinator credentials, Home Keys, +or access-token providers to a browser or an untrusted plugin runtime. + +## Installation + +```sh +npm install miakapi@next +``` + +Alpha releases use the `next` npm tag. The package is ESM-only. + +## Quick start + +```ts +import { + ApplicationCallError, + EventDirection, + createCoordinator, +} from 'miakapi'; + +const coordinator = createCoordinator({ + name: 'home-assistant', + accessTokenProvider: { + async getAccessToken({ coordinatorName, reason, signal }) { + const response = await fetch('https://example.test/miakapp/token', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ coordinatorName, reason }), + signal, + }); + if (!response.ok) throw new Error('Access token request failed'); + return response.json(); + }, + }, +}); + +coordinator.configure({ + state: { + 'climate.living_room.temperature': 20, + }, + stateAccess: [{ + userId: 'user-id', + patterns: ['climate.living_room.*'], + }], + events: [{ + topic: 'climate.living_room.changed', + directions: + EventDirection.acceptFromUsers | + EventDirection.publishToUsers, + }], + eventAccess: [{ + userId: 'user-id', + publish: ['climate.living_room.changed'], + subscribe: ['climate.living_room.changed'], + }], + functions: { + async 'climate.living_room.set_target'(call) { + if (typeof call.arguments !== 'number') { + throw new ApplicationCallError(2001, 'Target must be a number'); + } + await call.emit({ phase: 'applying' }); + return { accepted: true, target: call.arguments }; + }, + }, +}); + +coordinator.subscribe(({ current, reason }) => { + console.log('Miakapp coordinator status:', current, reason?.kind); +}); + +const session = await coordinator.start(); +console.log('Ready in generation', session.generation); +``` + +`configure` supplies all five declaration slices as one desired snapshot. The +coordinator becomes `ready` only after the relay acknowledges them in order. A +later declaration call replaces its complete slice and temporarily returns the +coordinator to `synchronizing` until atomic activation. + +## State, events, and calls + +State mutations are atomic and use acknowledged string paths: + +```ts +await coordinator.state.set([ + { path: 'climate.living_room.temperature', value: 21.5 }, +]); +``` + +Event publication returns a synchronous opaque ID and a transport-handoff +promise. `sent` is not a delivery receipt; a later correlated relay rejection is +reported through `coordinator.errors`. + +```ts +const event = coordinator.events.publish( + 'climate.living_room.changed', + { temperature: 21.5 }, +); +await event.sent; + +coordinator.errors.subscribe((failure) => { + if (failure.correlation?.localId === event.localId) { + console.error('The relay rejected the event:', failure.kind); + } +}); +``` + +Outgoing calls expose acceptance, pull-bounded progress, and one terminal +result: + +```ts +const call = coordinator.calls.start({ + function: 'lighting.scene.activate', + arguments: { scene: 'evening' }, + timeoutMs: 10_000, + idempotencyKey: 'intent-018f', +}); + +await call.accepted; +for await (const progress of call.stream) console.log(progress); +const result = await call.result; +``` + +MiakAPI never retries state mutations, events, or calls. An idempotency key is +passed to the callee but does not enable hidden retries. + +## Failure outcomes + +Every `CoordinatorFailure` includes an `outcome`: + +- `not_dispatched`: local validation, offline gating, or an explicit relay + terminal proves the operation did not dispatch. +- `sent`: an event frame reached the active transport; delivery is not implied. +- `accepted`: a call was accepted before its terminal failure. +- `applied`: a state mutation was acknowledged by the relay. +- `outcome_unknown`: transport loss, deadline, or post-accept cancellation means + an external effect may already have happened. + +Treat `outcome_unknown` as uncertainty, never as rollback. MiakAPI does not turn +an uncertain physical effect into a safe automatic retry. + +## Lifecycle and cleanup + +`start()` may be called once. `stop()` is idempotent and repeated calls return the +same terminal promise. It aborts token and handler work, settles pending +operations conservatively, removes listeners, and closes the owned socket. + +```ts +await coordinator.stop({ deadlineMs: 5_000 }); +``` + +`deadlineMs` bounds cleanup even when an injected dependency ignores its abort +signal. + +## Migration from MiakAPI 3 + +MiakAPI 4 removes the legacy `Miakapi(home, id, secret)` constructor, Firestore +lookup, mutable `home.variables`, UI callbacks, and notification helpers. Those +APIs depended on the retired Miakapp 3 transport and are not emulated. + +Integrations now: + +1. obtain short-lived access material through an `AccessTokenProvider`; +2. declare complete state, ACL, event, and function slices; +3. wait for `start()` readiness before issuing effects; +4. handle uncertainty explicitly through typed failures. + +For UI automation and agent-driven homes, use [miakapp.com](https://miakapp.com/) +instead of building against the retired page-callback protocol. + +## Protocol and conformance + +The public TypeScript API and wire codec are pinned to the Miakapp-V3 coordinator +contract at an immutable commit in [`contracts/miakapp-v3.json`](contracts/miakapp-v3.json). +The external conformance subject runs the real SDK against a deterministic relay +and must pass every scenario in the `sdk` profile. + +```sh +bun install --frozen-lockfile +bun run check +``` + +The check includes strict type checking, unit and adversarial tests, a Node.js +package smoke test, canonical external conformance, and an npm package dry run. + +## License + +[ISC](LICENSE) diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..afc6bb2 --- /dev/null +++ b/bun.lock @@ -0,0 +1,79 @@ +{ + "lockfileVersion": 1, + "workspaces": { + "": { + "name": "miakapi", + "dependencies": { + "@msgpack/msgpack": "3.1.3", + "ws": "8.21.3", + }, + "devDependencies": { + "@types/bun": "1.2.23", + "@types/node": "22.20.1", + "@types/ws": "8.18.1", + "typescript": "7.0.2", + }, + }, + }, + "packages": { + "@msgpack/msgpack": ["@msgpack/msgpack@3.1.3", "", {}, "sha512-47XIizs9XZXvuJgoaJUIE2lFoID8ugvc0jzSHP+Ptfk8nTbnR8g788wv48N03Kx0UkAv559HWRQ3yzOgzlRNUA=="], + + "@types/bun": ["@types/bun@1.2.23", "", { "dependencies": { "bun-types": "1.2.23" } }, "sha512-le8ueOY5b6VKYf19xT3McVbXqLqmxzPXHsQT/q9JHgikJ2X22wyTW3g3ohz2ZMnp7dod6aduIiq8A14Xyimm0A=="], + + "@types/node": ["@types/node@22.20.1", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q=="], + + "@types/react": ["@types/react@19.2.18", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w=="], + + "@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="], + + "@typescript/typescript-aix-ppc64": ["@typescript/typescript-aix-ppc64@7.0.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ=="], + + "@typescript/typescript-darwin-arm64": ["@typescript/typescript-darwin-arm64@7.0.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA=="], + + "@typescript/typescript-darwin-x64": ["@typescript/typescript-darwin-x64@7.0.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA=="], + + "@typescript/typescript-freebsd-arm64": ["@typescript/typescript-freebsd-arm64@7.0.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ=="], + + "@typescript/typescript-freebsd-x64": ["@typescript/typescript-freebsd-x64@7.0.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw=="], + + "@typescript/typescript-linux-arm": ["@typescript/typescript-linux-arm@7.0.2", "", { "os": "linux", "cpu": "arm" }, "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ=="], + + "@typescript/typescript-linux-arm64": ["@typescript/typescript-linux-arm64@7.0.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ=="], + + "@typescript/typescript-linux-loong64": ["@typescript/typescript-linux-loong64@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ=="], + + "@typescript/typescript-linux-mips64el": ["@typescript/typescript-linux-mips64el@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA=="], + + "@typescript/typescript-linux-ppc64": ["@typescript/typescript-linux-ppc64@7.0.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA=="], + + "@typescript/typescript-linux-riscv64": ["@typescript/typescript-linux-riscv64@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ=="], + + "@typescript/typescript-linux-s390x": ["@typescript/typescript-linux-s390x@7.0.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw=="], + + "@typescript/typescript-linux-x64": ["@typescript/typescript-linux-x64@7.0.2", "", { "os": "linux", "cpu": "x64" }, "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A=="], + + "@typescript/typescript-netbsd-arm64": ["@typescript/typescript-netbsd-arm64@7.0.2", "", { "os": "none", "cpu": "arm64" }, "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA=="], + + "@typescript/typescript-netbsd-x64": ["@typescript/typescript-netbsd-x64@7.0.2", "", { "os": "none", "cpu": "x64" }, "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA=="], + + "@typescript/typescript-openbsd-arm64": ["@typescript/typescript-openbsd-arm64@7.0.2", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ=="], + + "@typescript/typescript-openbsd-x64": ["@typescript/typescript-openbsd-x64@7.0.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg=="], + + "@typescript/typescript-sunos-x64": ["@typescript/typescript-sunos-x64@7.0.2", "", { "os": "sunos", "cpu": "x64" }, "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g=="], + + "@typescript/typescript-win32-arm64": ["@typescript/typescript-win32-arm64@7.0.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ=="], + + "@typescript/typescript-win32-x64": ["@typescript/typescript-win32-x64@7.0.2", "", { "os": "win32", "cpu": "x64" }, "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g=="], + + "bun-types": ["bun-types@1.2.23", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-R9f0hKAZXgFU3mlrA0YpE/fiDvwV0FT9rORApt2aQVWSuJDzZOyB5QLc0N/4HF57CS8IXJ6+L5E4W1bW6NS2Aw=="], + + "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], + + "typescript": ["typescript@7.0.2", "", { "optionalDependencies": { "@typescript/typescript-aix-ppc64": "7.0.2", "@typescript/typescript-darwin-arm64": "7.0.2", "@typescript/typescript-darwin-x64": "7.0.2", "@typescript/typescript-freebsd-arm64": "7.0.2", "@typescript/typescript-freebsd-x64": "7.0.2", "@typescript/typescript-linux-arm": "7.0.2", "@typescript/typescript-linux-arm64": "7.0.2", "@typescript/typescript-linux-loong64": "7.0.2", "@typescript/typescript-linux-mips64el": "7.0.2", "@typescript/typescript-linux-ppc64": "7.0.2", "@typescript/typescript-linux-riscv64": "7.0.2", "@typescript/typescript-linux-s390x": "7.0.2", "@typescript/typescript-linux-x64": "7.0.2", "@typescript/typescript-netbsd-arm64": "7.0.2", "@typescript/typescript-netbsd-x64": "7.0.2", "@typescript/typescript-openbsd-arm64": "7.0.2", "@typescript/typescript-openbsd-x64": "7.0.2", "@typescript/typescript-sunos-x64": "7.0.2", "@typescript/typescript-win32-arm64": "7.0.2", "@typescript/typescript-win32-x64": "7.0.2" }, "bin": { "tsc": "bin/tsc" } }, "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA=="], + + "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + + "ws": ["ws@8.21.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw=="], + } +} diff --git a/contracts/miakapp-v3.json b/contracts/miakapp-v3.json new file mode 100644 index 0000000..53bd452 --- /dev/null +++ b/contracts/miakapp-v3.json @@ -0,0 +1,6 @@ +{ + "repository": "https://github.com/Miakapp/Miakapp-V3.git", + "commit": "b927789691dd8cdebc91b673853cdc6711fe057d", + "profile": "sdk", + "schema": "miakapp.coordinator-contract/1" +} diff --git a/main.js b/main.js deleted file mode 100644 index 8d9e1ff..0000000 --- a/main.js +++ /dev/null @@ -1,305 +0,0 @@ -const https = require('https'); -const WebSocketClient = require('websocket').client; -const miakode = require('./miakode'); - -function getHome(homeID) { - return new Promise((cb, err) => { - https.get(`https://firestore.googleapis.com/v1/projects -/miakapp-3/databases/(default)/documents/homes/${homeID}`, (res) => { - let data = ''; - res.on('data', (c) => { data += c; }); - res.on('close', () => { - data = JSON.parse(data); - if (data.fields && data.fields.name) { - cb({ - id: homeID, - name: data.fields.name.stringValue, - server: data.fields.server.stringValue, - }); - } else err(new Error('Wrong homeID')); - }); - }); - }); -} - -function parsePacket(packet) { - if (!packet.binaryData) return { type: 'unknown' }; - - const parsed = packet.binaryData.toString(); - return { - type: parsed[0], - data: parsed.substring(1), - }; -} - -const connect = async (credentials, { - onReady, onHomeUpdate, onUserLogin, onUserAction, -}) => { - const homeDoc = await getHome(credentials.home); - if (!homeDoc.server) throw new Error('There is no selected server for this home'); - - const client = new WebSocketClient(); - function newSocket() { - console.log('Connecting to', homeDoc.server); - client.connect(`wss://${homeDoc.server}/${homeDoc.id}/`, null, '//coordinator.miakapp'); - } - - let socket = null; - - function sendPacket(type, data) { - if (socket) socket.sendBytes(Buffer.from(`${type}${data}`)); - } - - client.on('connect', (s) => { - console.log('Coordinator connected'); - socket = s; - - s.on('message', (packet) => { - const msg = parsePacket(packet); - - if (msg.type === '\x30') { // PING - sendPacket('\x40', msg.data); - return; - } - - if (msg.type === '\x31') { // USERLIST - const users = msg.data.split('\x00').filter((u) => u).map((u) => { - const [id, displayName, groups] = u.split('\x01'); - - return { - id: id.substring(2), - displayName, - isAdmin: (id[0] === '1'), - notifications: (id[1] === '1'), - groups: groups.split('\x02').filter((g) => g), - }; - }); - - onHomeUpdate(users); - return; - } - - if (msg.type === '\x32') { // USER CONNECT - const parsed = miakode.string.decode(msg.data); - const userClient = parsed.substring(1).split('@'); - - onUserLogin({ - type: ['DISCONNECT', 'CONNECT'][parsed[0]], - connectionUID: userClient[0], - user: userClient[1], - }); - return; - } - - if (msg.type === '\x33') { // USER ACTION - const [user, type, id, name, value] = miakode.array.decode(msg.data); - onUserAction({ - user, - type: (type === '1') ? 'input' : 'click', - input: { id, name, value }, - }); - return; - } - - if (msg.type === '\x00') { // LOGGED - onReady(); - return; - } - - console.log('Unknown packet', msg); - }); - - sendPacket('\x04', miakode.array.encode([ - credentials.home, - credentials.id, - credentials.secret, - ])); - - s.on('close', (code, desc) => { - console.log('CLOSE', code, desc); - if ([4001, 4005].includes(code)) return; - setTimeout(newSocket, 1000); - }); - }); - - client.on('connectFailed', () => { - console.log('Coordinator failed connect'); - setTimeout(newSocket, 1000); - }); - - newSocket(); - - return { - emitCallback(data) { - sendPacket('\x41', miakode.object.encode(data)); - }, - emitNotif(userID, { - title, body, tag, image, - }) { - sendPacket('\x42', miakode.array.encode([ - userID, title, body, tag, image, - ])); - }, - reconnect() { - if (socket && socket.close) { - socket.close(); - } - }, - }; -}; - -/** - * User login event data - * @typedef {Object} UserLoginEvent - * @property {User} user - * @property {'CONNECT' | 'DISCONNECT'} type Event type - * @property {number} connectionUID ID of connection - */ - -/** - * DOM input (or button) element in a Miakapp page - * @typedef InputElement - * @property {string} id ID of DOM element - * @property {string} name Name of DOM element - * @property {string} value Value of input element (if exists) - */ - -/** - * User action event data - * @typedef {Object} UserActionEvent - * @property {User} user User who interacted with an input - * @property {'click' | 'input'} type Event type ('click' or 'input') - * @property {InputElement} input Input the user interacted with - */ - -/** - * Represents a push notification - * @typedef {Object} Notification - * @property {string} title Notification title - * @property {string} body Notification body - * @property {string=} tag Notification tag - * @property {string=} image Notification image - */ - -/** - * User instance - * @typedef {Object} User - * @property {string} id ID of the user - * @property {string} displayName Display name of the user - * @property {boolean} isAdmin True if the user is admin of the home - * @property {boolean} notifications True if the user has enabled notifications - * @property {string[]} groups List of group names of the user - * @property {(notification: Notification) => void} sendPush Send push notification - */ - -/** - * Instance of miakapp home - * @typedef {Object} Home - * @property {User[]} users List of users who have access to the home - * @property {Object} variables Dynamic variables to inject in your pages - * @property {(modifs: {}) => void} commit Send data modifications to users - * @property {(userID: string, notification: Notification) => void - * } sendNotif Send push notification to user - * @property {(callback: () => void) => void} onReady Event that handles when API is ready - * @property {(callback: (users: User[]) => void) => void - * } onUpdate Event that handles when an update of home settings happens - * @property {(callback: (event: UserLoginEvent) => void) => void - * } onUserLogin Event that handles when a user connects or disconnects - * @property {(callback: (action: UserActionEvent) => void) => void - * } onUserAction Event that handles when a user interact with a page - * @property {() => void} reconnect Restart connection to the server - */ - -/** - * Creates a home instance - * @param {string} home Home ID (in your URL) - * @param {string} id Coordinator ID (default is "main") - * @param {string} secret Coordinator secret token - * @returns {Home} Returns an instance of home - */ -module.exports = function Miakapi(home, id, secret) { - /** @type {(() => void)[]} */ - const readyCallbacks = []; - /** @type {((users: User[]) => void)[]} */ - const userlistUpdateCallbacks = []; - /** @type {((event: UserLoginEvent) => void)[]} */ - const userLoginCallbacks = []; - /** @type {((action: UserActionEvent) => void)[]} */ - const userActionCallbacks = []; - - const client = { - emitCallback() { return false; }, - emitNotif() { return false; }, - reconnect() { return false; }, - }; - - /** @type {Home} */ - const thisHome = { - users: [], - variables: {}, - - commit(modifs = {}) { - Object.assign(this.variables, modifs); - client.emitCallback(this.variables); - }, - - sendNotif(userID, notification = {}) { - client.emitNotif(userID, notification); - }, - - onReady(callback) { - readyCallbacks.push(callback); - }, - onUpdate(callback) { - userlistUpdateCallbacks.push(callback); - }, - onUserLogin(callback) { - userLoginCallbacks.push(callback); - }, - onUserAction(callback) { - userActionCallbacks.push(callback); - }, - - reconnect() { - client.reconnect(); - }, - }; - - connect({ home, id, secret }, { - onReady() { - readyCallbacks.forEach((h) => h()); - }, - onHomeUpdate(data) { - thisHome.users = data.map((u) => ({ - ...u, - sendPush(notification) { - client.emitNotif(u.id, notification); - }, - })); - - userlistUpdateCallbacks.forEach((h) => h(thisHome.users)); - }, - onUserLogin(data) { - const eventData = { - ...data, - user: thisHome.users.find((u) => u.id === data.user), - }; - - userLoginCallbacks.forEach((h) => h(eventData)); - }, - onUserAction(data) { - const eventData = { - ...data, - user: thisHome.users.find((u) => u.id === data.user), - }; - - userActionCallbacks.forEach((h) => h(eventData)); - }, - }).then(({ emitCallback, emitNotif, reconnect }) => { - client.emitCallback = emitCallback; - client.emitNotif = emitNotif; - client.reconnect = reconnect; - }); - - return thisHome; -}; diff --git a/miakode.js b/miakode.js deleted file mode 100644 index 4b24e2c..0000000 --- a/miakode.js +++ /dev/null @@ -1,56 +0,0 @@ -module.exports = { - object: { - encode(o = {}) { - let e = Object.keys(o).map((k) => `${k}\x01${o[k]}`).join('\x00').split(''); - let m = null; - e.forEach((c) => { m = (!m || m > c) ? c.charCodeAt(0) : m; }); - e = e.map((c) => String.fromCharCode(c.charCodeAt(0) - m)).join(''); - - return `${String.fromCharCode(m)}${e}`; - }, - - decode(s) { - const m = s[0].charCodeAt(0); - let e = s.substring(1).split(''); - e = e.map((c) => String.fromCharCode(c.charCodeAt(0) + m)).join(''); - const o = {}; - e.split('\x00').forEach((l) => { - const p = l.split('\x01'); - [, o[p[0]]] = p; - }); - - return o; - }, - }, - - array: { - encode(a = []) { - let e = a.join('\x00').split(''); - let m = null; - e.forEach((c) => { m = (!m || m > c) ? c.charCodeAt(0) : m; }); - e = e.map((c) => String.fromCharCode(c.charCodeAt(0) - m)).join(''); - - return `${String.fromCharCode(m)}${e}`; - }, - - decode: (s) => s.substring(1).split('') - .map((c) => String.fromCharCode(c.charCodeAt(0) + s[0].charCodeAt(0))) - .join('') - .split('\x00'), - }, - - string: { - encode(s = '') { - let e = s.split(''); - let m = null; - e.forEach((c) => { m = (!m || m > c) ? c.charCodeAt(0) : m; }); - e = e.map((c) => String.fromCharCode(c.charCodeAt(0) - m)).join(''); - - return `${String.fromCharCode(m)}${e}`; - }, - - decode: (s = '') => s.substring(1).split('') - .map((c) => String.fromCharCode(c.charCodeAt(0) + s[0].charCodeAt(0))) - .join(''), - }, -}; diff --git a/package-lock.json b/package-lock.json deleted file mode 100644 index 1e283cf..0000000 --- a/package-lock.json +++ /dev/null @@ -1,3529 +0,0 @@ -{ - "name": "miakapi", - "version": "3.0.31", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "miakapi", - "version": "3.0.31", - "license": "ISC", - "dependencies": { - "websocket": "^1.0.34" - }, - "devDependencies": { - "eslint": "^7.29.0", - "eslint-config-airbnb-base": "^14.2.1", - "eslint-plugin-import": "^2.23.4" - } - }, - "node_modules/@babel/code-frame": { - "version": "7.12.11", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.12.11.tgz", - "integrity": "sha512-Zt1yodBx1UcyiePMSkWnU4hPqhwq7hGi2nFL1LeA3EUl+q2LQx16MISgJ0+z7dnmgvP9QtIleuETGOiOH1RcIw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/highlight": "^7.10.4" - } - }, - "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/highlight": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.25.9.tgz", - "integrity": "sha512-llL88JShoCsth8fF8R4SJnIn+WLvR6ccFxu1H3FlMhDontdcmZWf2HgIZ7AIqV3Xcck1idlohrN4EUBQz6klbw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-validator-identifier": "^7.25.9", - "chalk": "^2.4.2", - "js-tokens": "^4.0.0", - "picocolors": "^1.0.0" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/highlight/node_modules/ansi-styles": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", - "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-convert": "^1.9.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/@babel/highlight/node_modules/chalk": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", - "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^3.2.1", - "escape-string-regexp": "^1.0.5", - "supports-color": "^5.3.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/@babel/highlight/node_modules/color-convert": { - "version": "1.9.3", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", - "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-name": "1.1.3" - } - }, - "node_modules/@babel/highlight/node_modules/color-name": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", - "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@babel/highlight/node_modules/escape-string-regexp": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", - "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.8.0" - } - }, - "node_modules/@babel/highlight/node_modules/has-flag": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", - "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/@babel/highlight/node_modules/supports-color": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", - "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-flag": "^3.0.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/@eslint/eslintrc": { - "version": "0.4.3", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-0.4.3.tgz", - "integrity": "sha512-J6KFFz5QCYUJq3pf0mjEcCJVERbzv71PUIDczuh9JkwGEzced6CO5ADLHB1rbf/+oPBtoPfMYNOpGDzCANlbXw==", - "dev": true, - "license": "MIT", - "dependencies": { - "ajv": "^6.12.4", - "debug": "^4.1.1", - "espree": "^7.3.0", - "globals": "^13.9.0", - "ignore": "^4.0.6", - "import-fresh": "^3.2.1", - "js-yaml": "^3.13.1", - "minimatch": "^3.0.4", - "strip-json-comments": "^3.1.1" - }, - "engines": { - "node": "^10.12.0 || >=12.0.0" - } - }, - "node_modules/@humanwhocodes/config-array": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.5.0.tgz", - "integrity": "sha512-FagtKFz74XrTl7y6HCzQpwDfXP0yhxe9lHLD1UZxjvZIcbyRz8zTFF/yYNfSfzU414eDwZ1SrO0Qvtyf+wFMQg==", - "deprecated": "Use @eslint/config-array instead", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@humanwhocodes/object-schema": "^1.2.0", - "debug": "^4.1.1", - "minimatch": "^3.0.4" - }, - "engines": { - "node": ">=10.10.0" - } - }, - "node_modules/@humanwhocodes/object-schema": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-1.2.1.tgz", - "integrity": "sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA==", - "deprecated": "Use @eslint/object-schema instead", - "dev": true, - "license": "BSD-3-Clause" - }, - "node_modules/@rtsao/scc": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", - "integrity": "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/json5": { - "version": "0.0.29", - "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz", - "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/acorn": { - "version": "7.4.1", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-7.4.1.tgz", - "integrity": "sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==", - "dev": true, - "license": "MIT", - "bin": { - "acorn": "bin/acorn" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/acorn-jsx": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", - "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" - } - }, - "node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/ansi-colors": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", - "integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/argparse": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", - "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", - "dev": true, - "license": "MIT", - "dependencies": { - "sprintf-js": "~1.0.2" - } - }, - "node_modules/array-buffer-byte-length": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", - "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "is-array-buffer": "^3.0.5" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/array-includes": { - "version": "3.1.9", - "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.9.tgz", - "integrity": "sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.4", - "define-properties": "^1.2.1", - "es-abstract": "^1.24.0", - "es-object-atoms": "^1.1.1", - "get-intrinsic": "^1.3.0", - "is-string": "^1.1.1", - "math-intrinsics": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/array.prototype.findlastindex": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.6.tgz", - "integrity": "sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.4", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.9", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "es-shim-unscopables": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/array.prototype.flat": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.3.tgz", - "integrity": "sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.5", - "es-shim-unscopables": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/array.prototype.flatmap": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.3.tgz", - "integrity": "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.5", - "es-shim-unscopables": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/arraybuffer.prototype.slice": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz", - "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "array-buffer-byte-length": "^1.0.1", - "call-bind": "^1.0.8", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.5", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.6", - "is-array-buffer": "^3.0.4" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/astral-regex": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz", - "integrity": "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/async-function": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", - "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/available-typed-arrays": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", - "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "possible-typed-array-names": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/bufferutil": { - "version": "4.0.9", - "resolved": "https://registry.npmjs.org/bufferutil/-/bufferutil-4.0.9.tgz", - "integrity": "sha512-WDtdLmJvAuNNPzByAYpRo2rF1Mmradw6gvWsQKf63476DDXmomT9zUiGypLcG4ibIM67vhAj8jJRdbmEws2Aqw==", - "hasInstallScript": true, - "license": "MIT", - "dependencies": { - "node-gyp-build": "^4.3.0" - }, - "engines": { - "node": ">=6.14.2" - } - }, - "node_modules/call-bind": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", - "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.0", - "es-define-property": "^1.0.0", - "get-intrinsic": "^1.2.4", - "set-function-length": "^1.2.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/call-bind-apply-helpers": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", - "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/call-bound": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", - "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "get-intrinsic": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/callsites": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", - "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, - "license": "MIT" - }, - "node_modules/concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true, - "license": "MIT" - }, - "node_modules/confusing-browser-globals": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/confusing-browser-globals/-/confusing-browser-globals-1.0.11.tgz", - "integrity": "sha512-JsPKdmh8ZkmnHxDk55FZ1TqVLvEQTvoByJZRN9jzI0UjxK/QgAmsphz7PGtqgPieQZ/CQcHWXCR7ATDNhGe+YA==", - "dev": true, - "license": "MIT" - }, - "node_modules/cross-spawn": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", - "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "dev": true, - "license": "MIT", - "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/d": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/d/-/d-1.0.2.tgz", - "integrity": "sha512-MOqHvMWF9/9MX6nza0KgvFH4HpMU0EF5uUDXqX/BtxtU8NfB0QzRtJ8Oe/6SuS4kbhyzVJwjd97EA4PKrzJ8bw==", - "license": "ISC", - "dependencies": { - "es5-ext": "^0.10.64", - "type": "^2.7.2" - }, - "engines": { - "node": ">=0.12" - } - }, - "node_modules/data-view-buffer": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", - "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "es-errors": "^1.3.0", - "is-data-view": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/data-view-byte-length": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz", - "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "es-errors": "^1.3.0", - "is-data-view": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/inspect-js" - } - }, - "node_modules/data-view-byte-offset": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz", - "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "is-data-view": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/deep-is": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", - "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/define-data-property": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", - "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-define-property": "^1.0.0", - "es-errors": "^1.3.0", - "gopd": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/define-properties": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", - "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", - "dev": true, - "license": "MIT", - "dependencies": { - "define-data-property": "^1.0.1", - "has-property-descriptors": "^1.0.0", - "object-keys": "^1.1.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/doctrine": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", - "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "esutils": "^2.0.2" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/dunder-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", - "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.1", - "es-errors": "^1.3.0", - "gopd": "^1.2.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, - "license": "MIT" - }, - "node_modules/enquirer": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/enquirer/-/enquirer-2.4.1.tgz", - "integrity": "sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-colors": "^4.1.1", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8.6" - } - }, - "node_modules/es-abstract": { - "version": "1.24.0", - "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.0.tgz", - "integrity": "sha512-WSzPgsdLtTcQwm4CROfS5ju2Wa1QQcVeT37jFjYzdFz1r9ahadC8B8/a4qxJxM+09F18iumCdRmlr96ZYkQvEg==", - "dev": true, - "license": "MIT", - "dependencies": { - "array-buffer-byte-length": "^1.0.2", - "arraybuffer.prototype.slice": "^1.0.4", - "available-typed-arrays": "^1.0.7", - "call-bind": "^1.0.8", - "call-bound": "^1.0.4", - "data-view-buffer": "^1.0.2", - "data-view-byte-length": "^1.0.2", - "data-view-byte-offset": "^1.0.1", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "es-set-tostringtag": "^2.1.0", - "es-to-primitive": "^1.3.0", - "function.prototype.name": "^1.1.8", - "get-intrinsic": "^1.3.0", - "get-proto": "^1.0.1", - "get-symbol-description": "^1.1.0", - "globalthis": "^1.0.4", - "gopd": "^1.2.0", - "has-property-descriptors": "^1.0.2", - "has-proto": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "internal-slot": "^1.1.0", - "is-array-buffer": "^3.0.5", - "is-callable": "^1.2.7", - "is-data-view": "^1.0.2", - "is-negative-zero": "^2.0.3", - "is-regex": "^1.2.1", - "is-set": "^2.0.3", - "is-shared-array-buffer": "^1.0.4", - "is-string": "^1.1.1", - "is-typed-array": "^1.1.15", - "is-weakref": "^1.1.1", - "math-intrinsics": "^1.1.0", - "object-inspect": "^1.13.4", - "object-keys": "^1.1.1", - "object.assign": "^4.1.7", - "own-keys": "^1.0.1", - "regexp.prototype.flags": "^1.5.4", - "safe-array-concat": "^1.1.3", - "safe-push-apply": "^1.0.0", - "safe-regex-test": "^1.1.0", - "set-proto": "^1.0.0", - "stop-iteration-iterator": "^1.1.0", - "string.prototype.trim": "^1.2.10", - "string.prototype.trimend": "^1.0.9", - "string.prototype.trimstart": "^1.0.8", - "typed-array-buffer": "^1.0.3", - "typed-array-byte-length": "^1.0.3", - "typed-array-byte-offset": "^1.0.4", - "typed-array-length": "^1.0.7", - "unbox-primitive": "^1.1.0", - "which-typed-array": "^1.1.19" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/es-define-property": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", - "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-errors": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-object-atoms": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-set-tostringtag": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", - "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.6", - "has-tostringtag": "^1.0.2", - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-shim-unscopables": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.1.0.tgz", - "integrity": "sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==", - "dev": true, - "license": "MIT", - "dependencies": { - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-to-primitive": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.0.tgz", - "integrity": "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-callable": "^1.2.7", - "is-date-object": "^1.0.5", - "is-symbol": "^1.0.4" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/es5-ext": { - "version": "0.10.64", - "resolved": "https://registry.npmjs.org/es5-ext/-/es5-ext-0.10.64.tgz", - "integrity": "sha512-p2snDhiLaXe6dahss1LddxqEm+SkuDvV8dnIQG0MWjyHpcMNfXKPE+/Cc0y+PhxJX3A4xGNeFCj5oc0BUh6deg==", - "hasInstallScript": true, - "license": "ISC", - "dependencies": { - "es6-iterator": "^2.0.3", - "es6-symbol": "^3.1.3", - "esniff": "^2.0.1", - "next-tick": "^1.1.0" - }, - "engines": { - "node": ">=0.10" - } - }, - "node_modules/es6-iterator": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/es6-iterator/-/es6-iterator-2.0.3.tgz", - "integrity": "sha512-zw4SRzoUkd+cl+ZoE15A9o1oQd920Bb0iOJMQkQhl3jNc03YqVjAhG7scf9C5KWRU/R13Orf588uCC6525o02g==", - "license": "MIT", - "dependencies": { - "d": "1", - "es5-ext": "^0.10.35", - "es6-symbol": "^3.1.1" - } - }, - "node_modules/es6-symbol": { - "version": "3.1.4", - "resolved": "https://registry.npmjs.org/es6-symbol/-/es6-symbol-3.1.4.tgz", - "integrity": "sha512-U9bFFjX8tFiATgtkJ1zg25+KviIXpgRvRHS8sau3GfhVzThRQrOeksPeT0BWW2MNZs1OEWJ1DPXOQMn0KKRkvg==", - "license": "ISC", - "dependencies": { - "d": "^1.0.2", - "ext": "^1.7.0" - }, - "engines": { - "node": ">=0.12" - } - }, - "node_modules/escape-string-regexp": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", - "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/eslint": { - "version": "7.32.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-7.32.0.tgz", - "integrity": "sha512-VHZ8gX+EDfz+97jGcgyGCyRia/dPOd6Xh9yPv8Bl1+SoaIwD+a/vlrOmGRUyOYu7MwUhc7CxqeaDZU13S4+EpA==", - "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "7.12.11", - "@eslint/eslintrc": "^0.4.3", - "@humanwhocodes/config-array": "^0.5.0", - "ajv": "^6.10.0", - "chalk": "^4.0.0", - "cross-spawn": "^7.0.2", - "debug": "^4.0.1", - "doctrine": "^3.0.0", - "enquirer": "^2.3.5", - "escape-string-regexp": "^4.0.0", - "eslint-scope": "^5.1.1", - "eslint-utils": "^2.1.0", - "eslint-visitor-keys": "^2.0.0", - "espree": "^7.3.1", - "esquery": "^1.4.0", - "esutils": "^2.0.2", - "fast-deep-equal": "^3.1.3", - "file-entry-cache": "^6.0.1", - "functional-red-black-tree": "^1.0.1", - "glob-parent": "^5.1.2", - "globals": "^13.6.0", - "ignore": "^4.0.6", - "import-fresh": "^3.0.0", - "imurmurhash": "^0.1.4", - "is-glob": "^4.0.0", - "js-yaml": "^3.13.1", - "json-stable-stringify-without-jsonify": "^1.0.1", - "levn": "^0.4.1", - "lodash.merge": "^4.6.2", - "minimatch": "^3.0.4", - "natural-compare": "^1.4.0", - "optionator": "^0.9.1", - "progress": "^2.0.0", - "regexpp": "^3.1.0", - "semver": "^7.2.1", - "strip-ansi": "^6.0.0", - "strip-json-comments": "^3.1.0", - "table": "^6.0.9", - "text-table": "^0.2.0", - "v8-compile-cache": "^2.0.3" - }, - "bin": { - "eslint": "bin/eslint.js" - }, - "engines": { - "node": "^10.12.0 || >=12.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/eslint-config-airbnb-base": { - "version": "14.2.1", - "resolved": "https://registry.npmjs.org/eslint-config-airbnb-base/-/eslint-config-airbnb-base-14.2.1.tgz", - "integrity": "sha512-GOrQyDtVEc1Xy20U7vsB2yAoB4nBlfH5HZJeatRXHleO+OS5Ot+MWij4Dpltw4/DyIkqUfqz1epfhVR5XWWQPA==", - "dev": true, - "license": "MIT", - "dependencies": { - "confusing-browser-globals": "^1.0.10", - "object.assign": "^4.1.2", - "object.entries": "^1.1.2" - }, - "engines": { - "node": ">= 6" - }, - "peerDependencies": { - "eslint": "^5.16.0 || ^6.8.0 || ^7.2.0", - "eslint-plugin-import": "^2.22.1" - } - }, - "node_modules/eslint-import-resolver-node": { - "version": "0.3.9", - "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.9.tgz", - "integrity": "sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "^3.2.7", - "is-core-module": "^2.13.0", - "resolve": "^1.22.4" - } - }, - "node_modules/eslint-import-resolver-node/node_modules/debug": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", - "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.1" - } - }, - "node_modules/eslint-module-utils": { - "version": "2.12.1", - "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.12.1.tgz", - "integrity": "sha512-L8jSWTze7K2mTg0vos/RuLRS5soomksDPoJLXIslC7c8Wmut3bx7CPpJijDcBZtxQ5lrbUdM+s0OlNbz0DCDNw==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "^3.2.7" - }, - "engines": { - "node": ">=4" - }, - "peerDependenciesMeta": { - "eslint": { - "optional": true - } - } - }, - "node_modules/eslint-module-utils/node_modules/debug": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", - "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.1" - } - }, - "node_modules/eslint-plugin-import": { - "version": "2.32.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz", - "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@rtsao/scc": "^1.1.0", - "array-includes": "^3.1.9", - "array.prototype.findlastindex": "^1.2.6", - "array.prototype.flat": "^1.3.3", - "array.prototype.flatmap": "^1.3.3", - "debug": "^3.2.7", - "doctrine": "^2.1.0", - "eslint-import-resolver-node": "^0.3.9", - "eslint-module-utils": "^2.12.1", - "hasown": "^2.0.2", - "is-core-module": "^2.16.1", - "is-glob": "^4.0.3", - "minimatch": "^3.1.2", - "object.fromentries": "^2.0.8", - "object.groupby": "^1.0.3", - "object.values": "^1.2.1", - "semver": "^6.3.1", - "string.prototype.trimend": "^1.0.9", - "tsconfig-paths": "^3.15.0" - }, - "engines": { - "node": ">=4" - }, - "peerDependencies": { - "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9" - } - }, - "node_modules/eslint-plugin-import/node_modules/debug": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", - "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.1" - } - }, - "node_modules/eslint-plugin-import/node_modules/doctrine": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", - "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "esutils": "^2.0.2" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/eslint-plugin-import/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/eslint-scope": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", - "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "esrecurse": "^4.3.0", - "estraverse": "^4.1.1" - }, - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/eslint-utils": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/eslint-utils/-/eslint-utils-2.1.0.tgz", - "integrity": "sha512-w94dQYoauyvlDc43XnGB8lU3Zt713vNChgt4EWwhXAP2XkBvndfxF0AgIqKOOasjPIPzj9JqgwkwbCYD0/V3Zg==", - "dev": true, - "license": "MIT", - "dependencies": { - "eslint-visitor-keys": "^1.1.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/mysticatea" - } - }, - "node_modules/eslint-utils/node_modules/eslint-visitor-keys": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-1.3.0.tgz", - "integrity": "sha512-6J72N8UNa462wa/KFODt/PJ3IU60SDpC3QXC1Hjc1BXXpfL2C9R5+AU7jhe0F6GREqVMh4Juu+NY7xn+6dipUQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=4" - } - }, - "node_modules/eslint-visitor-keys": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-2.1.0.tgz", - "integrity": "sha512-0rSmRBzXgDzIsD6mGdJgevzgezI534Cer5L/vyMX0kHzT/jiB43jRhd9YUlMGYLQy2zprNmoT8qasCGtY+QaKw==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=10" - } - }, - "node_modules/esniff": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/esniff/-/esniff-2.0.1.tgz", - "integrity": "sha512-kTUIGKQ/mDPFoJ0oVfcmyJn4iBDRptjNVIzwIFR7tqWXdVI9xfA2RMwY/gbSpJG3lkdWNEjLap/NqVHZiJsdfg==", - "license": "ISC", - "dependencies": { - "d": "^1.0.1", - "es5-ext": "^0.10.62", - "event-emitter": "^0.3.5", - "type": "^2.7.2" - }, - "engines": { - "node": ">=0.10" - } - }, - "node_modules/espree": { - "version": "7.3.1", - "resolved": "https://registry.npmjs.org/espree/-/espree-7.3.1.tgz", - "integrity": "sha512-v3JCNCE64umkFpmkFGqzVKsOT0tN1Zr+ueqLZfpV1Ob8e+CEgPWa+OxCoGH3tnhimMKIaBm4m/vaRpJ/krRz2g==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "acorn": "^7.4.0", - "acorn-jsx": "^5.3.1", - "eslint-visitor-keys": "^1.3.0" - }, - "engines": { - "node": "^10.12.0 || >=12.0.0" - } - }, - "node_modules/espree/node_modules/eslint-visitor-keys": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-1.3.0.tgz", - "integrity": "sha512-6J72N8UNa462wa/KFODt/PJ3IU60SDpC3QXC1Hjc1BXXpfL2C9R5+AU7jhe0F6GREqVMh4Juu+NY7xn+6dipUQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=4" - } - }, - "node_modules/esprima": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", - "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", - "dev": true, - "license": "BSD-2-Clause", - "bin": { - "esparse": "bin/esparse.js", - "esvalidate": "bin/esvalidate.js" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/esquery": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", - "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "estraverse": "^5.1.0" - }, - "engines": { - "node": ">=0.10" - } - }, - "node_modules/esquery/node_modules/estraverse": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", - "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=4.0" - } - }, - "node_modules/esrecurse": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", - "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "estraverse": "^5.2.0" - }, - "engines": { - "node": ">=4.0" - } - }, - "node_modules/esrecurse/node_modules/estraverse": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", - "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=4.0" - } - }, - "node_modules/estraverse": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", - "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=4.0" - } - }, - "node_modules/esutils": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", - "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/event-emitter": { - "version": "0.3.5", - "resolved": "https://registry.npmjs.org/event-emitter/-/event-emitter-0.3.5.tgz", - "integrity": "sha512-D9rRn9y7kLPnJ+hMq7S/nhvoKwwvVJahBi2BPmx3bvbsEdK3W9ii8cBSGjP+72/LnM4n6fo3+dkCX5FeTQruXA==", - "license": "MIT", - "dependencies": { - "d": "1", - "es5-ext": "~0.10.14" - } - }, - "node_modules/ext": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/ext/-/ext-1.7.0.tgz", - "integrity": "sha512-6hxeJYaL110a9b5TEJSj0gojyHQAmA2ch5Os+ySCiA1QGdS697XWY1pzsrSjqA9LDEEgdB/KypIlR59RcLuHYw==", - "license": "ISC", - "dependencies": { - "type": "^2.7.2" - } - }, - "node_modules/fast-deep-equal": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/fast-json-stable-stringify": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", - "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", - "dev": true, - "license": "MIT" - }, - "node_modules/fast-levenshtein": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", - "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", - "dev": true, - "license": "MIT" - }, - "node_modules/fast-uri": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", - "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "BSD-3-Clause" - }, - "node_modules/file-entry-cache": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", - "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", - "dev": true, - "license": "MIT", - "dependencies": { - "flat-cache": "^3.0.4" - }, - "engines": { - "node": "^10.12.0 || >=12.0.0" - } - }, - "node_modules/flat-cache": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz", - "integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==", - "dev": true, - "license": "MIT", - "dependencies": { - "flatted": "^3.2.9", - "keyv": "^4.5.3", - "rimraf": "^3.0.2" - }, - "engines": { - "node": "^10.12.0 || >=12.0.0" - } - }, - "node_modules/flatted": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", - "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", - "dev": true, - "license": "ISC" - }, - "node_modules/for-each": { - "version": "0.3.5", - "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", - "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-callable": "^1.2.7" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/fs.realpath": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", - "dev": true, - "license": "ISC" - }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/function.prototype.name": { - "version": "1.1.8", - "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.8.tgz", - "integrity": "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.3", - "define-properties": "^1.2.1", - "functions-have-names": "^1.2.3", - "hasown": "^2.0.2", - "is-callable": "^1.2.7" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/functional-red-black-tree": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz", - "integrity": "sha512-dsKNQNdj6xA3T+QlADDA7mOSlX0qiMINjn0cgr+eGHGsbSHzTabcIogz2+p/iqP1Xs6EP/sS2SbqH+brGTbq0g==", - "dev": true, - "license": "MIT" - }, - "node_modules/functions-have-names": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", - "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/generator-function": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", - "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/get-intrinsic": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", - "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "function-bind": "^1.1.2", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "math-intrinsics": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", - "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "dev": true, - "license": "MIT", - "dependencies": { - "dunder-proto": "^1.0.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/get-symbol-description": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", - "integrity": "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.6" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "deprecated": "Glob versions prior to v9 are no longer supported", - "dev": true, - "license": "ISC", - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/globals": { - "version": "13.24.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", - "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "type-fest": "^0.20.2" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/globalthis": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", - "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "define-properties": "^1.2.1", - "gopd": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/gopd": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", - "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-bigints": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", - "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/has-property-descriptors": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", - "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-define-property": "^1.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-proto": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.2.0.tgz", - "integrity": "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "dunder-proto": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-symbols": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", - "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-tostringtag": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", - "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-symbols": "^1.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/ignore": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-4.0.6.tgz", - "integrity": "sha512-cyFDKrqc/YdcWFniJhzI42+AzS+gNwmUzOSFcRCQYwySuBBBy/KjuxWLZ/FHEH6Moq1NizMOBWyTcv8O4OZIMg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, - "node_modules/import-fresh": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", - "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "parent-module": "^1.0.0", - "resolve-from": "^4.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/imurmurhash": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", - "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.8.19" - } - }, - "node_modules/inflight": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", - "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", - "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", - "dev": true, - "license": "ISC", - "dependencies": { - "once": "^1.3.0", - "wrappy": "1" - } - }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/internal-slot": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", - "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "hasown": "^2.0.2", - "side-channel": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/is-array-buffer": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", - "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.3", - "get-intrinsic": "^1.2.6" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-async-function": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz", - "integrity": "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "async-function": "^1.0.0", - "call-bound": "^1.0.3", - "get-proto": "^1.0.1", - "has-tostringtag": "^1.0.2", - "safe-regex-test": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-bigint": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", - "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-bigints": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-boolean-object": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", - "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "has-tostringtag": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-callable": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", - "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-core-module": { - "version": "2.16.1", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", - "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", - "dev": true, - "license": "MIT", - "dependencies": { - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-data-view": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.2.tgz", - "integrity": "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "get-intrinsic": "^1.2.6", - "is-typed-array": "^1.1.13" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-date-object": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", - "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "has-tostringtag": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-extglob": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-finalizationregistry": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz", - "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/is-generator-function": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", - "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.4", - "generator-function": "^2.0.0", - "get-proto": "^1.0.1", - "has-tostringtag": "^1.0.2", - "safe-regex-test": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-glob": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", - "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-extglob": "^2.1.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-map": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", - "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-negative-zero": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", - "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-number-object": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", - "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "has-tostringtag": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-regex": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", - "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "gopd": "^1.2.0", - "has-tostringtag": "^1.0.2", - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-set": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", - "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-shared-array-buffer": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", - "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-string": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", - "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "has-tostringtag": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-symbol": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", - "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "has-symbols": "^1.1.0", - "safe-regex-test": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-typed-array": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", - "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "which-typed-array": "^1.1.16" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-typedarray": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", - "integrity": "sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==", - "license": "MIT" - }, - "node_modules/is-weakmap": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", - "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-weakref": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.1.tgz", - "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-weakset": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", - "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "get-intrinsic": "^1.2.6" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/isarray": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", - "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", - "dev": true, - "license": "MIT" - }, - "node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true, - "license": "ISC" - }, - "node_modules/js-tokens": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/js-yaml": { - "version": "3.14.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", - "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", - "dev": true, - "license": "MIT", - "dependencies": { - "argparse": "^1.0.7", - "esprima": "^4.0.0" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, - "node_modules/json-buffer": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", - "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true, - "license": "MIT" - }, - "node_modules/json-stable-stringify-without-jsonify": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", - "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/json5": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz", - "integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==", - "dev": true, - "license": "MIT", - "dependencies": { - "minimist": "^1.2.0" - }, - "bin": { - "json5": "lib/cli.js" - } - }, - "node_modules/keyv": { - "version": "4.5.4", - "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", - "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", - "dev": true, - "license": "MIT", - "dependencies": { - "json-buffer": "3.0.1" - } - }, - "node_modules/levn": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", - "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "prelude-ls": "^1.2.1", - "type-check": "~0.4.0" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/lodash.merge": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", - "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/lodash.truncate": { - "version": "4.4.2", - "resolved": "https://registry.npmjs.org/lodash.truncate/-/lodash.truncate-4.4.2.tgz", - "integrity": "sha512-jttmRe7bRse52OsWIMDLaXxWqRAmtIUccAQ3garviCqJjafXOfNMO0yMfNpdD6zbGaTU0P5Nz7e7gAT6cKmJRw==", - "dev": true, - "license": "MIT" - }, - "node_modules/math-intrinsics": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/minimist": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", - "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, - "license": "MIT" - }, - "node_modules/natural-compare": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", - "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", - "dev": true, - "license": "MIT" - }, - "node_modules/next-tick": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/next-tick/-/next-tick-1.1.0.tgz", - "integrity": "sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ==", - "license": "ISC" - }, - "node_modules/node-gyp-build": { - "version": "4.8.4", - "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", - "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", - "license": "MIT", - "bin": { - "node-gyp-build": "bin.js", - "node-gyp-build-optional": "optional.js", - "node-gyp-build-test": "build-test.js" - } - }, - "node_modules/object-inspect": { - "version": "1.13.4", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", - "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/object-keys": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", - "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/object.assign": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", - "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.3", - "define-properties": "^1.2.1", - "es-object-atoms": "^1.0.0", - "has-symbols": "^1.1.0", - "object-keys": "^1.1.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/object.entries": { - "version": "1.1.9", - "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.9.tgz", - "integrity": "sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.4", - "define-properties": "^1.2.1", - "es-object-atoms": "^1.1.1" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/object.fromentries": { - "version": "2.0.8", - "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.8.tgz", - "integrity": "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.7", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.2", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/object.groupby": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/object.groupby/-/object.groupby-1.0.3.tgz", - "integrity": "sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.7", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/object.values": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.2.1.tgz", - "integrity": "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.3", - "define-properties": "^1.2.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/once": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "dev": true, - "license": "ISC", - "dependencies": { - "wrappy": "1" - } - }, - "node_modules/optionator": { - "version": "0.9.4", - "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", - "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", - "dev": true, - "license": "MIT", - "dependencies": { - "deep-is": "^0.1.3", - "fast-levenshtein": "^2.0.6", - "levn": "^0.4.1", - "prelude-ls": "^1.2.1", - "type-check": "^0.4.0", - "word-wrap": "^1.2.5" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/own-keys": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", - "integrity": "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==", - "dev": true, - "license": "MIT", - "dependencies": { - "get-intrinsic": "^1.2.6", - "object-keys": "^1.1.1", - "safe-push-apply": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/parent-module": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", - "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", - "dev": true, - "license": "MIT", - "dependencies": { - "callsites": "^3.0.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/path-is-absolute": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", - "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/path-key": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/path-parse": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", - "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", - "dev": true, - "license": "MIT" - }, - "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, - "license": "ISC" - }, - "node_modules/possible-typed-array-names": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", - "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/prelude-ls": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", - "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/progress": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", - "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/punycode": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", - "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/reflect.getprototypeof": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", - "integrity": "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.9", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.0.0", - "get-intrinsic": "^1.2.7", - "get-proto": "^1.0.1", - "which-builtin-type": "^1.2.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/regexp.prototype.flags": { - "version": "1.5.4", - "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", - "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "define-properties": "^1.2.1", - "es-errors": "^1.3.0", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "set-function-name": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/regexpp": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/regexpp/-/regexpp-3.2.0.tgz", - "integrity": "sha512-pq2bWo9mVD43nbts2wGv17XLiNLya+GklZ8kaDLV2Z08gDCsGpnKn9BFMepvWuHCbyVvY7J5o5+BVvoQbmlJLg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/mysticatea" - } - }, - "node_modules/require-from-string": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", - "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/resolve": { - "version": "1.22.11", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", - "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-core-module": "^2.16.1", - "path-parse": "^1.0.7", - "supports-preserve-symlinks-flag": "^1.0.0" - }, - "bin": { - "resolve": "bin/resolve" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/resolve-from": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", - "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/rimraf": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", - "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", - "deprecated": "Rimraf versions prior to v4 are no longer supported", - "dev": true, - "license": "ISC", - "dependencies": { - "glob": "^7.1.3" - }, - "bin": { - "rimraf": "bin.js" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/safe-array-concat": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz", - "integrity": "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.2", - "get-intrinsic": "^1.2.6", - "has-symbols": "^1.1.0", - "isarray": "^2.0.5" - }, - "engines": { - "node": ">=0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/safe-push-apply": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", - "integrity": "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "isarray": "^2.0.5" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/safe-regex-test": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", - "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "is-regex": "^1.2.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/semver": { - "version": "7.7.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", - "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/set-function-length": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", - "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", - "dev": true, - "license": "MIT", - "dependencies": { - "define-data-property": "^1.1.4", - "es-errors": "^1.3.0", - "function-bind": "^1.1.2", - "get-intrinsic": "^1.2.4", - "gopd": "^1.0.1", - "has-property-descriptors": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/set-function-name": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", - "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "define-data-property": "^1.1.4", - "es-errors": "^1.3.0", - "functions-have-names": "^1.2.3", - "has-property-descriptors": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/set-proto": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/set-proto/-/set-proto-1.0.0.tgz", - "integrity": "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==", - "dev": true, - "license": "MIT", - "dependencies": { - "dunder-proto": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/shebang-command": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, - "license": "MIT", - "dependencies": { - "shebang-regex": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/shebang-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/side-channel": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", - "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.3", - "side-channel-list": "^1.0.0", - "side-channel-map": "^1.0.1", - "side-channel-weakmap": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-list": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", - "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-map": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", - "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-weakmap": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", - "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3", - "side-channel-map": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/slice-ansi": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-4.0.0.tgz", - "integrity": "sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "astral-regex": "^2.0.0", - "is-fullwidth-code-point": "^3.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/slice-ansi?sponsor=1" - } - }, - "node_modules/sprintf-js": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", - "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", - "dev": true, - "license": "BSD-3-Clause" - }, - "node_modules/stop-iteration-iterator": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", - "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "internal-slot": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/string.prototype.trim": { - "version": "1.2.10", - "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz", - "integrity": "sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.2", - "define-data-property": "^1.1.4", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.5", - "es-object-atoms": "^1.0.0", - "has-property-descriptors": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/string.prototype.trimend": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.9.tgz", - "integrity": "sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.2", - "define-properties": "^1.2.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/string.prototype.trimstart": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", - "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.7", - "define-properties": "^1.2.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-bom": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", - "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/strip-json-comments": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", - "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/supports-preserve-symlinks-flag": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", - "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/table": { - "version": "6.9.0", - "resolved": "https://registry.npmjs.org/table/-/table-6.9.0.tgz", - "integrity": "sha512-9kY+CygyYM6j02t5YFHbNz2FN5QmYGv9zAjVp4lCDjlCw7amdckXlEt/bjMhUIfj4ThGRE4gCUH5+yGnNuPo5A==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "ajv": "^8.0.1", - "lodash.truncate": "^4.4.2", - "slice-ansi": "^4.0.0", - "string-width": "^4.2.3", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=10.0.0" - } - }, - "node_modules/table/node_modules/ajv": { - "version": "8.17.1", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", - "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/table/node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "dev": true, - "license": "MIT" - }, - "node_modules/text-table": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", - "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", - "dev": true, - "license": "MIT" - }, - "node_modules/tsconfig-paths": { - "version": "3.15.0", - "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", - "integrity": "sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/json5": "^0.0.29", - "json5": "^1.0.2", - "minimist": "^1.2.6", - "strip-bom": "^3.0.0" - } - }, - "node_modules/type": { - "version": "2.7.3", - "resolved": "https://registry.npmjs.org/type/-/type-2.7.3.tgz", - "integrity": "sha512-8j+1QmAbPvLZow5Qpi6NCaN8FB60p/6x8/vfNqOk/hC+HuvFZhL4+WfekuhQLiqFZXOgQdrs3B+XxEmCc6b3FQ==", - "license": "ISC" - }, - "node_modules/type-check": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", - "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", - "dev": true, - "license": "MIT", - "dependencies": { - "prelude-ls": "^1.2.1" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/type-fest": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", - "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", - "dev": true, - "license": "(MIT OR CC0-1.0)", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/typed-array-buffer": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", - "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "es-errors": "^1.3.0", - "is-typed-array": "^1.1.14" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/typed-array-byte-length": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz", - "integrity": "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "for-each": "^0.3.3", - "gopd": "^1.2.0", - "has-proto": "^1.2.0", - "is-typed-array": "^1.1.14" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/typed-array-byte-offset": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz", - "integrity": "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "available-typed-arrays": "^1.0.7", - "call-bind": "^1.0.8", - "for-each": "^0.3.3", - "gopd": "^1.2.0", - "has-proto": "^1.2.0", - "is-typed-array": "^1.1.15", - "reflect.getprototypeof": "^1.0.9" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/typed-array-length": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.7.tgz", - "integrity": "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.7", - "for-each": "^0.3.3", - "gopd": "^1.0.1", - "is-typed-array": "^1.1.13", - "possible-typed-array-names": "^1.0.0", - "reflect.getprototypeof": "^1.0.6" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/typedarray-to-buffer": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz", - "integrity": "sha512-zdu8XMNEDepKKR+XYOXAVPtWui0ly0NtohUscw+UmaHiAWT8hrV1rr//H6V+0DvJ3OQ19S979M0laLfX8rm82Q==", - "license": "MIT", - "dependencies": { - "is-typedarray": "^1.0.0" - } - }, - "node_modules/unbox-primitive": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", - "integrity": "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "has-bigints": "^1.0.2", - "has-symbols": "^1.1.0", - "which-boxed-primitive": "^1.1.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/uri-js": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", - "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "punycode": "^2.1.0" - } - }, - "node_modules/utf-8-validate": { - "version": "5.0.10", - "resolved": "https://registry.npmjs.org/utf-8-validate/-/utf-8-validate-5.0.10.tgz", - "integrity": "sha512-Z6czzLq4u8fPOyx7TU6X3dvUZVvoJmxSQ+IcrlmagKhilxlhZgxPK6C5Jqbkw1IDUmFTM+cz9QDnnLTwDz/2gQ==", - "hasInstallScript": true, - "license": "MIT", - "dependencies": { - "node-gyp-build": "^4.3.0" - }, - "engines": { - "node": ">=6.14.2" - } - }, - "node_modules/v8-compile-cache": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/v8-compile-cache/-/v8-compile-cache-2.4.0.tgz", - "integrity": "sha512-ocyWc3bAHBB/guyqJQVI5o4BZkPhznPYUG2ea80Gond/BgNWpap8TOmLSeeQG7bnh2KMISxskdADG59j7zruhw==", - "dev": true, - "license": "MIT" - }, - "node_modules/websocket": { - "version": "1.0.35", - "resolved": "https://registry.npmjs.org/websocket/-/websocket-1.0.35.tgz", - "integrity": "sha512-/REy6amwPZl44DDzvRCkaI1q1bIiQB0mEFQLUrhz3z2EK91cp3n72rAjUlrTP0zV22HJIUOVHQGPxhFRjxjt+Q==", - "license": "Apache-2.0", - "dependencies": { - "bufferutil": "^4.0.1", - "debug": "^2.2.0", - "es5-ext": "^0.10.63", - "typedarray-to-buffer": "^3.1.5", - "utf-8-validate": "^5.0.2", - "yaeti": "^0.0.6" - }, - "engines": { - "node": ">=4.0.0" - } - }, - "node_modules/websocket/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "license": "MIT", - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/websocket/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "license": "MIT" - }, - "node_modules/which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, - "license": "ISC", - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "node-which": "bin/node-which" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/which-boxed-primitive": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", - "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-bigint": "^1.1.0", - "is-boolean-object": "^1.2.1", - "is-number-object": "^1.1.1", - "is-string": "^1.1.1", - "is-symbol": "^1.1.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/which-builtin-type": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.2.1.tgz", - "integrity": "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "function.prototype.name": "^1.1.6", - "has-tostringtag": "^1.0.2", - "is-async-function": "^2.0.0", - "is-date-object": "^1.1.0", - "is-finalizationregistry": "^1.1.0", - "is-generator-function": "^1.0.10", - "is-regex": "^1.2.1", - "is-weakref": "^1.0.2", - "isarray": "^2.0.5", - "which-boxed-primitive": "^1.1.0", - "which-collection": "^1.0.2", - "which-typed-array": "^1.1.16" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/which-collection": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", - "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-map": "^2.0.3", - "is-set": "^2.0.3", - "is-weakmap": "^2.0.2", - "is-weakset": "^2.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/which-typed-array": { - "version": "1.1.19", - "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.19.tgz", - "integrity": "sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw==", - "dev": true, - "license": "MIT", - "dependencies": { - "available-typed-arrays": "^1.0.7", - "call-bind": "^1.0.8", - "call-bound": "^1.0.4", - "for-each": "^0.3.5", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-tostringtag": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/word-wrap": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", - "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/wrappy": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/yaeti": { - "version": "0.0.6", - "resolved": "https://registry.npmjs.org/yaeti/-/yaeti-0.0.6.tgz", - "integrity": "sha512-MvQa//+KcZCUkBTIC9blM+CU9J2GzuTytsOUwf2lidtvkx/6gnEp1QvJv34t9vdjhFmha/mUiNDbN0D0mJWdug==", - "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", - "license": "MIT", - "engines": { - "node": ">=0.10.32" - } - } - } -} diff --git a/package.json b/package.json index ae374f6..73a4da6 100644 --- a/package.json +++ b/package.json @@ -1,28 +1,61 @@ { "name": "miakapi", - "version": "3.0.31", - "description": "Miakapi for Miakapp V3", - "main": "main.js", + "version": "4.0.0-alpha.0", + "description": "Typed coordinator SDK for Miakapp", + "type": "module", + "packageManager": "bun@1.2.23", + "engines": { + "node": ">=22.9" + }, + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "files": [ + "contracts", + "dist", + "LICENSE", + "README.md" + ], + "sideEffects": false, "scripts": { - "test": "node test" + "build": "tsc -p tsconfig.build.json", + "build:contract": "tsc -p tsconfig.contract.json", + "pack:check": "npm pack --dry-run", + "prepublishOnly": "bun run check", + "smoke:node": "node test/node-smoke.mjs", + "test": "bun test", + "test:contract": "bun run build:contract && node scripts/check-contract.mjs", + "typecheck": "tsc --noEmit", + "check": "bun run typecheck && bun run test && bun run build && bun run smoke:node && bun run test:contract && bun run pack:check" }, "repository": { "type": "git", "url": "git+https://github.com/Miakapp/MiakAPI.git" }, + "publishConfig": { + "access": "public", + "tag": "next" + }, "keywords": [ "Miakapp", "MiakAPI", - "Smart-Home" + "smart-home", + "coordinator", + "websocket" ], "author": "Mathieu Colmon", "license": "ISC", "devDependencies": { - "eslint": "^7.29.0", - "eslint-config-airbnb-base": "^14.2.1", - "eslint-plugin-import": "^2.23.4" + "@types/bun": "1.2.23", + "@types/node": "22.20.1", + "@types/ws": "8.18.1", + "typescript": "7.0.2" }, "dependencies": { - "websocket": "^1.0.34" + "@msgpack/msgpack": "3.1.3", + "ws": "8.21.3" } } diff --git a/scripts/check-contract.mjs b/scripts/check-contract.mjs new file mode 100644 index 0000000..855dc52 --- /dev/null +++ b/scripts/check-contract.mjs @@ -0,0 +1,106 @@ +#!/usr/bin/env node + +import { spawn } from 'node:child_process'; +import { mkdir, readFile, stat } from 'node:fs/promises'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = fileURLToPath(new URL('..', import.meta.url)); +const pinPath = join(root, 'contracts', 'miakapp-v3.json'); +const subjectPath = join(root, '.contract-dist', 'test', 'contract', 'subject.js'); +const defaultCheckout = join(root, '.contract', 'miakapp-v3'); +const GIT_OBJECT_ID = /^[0-9a-f]{40}$/; +const ALLOWED_PIN_KEYS = new Set(['repository', 'commit', 'profile', 'schema']); + +function validatePin(value) { + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + throw new TypeError('Contract pin must be an object'); + } + const keys = Object.keys(value); + if (keys.length !== ALLOWED_PIN_KEYS.size + || keys.some((key) => !ALLOWED_PIN_KEYS.has(key)) + || typeof value.repository !== 'string' + || !value.repository.startsWith('https://github.com/Miakapp/') + || typeof value.commit !== 'string' + || !GIT_OBJECT_ID.test(value.commit) + || value.profile !== 'sdk' + || value.schema !== 'miakapp.coordinator-contract/1') { + throw new TypeError('Contract pin is invalid'); + } + return value; +} + +function run(command, arguments_, options = {}) { + return new Promise((resolveRun, rejectRun) => { + const child = spawn(command, arguments_, { + cwd: options.cwd, + env: process.env, + stdio: options.capture === true ? ['ignore', 'pipe', 'inherit'] : 'inherit', + }); + const chunks = []; + child.stdout?.on('data', (chunk) => chunks.push(chunk)); + child.once('error', rejectRun); + child.once('close', (code, signal) => { + if (code !== 0) { + rejectRun(new Error( + signal === null + ? `${command} exited with code ${code}` + : `${command} terminated by ${signal}`, + )); + return; + } + resolveRun(Buffer.concat(chunks).toString('utf8').trim()); + }); + }); +} + +async function exists(path) { + try { + await stat(path); + return true; + } catch (error) { + if (error !== null && typeof error === 'object' && error.code === 'ENOENT') return false; + throw error; + } +} + +const pin = validatePin(JSON.parse(await readFile(pinPath, 'utf8'))); +const suppliedCheckout = process.env.MIAKAPP_V3_CHECKOUT; +const checkout = suppliedCheckout === undefined + ? defaultCheckout + : resolve(suppliedCheckout); + +if (suppliedCheckout === undefined && !await exists(join(checkout, '.git'))) { + await mkdir(dirname(checkout), { recursive: true }); + await run('git', ['clone', '--filter=blob:none', '--no-checkout', pin.repository, checkout]); +} +if (suppliedCheckout === undefined) { + await run('git', ['-C', checkout, 'fetch', '--depth=1', 'origin', pin.commit]); + await run('git', ['-C', checkout, 'checkout', '--detach', pin.commit]); +} + +const actualCommit = await run( + 'git', + ['-C', checkout, 'rev-parse', 'HEAD'], + { capture: true }, +); +if (actualCommit !== pin.commit) { + throw new Error(`Contract checkout is ${actualCommit}; expected ${pin.commit}`); +} +const trackedChanges = await run( + 'git', + ['-C', checkout, 'status', '--porcelain', '--untracked-files=no'], + { capture: true }, +); +if (trackedChanges.length > 0) { + throw new Error('Contract checkout contains tracked modifications'); +} +if (!await exists(subjectPath)) { + throw new Error('Contract subject is not built; run bun run build:contract first'); +} + +await run( + join(checkout, 'coordinator-contract', 'check-external.sh'), + ['--profile', pin.profile, subjectPath], + { cwd: checkout }, +); diff --git a/src/api.ts b/src/api.ts new file mode 100644 index 0000000..3344c8c --- /dev/null +++ b/src/api.ts @@ -0,0 +1,347 @@ +export type ProtocolObject = { [key: string]: ProtocolValue }; + +const API_UTF8 = new TextEncoder(); +const API_CONTROL_CHARACTER = /\p{Cc}/u; + +function hasUnpairedSurrogate(value: string): boolean { + for (let index = 0; index < value.length; index += 1) { + const unit = value.charCodeAt(index); + if (unit >= 0xd800 && unit <= 0xdbff) { + const following = value.charCodeAt(index + 1); + if (index + 1 >= value.length || following < 0xdc00 || following > 0xdfff) return true; + index += 1; + } else if (unit >= 0xdc00 && unit <= 0xdfff) { + return true; + } + } + return false; +} + +export type ProtocolValue = + | null + | boolean + | number + | string + | Uint8Array + | ProtocolValue[] + | ProtocolObject; + +export type Unsubscribe = () => void; + +export type CoordinatorStatus = + | 'idle' + | 'connecting' + | 'authenticating' + | 'synchronizing' + | 'ready' + | 'reconnecting' + | 'draining' + | 'stopping' + | 'stopped'; + +export type DispatchOutcome = + | 'not_dispatched' + | 'sent' + | 'accepted' + | 'applied' + | 'outcome_unknown'; + +export interface AccessTokenRequest { + coordinatorName: string; + reason: 'initial' | 'reauth' | 'reconnect'; + relayHost?: string; + signal: AbortSignal; +} + +export interface AccessToken { + relayUrl: string; + token: string; + expiresAtMs: number; +} + +export interface AccessTokenProvider { + getAccessToken(request: AccessTokenRequest): Promise; +} + +export interface CoordinatorLogRecord { + level: 'debug' | 'info' | 'warn' | 'error'; + event: string; + status?: CoordinatorStatus; + code?: number; +} + +export interface CoordinatorLogger { + write(record: CoordinatorLogRecord): void; +} + +export interface CoordinatorOptions { + name: string; + accessTokenProvider: AccessTokenProvider; + logger?: CoordinatorLogger; +} + +export interface CoordinatorConfiguration { + state: Readonly>; + stateAccess: readonly UserStateAccess[]; + events: readonly EventDeclaration[]; + eventAccess: readonly UserEventAccess[]; + functions: Readonly>; +} + +export interface ReadySession { + sessionId: number; + generation: number; + connectedAtMs: number; +} + +export interface CoordinatorFailure extends Error { + kind: + | 'protocol' + | 'authentication' + | 'authorization' + | 'conflict' + | 'invalid_lifecycle' + | 'unavailable' + | 'cancelled' + | 'superseded' + | 'internal'; + code?: number; + retryable: boolean; + outcome: DispatchOutcome; + correlation?: { + kind: 'event' | 'call'; + localId: string; + }; +} + +export class ApplicationCallError extends Error { + readonly code: number; + readonly retryable: boolean; + + constructor(code: number, message = 'Application call failed', retryable = false) { + if (!Number.isInteger(code) || code < 2_000 || code > 2_999) { + throw new RangeError('Application call error code must be between 2000 and 2999'); + } + if (typeof message !== 'string' + || message.length === 0 + || hasUnpairedSurrogate(message) + || API_CONTROL_CHARACTER.test(message) + || API_UTF8.encode(message).byteLength > 256) { + throw new TypeError('Application call error message must contain 1 to 256 safe UTF-8 bytes'); + } + if (typeof retryable !== 'boolean') { + throw new TypeError('Application call error retryable flag must be a boolean'); + } + super(message); + this.name = 'ApplicationCallError'; + this.code = code; + this.retryable = retryable; + } +} + +export interface LifecycleEvent { + previous: CoordinatorStatus; + current: CoordinatorStatus; + session?: ReadySession; + reason?: CoordinatorFailure; +} + +export interface StartOptions { + signal?: AbortSignal; +} + +export interface StopOptions { + deadlineMs?: number; +} + +export interface DeclarationOptions { + signal?: AbortSignal; +} + +export interface OperationOptions { + signal?: AbortSignal; +} + +export interface DeclarationReceipt { + sessionId: number; + generation: number; +} + +export interface StateReceipt { + outcome: 'applied'; +} + +export type StateMutation = + | { path: string; value: ProtocolValue } + | { path: string; delete: true }; + +export interface CoordinatorState { + declare( + entries: Readonly>, + options?: DeclarationOptions, + ): Promise; + + set( + mutations: readonly StateMutation[], + options?: OperationOptions, + ): Promise; +} + +export interface UserStateAccess { + userId: string; + patterns: readonly string[]; +} + +export interface UserEventAccess { + userId: string; + publish: readonly string[]; + subscribe: readonly string[]; +} + +export interface CoordinatorAccess { + declareState( + entries: readonly UserStateAccess[], + options?: DeclarationOptions, + ): Promise; + + declareEvents( + entries: readonly UserEventAccess[], + options?: DeclarationOptions, + ): Promise; +} + +export const EventDirection = Object.freeze({ + acceptFromUsers: 0x01, + publishToUsers: 0x02, + acceptFromCoordinators: 0x04, + publishToCoordinators: 0x08, +} as const); + +export interface EventDeclaration { + topic: string; + directions: number; +} + +export type EventTarget = + | { kind: 'default' } + | { kind: 'user_session'; id: number } + | { kind: 'coordinator'; id: string }; + +export interface SentEvent { + outcome: 'sent'; +} + +export interface EventHandle { + readonly localId: string; + readonly sent: Promise; +} + +export interface IncomingEvent { + source: Principal; + topic: string; + value: ProtocolValue; +} + +export interface CoordinatorEvents { + declare( + entries: readonly EventDeclaration[], + options?: DeclarationOptions, + ): Promise; + + publish( + topic: string, + value: ProtocolValue, + options?: OperationOptions & { target?: EventTarget }, + ): EventHandle; + + subscribe(topic: string, listener: (event: IncomingEvent) => void): Unsubscribe; +} + +export interface Principal { + kind: 'user' | 'coordinator' | 'cli'; + id: string; + sessionId: number; + coordinatorName: string | null; + verifiedEmail: string | null; +} + +export interface IncomingCall { + source: Principal; + arguments: ProtocolValue; + idempotencyKey: string | null; + signal: AbortSignal; + emit(value: ProtocolValue): Promise; +} + +export type FunctionHandler = ( + call: IncomingCall, +) => ProtocolValue | Promise; + +export interface CoordinatorFunctions { + declare( + handlers: Readonly>, + options?: DeclarationOptions, + ): Promise; +} + +export type CallTarget = + | { kind: 'default' } + | { kind: 'user_session'; id: number } + | { kind: 'coordinator'; id: string }; + +export interface StartCallOptions { + function: string; + arguments: ProtocolValue; + timeoutMs: number; + target?: CallTarget; + idempotencyKey?: string; + signal?: AbortSignal; +} + +export interface CallHandle { + readonly localId: string; + readonly accepted: Promise; + readonly stream: AsyncIterable; + readonly result: Promise; + cancel(reason?: string): void; +} + +export interface CoordinatorCalls { + start(options: StartCallOptions): CallHandle; +} + +export interface PresenceEntry { + sessionId: number; + userId: string; +} + +export interface CoordinatorPresence { + snapshot(): readonly PresenceEntry[]; + subscribe(listener: (entries: readonly PresenceEntry[]) => void): Unsubscribe; +} + +export interface CoordinatorErrors { + subscribe(listener: (failure: CoordinatorFailure) => void): Unsubscribe; +} + +export interface Coordinator { + readonly status: CoordinatorStatus; + readonly state: CoordinatorState; + readonly access: CoordinatorAccess; + readonly events: CoordinatorEvents; + readonly functions: CoordinatorFunctions; + readonly calls: CoordinatorCalls; + readonly presence: CoordinatorPresence; + readonly errors: CoordinatorErrors; + + configure(configuration: CoordinatorConfiguration): void; + start(options?: StartOptions): Promise; + stop(options?: StopOptions): Promise; + subscribe(listener: (event: LifecycleEvent) => void): Unsubscribe; +} + +export type CoordinatorFactory = (options: CoordinatorOptions) => Coordinator; + +export interface CoordinatorModule { + createCoordinator: CoordinatorFactory; +} diff --git a/src/coordinator.ts b/src/coordinator.ts new file mode 100644 index 0000000..2944155 --- /dev/null +++ b/src/coordinator.ts @@ -0,0 +1,621 @@ +import type { + AccessToken, + AccessTokenRequest, + Coordinator, + CoordinatorConfiguration, + CoordinatorErrors, + CoordinatorFailure, + CoordinatorLogger, + CoordinatorOptions, + CoordinatorStatus, + DeclarationReceipt, + LifecycleEvent, + ReadySession, + StartOptions, + StopOptions, + Unsubscribe, +} from './api.js'; +import { Opcode, type Frame } from './protocol/codec.js'; +import { + AccessManager, + DeclarationManager, + type ActiveDeclarations, + type DeclarationHost, +} from './internal/declarations.js'; +import { + cancelled, + CoordinatorError, + internalFailure, + invalidLifecycle, + protocolFailure, + relayFailure, + safeLog, + unavailable, +} from './internal/errors.js'; +import { EventManager, type EventHost } from './internal/events.js'; +import { CallManager, FunctionManager, type CallHost } from './internal/calls.js'; +import { PresenceManager, type PresenceHost } from './internal/presence.js'; +import { + childAbortController, + createDeferred, + IdSequence, + ListenerSet, + type Deferred, +} from './internal/resources.js'; +import { delay, type CoordinatorRuntime, type RuntimeTimer } from './internal/runtime.js'; +import { RelaySession } from './internal/session.js'; +import { createProductionRuntime } from './internal/socket.js'; +import { StateManager, type StateHost } from './internal/state.js'; +import { + validateAccessToken, + validateConfiguration, + validateCoordinatorOptions, + validateStartOptions, + validateStopOptions, +} from './internal/validation.js'; + +interface SessionEnd { + failure?: CoordinatorFailure; + retryAfterMs?: number; +} + +interface TokenRequest { + controller: AbortController; + dispose: Unsubscribe; + promise: Promise; +} + +interface PendingReauthentication { + requestId: number; + deferred: Deferred; +} + +const FIRST_RECONNECT_CEILING_MS = 1_000; +const MAX_RECONNECT_CEILING_MS = 30_000; + +function relayInteger(frame: Frame, index: number, label: string): number { + const value = frame.payload[index]; + if (typeof value !== 'number' || !Number.isSafeInteger(value)) { + throw protocolFailure(`${label} is not an integer`); + } + return value; +} + +function relayBoolean(frame: Frame, index: number, label: string): boolean { + const value = frame.payload[index]; + if (typeof value !== 'boolean') throw protocolFailure(`${label} is not a boolean`); + return value; +} + +class CoordinatorImpl implements + Coordinator, + DeclarationHost, + StateHost, + EventHost, + CallHost, + PresenceHost { + readonly #options: CoordinatorOptions; + readonly #runtime: CoordinatorRuntime; + readonly #lifecycleListeners = new ListenerSet(); + readonly #errorListeners = new ListenerSet(); + readonly #declarations: DeclarationManager; + readonly #loopController = new AbortController(); + readonly state: StateManager; + readonly access: AccessManager; + readonly events: EventManager; + readonly functions: FunctionManager; + readonly calls: CallManager; + readonly presence: PresenceManager; + readonly errors: CoordinatorErrors; + #status: CoordinatorStatus = 'idle'; + #started = false; + #configured = false; + #session: RelaySession | undefined; + #sessionEnd: Deferred | undefined; + #startDeferred: Deferred | undefined; + #stopDeferred: Deferred | undefined; + #stopTimer: RuntimeTimer | undefined; + #loopTask: Promise | undefined; + #startSignal: AbortSignal | undefined; + #startAbort: (() => void) | undefined; + #tokenRequest: TokenRequest | undefined; + #reauthentication: PendingReauthentication | undefined; + #reauthTimer: import('./internal/runtime.js').RuntimeTimer | undefined; + #requestIds = new IdSequence(); + #eventIds = new IdSequence(); + #callIds = new IdSequence(); + readonly #localEventIds = new IdSequence(); + readonly #localCallIds = new IdSequence(); + #reconnectAttempt = 0; + #relayHost: string | undefined; + #goawayRetryAfterMs: number | undefined; + + constructor(options: CoordinatorOptions, runtime: CoordinatorRuntime) { + this.#options = options; + this.#runtime = runtime; + this.#declarations = new DeclarationManager(this); + this.state = new StateManager(this, this.#declarations); + this.access = new AccessManager(this.#declarations); + this.events = new EventManager(this, this.#declarations); + this.functions = new FunctionManager(this.#declarations); + this.calls = new CallManager(this); + this.presence = new PresenceManager(this); + this.errors = Object.freeze({ + subscribe: (listener: (failure: CoordinatorFailure) => void) => { + if (typeof listener !== 'function') throw new TypeError('error listener must be a function'); + return this.#errorListeners.subscribe(listener); + }, + }); + } + + get status(): CoordinatorStatus { + return this.#status; + } + + configure(configuration: CoordinatorConfiguration): void { + if (this.#started || this.#status !== 'idle' || this.#configured) { + throw invalidLifecycle('configure may be called once before start'); + } + this.#declarations.configure(validateConfiguration(configuration)); + this.#configured = true; + } + + start(options: StartOptions = {}): Promise { + if (this.#started || this.#status !== 'idle') { + return Promise.reject(invalidLifecycle('Coordinator has already started or stopped')); + } + const signal = validateStartOptions(options); + if (signal?.aborted === true) return Promise.reject(cancelled('not_dispatched')); + this.#started = true; + this.#startDeferred = createDeferred(); + if (signal !== undefined) { + const abort = () => void this.stop(); + this.#startSignal = signal; + this.#startAbort = abort; + signal.addEventListener('abort', abort, { once: true }); + } + this.#loopTask = this.#runConnectionLoop(); + void this.#loopTask.catch((error) => { + const failure = error instanceof CoordinatorError ? error : internalFailure(); + this.#startDeferred?.reject(failure); + this.#emitFailure(failure); + void this.stop(); + }); + return this.#startDeferred.promise; + } + + stop(options: StopOptions = {}): Promise { + if (this.#stopDeferred !== undefined) return this.#stopDeferred.promise; + let deadlineMs: number; + try { + deadlineMs = validateStopOptions(options); + } catch (error) { + return Promise.reject(error); + } + this.#stopDeferred = createDeferred(); + const stoppingFailure = cancelled('not_dispatched', 'Coordinator is stopping'); + this.#transition('stopping'); + this.calls.stop(); + this.#declarations.stop(); + this.state.stop(stoppingFailure); + this.events.stop(); + this.presence.stop(); + this.#clearReauthentication(); + this.#abortTokenRequest(); + this.#loopController.abort(stoppingFailure); + this.#sessionEnd?.resolve({ failure: stoppingFailure }); + this.#session?.terminate(); + this.#startDeferred?.reject(stoppingFailure); + const finish = () => this.#finishStop(); + if (this.#loopTask === undefined) finish(); + else { + this.#stopTimer = this.#runtime.setTimer(finish, deadlineMs); + void this.#loopTask.then(finish, finish); + } + return this.#stopDeferred.promise; + } + + subscribe(listener: (event: LifecycleEvent) => void): Unsubscribe { + if (typeof listener !== 'function') throw new TypeError('lifecycle listener must be a function'); + return this.#lifecycleListeners.subscribe(listener); + } + + nextRequestId(): number { + return this.#requestIds.take(); + } + + nextEventId(): number { + return this.#eventIds.take(); + } + + nextCallId(): number { + return this.#callIds.take(); + } + + nextLocalEventId(): number { + return this.#localEventIds.take(); + } + + nextLocalCallId(): number { + return this.#localCallIds.take(); + } + + currentSession(): RelaySession | undefined { + return this.#status === 'ready' || this.#status === 'synchronizing' + ? this.#session + : undefined; + } + + readySession(): RelaySession | undefined { + return this.#status === 'ready' ? this.#session : undefined; + } + + activeDeclarations(): ActiveDeclarations | undefined { + return this.#declarations.active; + } + + runtime(): CoordinatorRuntime { + return this.#runtime; + } + + logger(): CoordinatorLogger | undefined { + return this.#options.logger; + } + + emitFailure(failure: CoordinatorError): void { + this.#emitFailure(failure); + } + + synchronizing(): void { + this.#transition('synchronizing'); + } + + activeDeclarationsChanged(active: ActiveDeclarations): void { + this.calls.setActiveDeclarations(active); + } + + declarationsReady(_receipt: DeclarationReceipt): void { + const session = this.#session; + if (session === undefined) return; + const ready = session.welcome.readySession; + this.#transition('ready', ready); + this.#startDeferred?.resolve(ready); + } + + declarationFailure( + failure: CoordinatorError, + hasActiveConfiguration: boolean, + hasQueuedSnapshot: boolean, + ): void { + this.#emitFailure(failure); + if (hasActiveConfiguration && !hasQueuedSnapshot && this.#session !== undefined) { + const ready = this.#session.welcome.readySession; + this.#transition('ready', ready, failure); + } + } + + transportFailure(error: unknown): void { + const failure = error instanceof CoordinatorError + ? error + : unavailable('Coordinator transport failed'); + this.#sessionEnd?.resolve({ failure }); + this.#session?.terminate(); + } + + async #runConnectionLoop(): Promise { + let reason: AccessTokenRequest['reason'] = 'initial'; + while (!this.#loopController.signal.aborted) { + let end: SessionEnd = {}; + let sessionEnd: Deferred | undefined; + try { + this.#transition('connecting'); + if (this.#loopController.signal.aborted) break; + const token = await this.#getAccessToken(reason); + this.#relayHost = new URL(token.relayUrl).host; + if (this.#loopController.signal.aborted) break; + this.#transition('authenticating'); + if (this.#loopController.signal.aborted) break; + this.#requestIds = new IdSequence(); + this.#eventIds = new IdSequence(); + this.#callIds = new IdSequence(); + const connectionEnd = createDeferred(); + sessionEnd = connectionEnd; + this.#sessionEnd = connectionEnd; + this.#goawayRetryAfterMs = undefined; + const session = await RelaySession.connect( + this.#runtime, + this.#options.name, + token.relayUrl, + token.token, + this.#loopController.signal, + { + frame: (frame) => this.#handleFrame(frame), + closed: () => { + const retryAfterMs = this.#goawayRetryAfterMs; + connectionEnd.resolve(retryAfterMs === undefined ? {} : { retryAfterMs }); + }, + failed: (error) => connectionEnd.resolve({ + failure: error instanceof CoordinatorError + ? error + : unavailable('Relay connection failed'), + }), + }, + ); + if (this.#loopController.signal.aborted) { + session.terminate(); + session.detach(); + break; + } + this.#session = session; + this.#reconnectAttempt = 0; + this.#scheduleReauthentication(Math.min(token.expiresAtMs, session.welcome.expiresAtMs)); + this.#declarations.synchronize(session); + if (this.#loopController.signal.aborted) { + session.terminate(); + break; + } + session.startDelivery(); + end = await connectionEnd.promise; + } catch (error) { + if (this.#loopController.signal.aborted) break; + end = sessionEnd?.settled === true + ? await sessionEnd.promise + : { failure: error instanceof CoordinatorError ? error : unavailable('Connection attempt failed') }; + } + this.#disconnectSession(); + if (this.#loopController.signal.aborted) break; + if (end.failure !== undefined) this.#emitFailure(end.failure); + this.#transition('reconnecting', undefined, end.failure); + const ceiling = Math.min( + FIRST_RECONNECT_CEILING_MS * (2 ** this.#reconnectAttempt), + MAX_RECONNECT_CEILING_MS, + ); + this.#reconnectAttempt += 1; + const randomDelay = Math.floor(this.#runtime.random() * (ceiling + 1)); + const reconnectDelay = Math.max(randomDelay, end.retryAfterMs ?? 0); + try { + await delay(this.#runtime, reconnectDelay, this.#loopController.signal); + } catch { + break; + } + reason = 'reconnect'; + } + } + + async #getAccessToken(reason: AccessTokenRequest['reason']): Promise { + if (this.#tokenRequest !== undefined) return this.#tokenRequest.promise; + const child = childAbortController(this.#loopController.signal); + const request: AccessTokenRequest = this.#relayHost === undefined + ? Object.freeze({ + coordinatorName: this.#options.name, + reason, + signal: child.controller.signal, + }) + : Object.freeze({ + coordinatorName: this.#options.name, + reason, + relayHost: this.#relayHost, + signal: child.controller.signal, + }); + const promise = Promise.resolve() + .then(() => this.#options.accessTokenProvider.getAccessToken(request)) + .then((value) => validateAccessToken(value, this.#runtime.now())); + const tokenRequest: TokenRequest = { + controller: child.controller, + dispose: child.dispose, + promise, + }; + this.#tokenRequest = tokenRequest; + void promise.finally(() => { + if (this.#tokenRequest !== tokenRequest) return; + child.dispose(); + this.#tokenRequest = undefined; + }).catch(() => undefined); + return promise; + } + + #abortTokenRequest(): void { + const request = this.#tokenRequest; + if (request === undefined) return; + request.controller.abort(cancelled('not_dispatched')); + request.dispose(); + this.#tokenRequest = undefined; + } + + #scheduleReauthentication(expiresAtMs: number): void { + this.#clearReauthentication(); + if (this.#status === 'draining' + || this.#status === 'stopping' + || this.#status === 'stopped') return; + const remaining = Math.max(0, expiresAtMs - this.#runtime.now()); + const lead = Math.min(30_000, Math.floor(remaining / 2)); + this.#reauthTimer = this.#runtime.setTimer(() => { + void this.#reauthenticate(); + }, Math.max(0, remaining - lead)); + } + + async #reauthenticate(): Promise { + const session = this.#session; + if (session === undefined || this.#loopController.signal.aborted) return; + try { + const token = await this.#getAccessToken('reauth'); + if (!this.#mayReauthenticate(session)) return; + if (new URL(token.relayUrl).host !== this.#relayHost) { + throw new TypeError('Reauthentication cannot change relay host'); + } + const requestId = this.nextRequestId(); + const deferred = createDeferred(); + this.#reauthentication = { requestId, deferred }; + await session.send({ opcode: Opcode.Reauth, payload: [requestId, token.token] }); + const relayExpiry = await deferred.promise; + if (!this.#mayReauthenticate(session)) return; + this.#scheduleReauthentication(Math.min(token.expiresAtMs, relayExpiry)); + } catch (error) { + if (this.#mayReauthenticate(session)) this.transportFailure(error); + } + } + + #mayReauthenticate(session: RelaySession): boolean { + return this.#session === session + && !this.#loopController.signal.aborted + && this.#status !== 'draining'; + } + + #clearReauthentication(): void { + this.#reauthTimer?.cancel(); + this.#reauthTimer = undefined; + this.#reauthentication?.deferred.reject(cancelled('not_dispatched')); + this.#reauthentication = undefined; + } + + #handleFrame(frame: Frame): void { + try { + if (frame.opcode === Opcode.Error) { + this.#handleRelayError(frame); + return; + } + if (frame.opcode === Opcode.Fatal) { + const code = relayInteger(frame, 1, 'FATAL.code'); + const retryable = relayBoolean(frame, 2, 'FATAL.retryable'); + const failure = relayFailure(code, retryable, 'not_dispatched'); + if (retryable) this.transportFailure(failure); + else { + this.#emitFailure(failure); + this.#startDeferred?.reject(failure); + void this.stop(); + } + return; + } + if (frame.opcode === Opcode.ReauthOk) { + const requestId = relayInteger(frame, 0, 'REAUTH_OK.requestId'); + if (this.#reauthentication?.requestId !== requestId) { + throw protocolFailure('REAUTH_OK is not correlated'); + } + const expiresAtMs = relayInteger(frame, 1, 'REAUTH_OK.expiresAtMs'); + if (expiresAtMs <= this.#runtime.now()) { + throw protocolFailure('REAUTH_OK expiry is not in the future'); + } + this.#reauthentication.deferred.resolve(expiresAtMs); + this.#reauthentication = undefined; + return; + } + if (frame.opcode === Opcode.Goaway) { + this.#transition('draining'); + this.#reauthTimer?.cancel(); + this.#reauthTimer = undefined; + this.#abortTokenRequest(); + this.#goawayRetryAfterMs = relayInteger(frame, 0, 'GOAWAY.retryAfterMs'); + return; + } + if (frame.opcode === Opcode.StateDict || frame.opcode === Opcode.TopicDict) return; + if (this.#declarations.handleFrame(frame) + || this.state.handleFrame(frame) + || this.events.handleFrame(frame) + || this.calls.handleFrame(frame) + || this.presence.handleFrame(frame)) return; + throw protocolFailure('Relay sent an unsupported coordinator frame'); + } catch (error) { + const failure = error instanceof CoordinatorError ? error : protocolFailure(); + this.transportFailure(failure); + } + } + + #handleRelayError(frame: Frame): void { + const correlationId = relayInteger(frame, 0, 'ERROR.correlationId'); + const sourceOpcode = relayInteger(frame, 1, 'ERROR.sourceOpcode'); + const code = relayInteger(frame, 2, 'ERROR.code'); + const retryable = relayBoolean(frame, 3, 'ERROR.retryable'); + if (sourceOpcode === Opcode.Event && this.events.handleError(correlationId, code, retryable)) return; + if (sourceOpcode === Opcode.StateSet && this.state.handleError(correlationId, code, retryable)) return; + if ((sourceOpcode === Opcode.StateSync + || sourceOpcode === Opcode.StateAclSync + || sourceOpcode === Opcode.EventSync + || sourceOpcode === Opcode.EventAclSync + || sourceOpcode === Opcode.FunctionSync) + && this.#declarations.handleError(correlationId, code, retryable)) return; + if ((sourceOpcode === Opcode.Call + || sourceOpcode === Opcode.CallCancel + || sourceOpcode === Opcode.CallCredit) + && this.calls.handleError(correlationId, code, retryable)) return; + if ((sourceOpcode === Opcode.CallResult || sourceOpcode === Opcode.CallError) + && this.calls.handleResponseError(correlationId, code, retryable)) return; + if (sourceOpcode === Opcode.Reauth + && this.#reauthentication?.requestId === correlationId) { + const failure = relayFailure(code, retryable, 'not_dispatched'); + this.#reauthentication.deferred.reject(failure); + this.#reauthentication = undefined; + return; + } + if (correlationId !== 0 || sourceOpcode !== 0) { + throw protocolFailure('ERROR is not correlated to an active operation'); + } + this.#emitFailure(relayFailure(code, retryable, 'not_dispatched')); + } + + #disconnectSession(): void { + this.#clearReauthentication(); + this.#abortTokenRequest(); + this.#declarations.disconnected(); + this.state.disconnected(); + this.events.disconnected(); + this.calls.disconnected(); + this.presence.disconnected(); + this.#session?.detach(); + this.#session = undefined; + this.#sessionEnd = undefined; + this.#goawayRetryAfterMs = undefined; + } + + #emitFailure(failure: CoordinatorFailure): void { + this.#errorListeners.emit(failure, () => { + safeLog(this.#options.logger, { level: 'error', event: 'error_listener_failed' }); + }); + } + + #transition( + current: CoordinatorStatus, + session?: ReadySession, + reason?: CoordinatorFailure, + ): void { + if (this.#status === current) return; + const previous = this.#status; + this.#status = current; + const event: LifecycleEvent = session === undefined && reason === undefined + ? Object.freeze({ previous, current }) + : session === undefined + ? Object.freeze({ previous, current, reason }) + : reason === undefined + ? Object.freeze({ previous, current, session }) + : Object.freeze({ previous, current, session, reason }); + safeLog(this.#options.logger, { level: 'info', event: 'status_changed', status: current }); + this.#lifecycleListeners.emit(event, () => { + safeLog(this.#options.logger, { level: 'error', event: 'lifecycle_listener_failed' }); + }); + } + + #finishStop(): void { + if (this.#status === 'stopped') return; + this.#stopTimer?.cancel(); + this.#stopTimer = undefined; + this.#disconnectSession(); + if (this.#startSignal !== undefined && this.#startAbort !== undefined) { + this.#startSignal.removeEventListener('abort', this.#startAbort); + } + this.#transition('stopped'); + this.#stopDeferred?.resolve(); + this.#lifecycleListeners.clear(); + this.#errorListeners.clear(); + } +} + +export function createCoordinator(options: CoordinatorOptions): Coordinator { + return new CoordinatorImpl(validateCoordinatorOptions(options), createProductionRuntime()); +} + +/** @internal */ +export function createCoordinatorWithRuntime( + options: CoordinatorOptions, + runtime: CoordinatorRuntime, +): Coordinator { + return new CoordinatorImpl(validateCoordinatorOptions(options), runtime); +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..1560f98 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,2 @@ +export * from './api.js'; +export { createCoordinator } from './coordinator.js'; diff --git a/src/internal/calls.ts b/src/internal/calls.ts new file mode 100644 index 0000000..a8b2dca --- /dev/null +++ b/src/internal/calls.ts @@ -0,0 +1,606 @@ +import type { + CallHandle, + CoordinatorCalls, + CoordinatorFunctions, + CoordinatorLogger, + DeclarationOptions, + FunctionHandler, + IncomingCall, + Principal, + ProtocolValue, + StartCallOptions, +} from '../api.js'; +import { ApplicationCallError } from '../api.js'; +import { LIMITS, Opcode, type Frame } from '../protocol/codec.js'; +import type { ActiveDeclarations, DeclarationManager } from './declarations.js'; +import { + cancelled, + outcomeUnknown, + relayFailure, + safeLog, + unavailable, + type CoordinatorError, +} from './errors.js'; +import { + AsyncValueQueue, + createDeferred, + type Deferred, +} from './resources.js'; +import type { CoordinatorRuntime, RuntimeTimer } from './runtime.js'; +import type { RelaySession } from './session.js'; +import { + targetFields, + validateDeclarationOptions, + validateFunctions, + validateProtocolValue, + validateStartCallOptions, +} from './validation.js'; + +export interface CallHost { + readySession(): RelaySession | undefined; + currentSession(): RelaySession | undefined; + activeDeclarations(): ActiveDeclarations | undefined; + nextCallId(): number; + nextLocalCallId(): number; + runtime(): CoordinatorRuntime; + emitFailure(failure: CoordinatorError): void; + logger(): CoordinatorLogger | undefined; +} + +interface OutgoingCall { + id: number; + localId: string; + session: RelaySession; + accepted: Deferred; + result: Deferred; + stream: AsyncValueQueue; + handedOff: boolean; + wasAccepted: boolean; + terminal: boolean; + cancellationRequested: boolean; + credit: number; + timer: RuntimeTimer; + signal?: AbortSignal; + abort?: () => void; +} + +interface IncomingRoute { + id: number; + session: RelaySession; + controller: AbortController; + credit: number; + creditWaiter: Deferred | undefined; + emitTail: Promise; + terminal: boolean; + timer: RuntimeTimer; +} + +function numeric(value: ProtocolValue | undefined, label: string): number { + if (typeof value !== 'number' || !Number.isSafeInteger(value)) { + throw new TypeError(`${label} is not an integer`); + } + return value; +} + +function nullableString(value: ProtocolValue | undefined, label: string): string | null { + if (value !== null && typeof value !== 'string') throw new TypeError(`${label} is invalid`); + return value; +} + +function principal(value: ProtocolValue | undefined): Principal { + if (!Array.isArray(value)) throw new TypeError('CALL_DISPATCH source is invalid'); + const kind = value[0] === 1 ? 'user' : value[0] === 2 ? 'coordinator' : value[0] === 3 ? 'cli' : undefined; + const id = value[1]; + const sessionId = value[2]; + const coordinatorName = value[3]; + const verifiedEmail = value[4]; + if (kind === undefined + || typeof id !== 'string' + || typeof sessionId !== 'number' + || !Number.isSafeInteger(sessionId) + || (coordinatorName !== null && typeof coordinatorName !== 'string') + || (verifiedEmail !== null && typeof verifiedEmail !== 'string')) { + throw new TypeError('CALL_DISPATCH source is invalid'); + } + return Object.freeze({ kind, id, sessionId, coordinatorName, verifiedEmail }); +} + +function functionForId(active: ActiveDeclarations, id: number): { + name: string; + handler: FunctionHandler; +} | undefined { + for (const [name, functionId] of active.functionIds) { + if (functionId !== id) continue; + const handler = active.snapshot.functions[name]; + if (handler !== undefined) return { name, handler }; + } + return undefined; +} + +export class FunctionManager implements CoordinatorFunctions { + readonly #declarations: DeclarationManager; + + constructor(declarations: DeclarationManager) { + this.#declarations = declarations; + } + + declare( + handlers: Readonly>, + options: DeclarationOptions = {}, + ): Promise { + return this.#declarations.declareFunctions( + validateFunctions(handlers), + validateDeclarationOptions(options, 'function declaration'), + ); + } +} + +export class CallManager implements CoordinatorCalls { + readonly #host: CallHost; + readonly #outgoing = new Map(); + readonly #incoming = new Map(); + readonly #completedIncoming = new Set(); + readonly #functionIds = new Map(); + #epoch: Uint8Array | undefined; + + constructor(host: CallHost) { + this.#host = host; + } + + start(rawOptions: StartCallOptions): CallHandle { + const options = validateStartCallOptions(rawOptions); + const localId = `call:${this.#host.nextLocalCallId()}`; + const accepted = createDeferred(); + const result = createDeferred(); + void accepted.promise.catch(() => undefined); + void result.promise.catch(() => undefined); + const inactiveStream = new AsyncValueQueue(); + const inactiveHandle = Object.freeze({ + localId, + accepted: accepted.promise, + stream: inactiveStream, + result: result.promise, + cancel() {}, + }); + if (options.signal?.aborted === true) { + const failure = cancelled('not_dispatched'); + accepted.reject(failure); + result.reject(failure); + inactiveStream.fail(failure); + return inactiveHandle; + } + const session = this.#host.readySession(); + const active = this.#host.activeDeclarations(); + const functionId = this.#functionIds.get(options.function) + ?? active?.functionIds.get(options.function); + if (session === undefined || active === undefined || functionId === undefined) { + const failure = unavailable('Call target function is not available in a ready session'); + accepted.reject(failure); + result.reject(failure); + inactiveStream.fail(failure); + return inactiveHandle; + } + if (this.#outgoing.size >= session.welcome.limits.inflightCalls) { + const failure = unavailable('Call concurrency limit is reached'); + accepted.reject(failure); + result.reject(failure); + inactiveStream.fail(failure); + return inactiveHandle; + } + + const id = this.#host.nextCallId(); + const timer = this.#host.runtime().setTimer(() => { + this.#cancelOutgoing(id, 1403, 'Call deadline expired', false); + }, options.timeoutMs); + const creditedStream = new AsyncValueQueue(() => this.#grantCredit(id)); + const pending: OutgoingCall = { + id, + localId, + session, + accepted, + result, + stream: creditedStream, + handedOff: false, + wasAccepted: false, + terminal: false, + cancellationRequested: false, + credit: 1, + timer, + }; + if (options.signal !== undefined) { + const abort = () => this.#cancelOutgoing(id, 1405, 'Call was aborted', true); + pending.signal = options.signal; + pending.abort = abort; + options.signal.addEventListener('abort', abort, { once: true }); + } + this.#outgoing.set(id, pending); + const returnedHandle = Object.freeze({ + localId, + accepted: accepted.promise, + stream: creditedStream, + result: result.promise, + cancel: (reason?: string) => { + if (reason !== undefined && typeof reason !== 'string') { + throw new TypeError('Call cancellation reason must be a string'); + } + this.#cancelOutgoing(id, 1405, 'Call was cancelled', true); + }, + }); + const [targetKind, targetValue] = targetFields(options.target); + queueMicrotask(() => { + if (pending.terminal) return; + pending.handedOff = true; + void session.send({ + opcode: Opcode.Call, + payload: [ + id, + targetKind, + targetValue, + functionId, + options.timeoutMs, + options.idempotencyKey ?? null, + 1, + options.arguments, + ], + }).then(() => undefined, () => { + if (this.#outgoing.get(id) !== pending) return; + this.#terminalOutgoing(pending, unavailable('Call was not handed to the transport')); + }); + }); + return returnedHandle; + } + + setActiveDeclarations(active: ActiveDeclarations): void { + this.#functionIds.clear(); + for (const [name, id] of active.functionIds) this.#functionIds.set(name, id); + this.#epoch = this.#host.currentSession()?.welcome.epoch.slice(); + } + + handleFrame(frame: Frame): boolean { + if (frame.opcode === Opcode.FunctionDict) { + this.#handleFunctionDictionary(frame); + return true; + } + if (frame.opcode === Opcode.CallDispatch) { + this.#startIncoming(frame); + return true; + } + if (frame.opcode === Opcode.CallAccepted) { + const call = this.#outgoingCall(frame); + if (call.wasAccepted) throw new TypeError('CALL_ACCEPTED was duplicated'); + call.wasAccepted = true; + call.accepted.resolve(); + return true; + } + if (frame.opcode === Opcode.CallResult) { + const call = this.#outgoingCall(frame); + if (!call.wasAccepted) throw new TypeError('CALL_RESULT arrived before CALL_ACCEPTED'); + const final = frame.payload[1]; + const value = validateProtocolValue(frame.payload[2], 'call result'); + if (final === true) { + call.result.resolve(value); + call.stream.close(); + this.#finishOutgoing(call); + } else if (final === false) { + if (call.credit < 1) throw new TypeError('CALL_RESULT exceeded stream credit'); + call.credit -= 1; + if (!call.stream.push(value)) throw new TypeError('CALL_RESULT followed a terminal frame'); + } else { + throw new TypeError('CALL_RESULT final flag is invalid'); + } + return true; + } + if (frame.opcode === Opcode.CallError) { + const call = this.#outgoingCall(frame); + const code = numeric(frame.payload[1], 'CALL_ERROR.code'); + const retryable = frame.payload[2]; + if (typeof retryable !== 'boolean') throw new TypeError('CALL_ERROR.retryable is invalid'); + const outcome = code === 1404 || (code === 1405 && call.wasAccepted) + ? 'outcome_unknown' + : call.wasAccepted + ? 'accepted' + : 'not_dispatched'; + const failure = relayFailure(code, retryable, outcome, { + kind: 'call', + localId: call.localId, + }); + this.#terminalOutgoing(call, failure); + this.#host.emitFailure(failure); + return true; + } + if (frame.opcode === Opcode.CallCancel) { + const id = numeric(frame.payload[0], 'CALL_CANCEL.callId'); + const incoming = this.#incoming.get(id); + if (incoming === undefined) throw new TypeError('CALL_CANCEL is not correlated'); + this.#finishIncoming(incoming, cancelled('outcome_unknown')); + return true; + } + if (frame.opcode === Opcode.CallCredit) { + const id = numeric(frame.payload[0], 'CALL_CREDIT.callId'); + const additional = numeric(frame.payload[1], 'CALL_CREDIT.additionalCredit'); + const incoming = this.#incoming.get(id); + if (incoming === undefined) throw new TypeError('CALL_CREDIT is not correlated'); + if (incoming.credit + additional > LIMITS.streamCredit) { + throw new TypeError('CALL_CREDIT exceeds the credit limit'); + } + incoming.credit += additional; + incoming.creditWaiter?.resolve(); + incoming.creditWaiter = undefined; + return true; + } + return false; + } + + handleError(id: number, code: number, retryable: boolean): boolean { + const call = this.#outgoing.get(id); + if (call === undefined || call.terminal) return false; + const outcome = code === 1404 || (code === 1405 && call.wasAccepted) + ? 'outcome_unknown' + : call.wasAccepted + ? 'accepted' + : 'not_dispatched'; + const failure = relayFailure(code, retryable, outcome, { + kind: 'call', + localId: call.localId, + }); + this.#terminalOutgoing(call, failure); + this.#host.emitFailure(failure); + return true; + } + + handleResponseError(id: number, code: number, retryable: boolean): boolean { + const route = this.#incoming.get(id); + if (route === undefined && !this.#completedIncoming.delete(id)) return false; + const failure = relayFailure(code, retryable, 'outcome_unknown'); + if (route !== undefined) { + this.#completedIncoming.delete(id); + if (route.terminal) { + route.controller.abort(failure); + this.#removeIncoming(route); + } else { + this.#finishIncoming(route, failure); + } + } + this.#host.emitFailure(failure); + return true; + } + + disconnected(): void { + for (const call of [...this.#outgoing.values()]) { + this.#terminalOutgoing(call, call.handedOff ? outcomeUnknown() : unavailable()); + } + for (const route of [...this.#incoming.values()]) { + this.#finishIncoming(route, outcomeUnknown()); + } + this.#functionIds.clear(); + this.#completedIncoming.clear(); + this.#epoch = undefined; + } + + stop(): void { + for (const call of [...this.#outgoing.values()]) { + if (call.handedOff && !call.terminal) { + void call.session.send({ opcode: Opcode.CallCancel, payload: [call.id, 1405] }); + } + this.#terminalOutgoing(call, call.handedOff ? outcomeUnknown() : cancelled('not_dispatched')); + } + for (const route of [...this.#incoming.values()]) { + this.#finishIncoming(route, cancelled('outcome_unknown')); + } + this.#completedIncoming.clear(); + } + + #outgoingCall(frame: Frame): OutgoingCall { + const id = numeric(frame.payload[0], 'callId'); + const call = this.#outgoing.get(id); + if (call === undefined || call.terminal) throw new TypeError('Call frame is not correlated'); + return call; + } + + #handleFunctionDictionary(frame: Frame): void { + const epoch = frame.payload[0]; + const replace = frame.payload[1]; + const entries = frame.payload[2]; + if (!(epoch instanceof Uint8Array) + || (replace !== true && replace !== false) + || !Array.isArray(entries)) { + throw new TypeError('FUNCTION_DICT is invalid'); + } + if (this.#epoch !== undefined + && (epoch.length !== this.#epoch.length + || epoch.some((value, index) => value !== this.#epoch?.[index]))) { + throw new TypeError('FUNCTION_DICT uses a stale epoch'); + } + if (replace) this.#functionIds.clear(); + for (const raw of entries) { + if (!Array.isArray(raw) || typeof raw[0] !== 'number' || typeof raw[1] !== 'string') { + throw new TypeError('FUNCTION_DICT entry is invalid'); + } + this.#functionIds.set(raw[1], raw[0]); + } + } + + #startIncoming(frame: Frame): void { + const id = numeric(frame.payload[0], 'CALL_DISPATCH.callId'); + if (this.#incoming.has(id)) throw new TypeError('CALL_DISPATCH was duplicated'); + this.#completedIncoming.delete(id); + const active = this.#host.activeDeclarations(); + const session = this.#host.currentSession(); + if (active === undefined || session === undefined) { + throw new TypeError('CALL_DISPATCH arrived without an active ready session'); + } + if (this.#incoming.size >= session.welcome.limits.inflightCalls) { + throw new TypeError('CALL_DISPATCH exceeds the negotiated concurrency limit'); + } + const functionId = numeric(frame.payload[4], 'CALL_DISPATCH.functionId'); + const declared = functionForId(active, functionId); + if (declared === undefined) throw new TypeError('CALL_DISPATCH has no active handler'); + const timeoutMs = numeric(frame.payload[5], 'CALL_DISPATCH.timeoutMs'); + const source = principal(frame.payload[1]); + const idempotencyKey = nullableString(frame.payload[6], 'CALL_DISPATCH.idempotencyKey'); + const credit = numeric(frame.payload[7], 'CALL_DISPATCH.initialCredit'); + const arguments_ = validateProtocolValue(frame.payload[8], 'call arguments'); + const controller = new AbortController(); + let route: IncomingRoute | undefined; + const timer = this.#host.runtime().setTimer(() => { + if (route !== undefined) { + this.#finishIncoming(route, cancelled('outcome_unknown', 'Incoming call deadline expired')); + } + }, timeoutMs); + route = { + id, + session, + controller, + credit, + creditWaiter: undefined, + emitTail: Promise.resolve(), + terminal: false, + timer, + }; + this.#incoming.set(id, route); + const incoming: IncomingCall = Object.freeze({ + source, + arguments: arguments_, + idempotencyKey, + signal: controller.signal, + emit: (value: ProtocolValue) => ( + this.#emitIncoming(route, validateProtocolValue(value, 'call progress')) + ), + }); + void Promise.resolve() + .then(() => declared.handler(incoming)) + .then((value) => validateProtocolValue(value, 'call result')) + .then((value) => this.#completeIncoming(route, value)) + .catch((error: unknown) => this.#failIncoming(route, error)) + .catch(() => undefined); + } + + #emitIncoming(route: IncomingRoute, value: ProtocolValue): Promise { + const emit = route.emitTail.then(async () => { + while (route.credit === 0) { + if (route.terminal) throw cancelled('outcome_unknown'); + route.creditWaiter ??= createDeferred(); + await route.creditWaiter.promise; + } + if (route.terminal) throw cancelled('outcome_unknown'); + route.credit -= 1; + await route.session.send({ + opcode: Opcode.CallResult, + payload: [route.id, false, value], + }); + }); + route.emitTail = emit.catch(() => undefined); + return emit; + } + + async #completeIncoming(route: IncomingRoute, value: ProtocolValue): Promise { + try { + await route.emitTail; + if (route.terminal) return; + route.terminal = true; + this.#rememberIncomingResponse(route.id); + await route.session.send({ + opcode: Opcode.CallResult, + payload: [route.id, true, value], + }); + this.#removeIncoming(route); + } catch { + route.controller.abort(outcomeUnknown()); + this.#removeIncoming(route); + } + } + + async #failIncoming(route: IncomingRoute, thrown: unknown): Promise { + if (route.terminal) return; + route.terminal = true; + this.#rememberIncomingResponse(route.id); + const application = thrown instanceof ApplicationCallError ? thrown : undefined; + if (application === undefined) { + safeLog(this.#host.logger(), { level: 'error', event: 'function_handler_failed' }); + } + try { + await route.session.send({ + opcode: Opcode.CallError, + payload: application === undefined + ? [route.id, 1500, false, 'Application handler failed', null] + : [route.id, application.code, application.retryable, application.message, null], + }); + } finally { + this.#removeIncoming(route); + } + } + + #grantCredit(id: number): void { + const call = this.#outgoing.get(id); + if (call === undefined || call.terminal || !call.wasAccepted) return; + call.credit += 1; + void call.session.send({ opcode: Opcode.CallCredit, payload: [id, 1] }).then( + () => undefined, + () => this.#terminalOutgoing(call, outcomeUnknown()), + ); + } + + #cancelOutgoing( + id: number, + reasonCode: number, + message: string, + awaitRelayTerminal: boolean, + ): void { + const call = this.#outgoing.get(id); + if (call === undefined || call.terminal) return; + if (call.cancellationRequested) { + if (!awaitRelayTerminal) this.#terminalOutgoing(call, outcomeUnknown(message)); + return; + } + if (call.handedOff) { + call.cancellationRequested = true; + void call.session.send({ opcode: Opcode.CallCancel, payload: [id, reasonCode] }).then( + () => { + if (!awaitRelayTerminal) this.#terminalOutgoing(call, outcomeUnknown(message)); + }, + () => this.#terminalOutgoing(call, outcomeUnknown(message)), + ); + } else { + this.#terminalOutgoing(call, cancelled('not_dispatched', message)); + } + } + + #terminalOutgoing(call: OutgoingCall, failure: CoordinatorError): void { + if (call.terminal) return; + call.terminal = true; + if (!call.accepted.settled) call.accepted.reject(failure); + call.result.reject(failure); + call.stream.fail(failure); + this.#finishOutgoing(call); + } + + #finishOutgoing(call: OutgoingCall): void { + call.terminal = true; + call.timer.cancel(); + if (call.signal !== undefined && call.abort !== undefined) { + call.signal.removeEventListener('abort', call.abort); + } + if (this.#outgoing.get(call.id) === call) this.#outgoing.delete(call.id); + } + + #finishIncoming(route: IncomingRoute, failure: CoordinatorError): void { + if (route.terminal) return; + route.terminal = true; + route.controller.abort(failure); + route.creditWaiter?.reject(failure); + this.#removeIncoming(route); + } + + #removeIncoming(route: IncomingRoute): void { + route.timer.cancel(); + if (this.#incoming.get(route.id) === route) this.#incoming.delete(route.id); + } + + #rememberIncomingResponse(id: number): void { + this.#completedIncoming.delete(id); + this.#completedIncoming.add(id); + if (this.#completedIncoming.size <= LIMITS.inflightCalls) return; + const oldest = this.#completedIncoming.values().next().value; + if (oldest !== undefined) this.#completedIncoming.delete(oldest); + } +} diff --git a/src/internal/declarations.ts b/src/internal/declarations.ts new file mode 100644 index 0000000..06a4333 --- /dev/null +++ b/src/internal/declarations.ts @@ -0,0 +1,582 @@ +import type { + CoordinatorAccess, + CoordinatorConfiguration, + DeclarationReceipt, + EventDeclaration, + FunctionHandler, + ProtocolValue, + UserEventAccess, + UserStateAccess, +} from '../api.js'; +import { LIMITS, Opcode, type Frame } from '../protocol/codec.js'; +import { + cancelled, + invalidLifecycle, + relayFailure, + superseded, + type CoordinatorError, +} from './errors.js'; +import { createDeferred, type Deferred } from './resources.js'; +import type { RelaySession } from './session.js'; +import { + validateDeclarationOptions, + validateEventAccess, + validateStateAccess, +} from './validation.js'; + +export type DeclarationDomain = + | 'state' + | 'state_access' + | 'events' + | 'event_access' + | 'functions'; + +export const DECLARATION_DOMAINS: readonly DeclarationDomain[] = Object.freeze([ + 'state', + 'state_access', + 'events', + 'event_access', + 'functions', +]); + +interface DeclarationRevisions { + state: number; + state_access: number; + events: number; + event_access: number; + functions: number; +} + +export interface DeclarationSnapshot { + state: Readonly>; + stateAccess: readonly UserStateAccess[]; + events: readonly EventDeclaration[]; + eventAccess: readonly UserEventAccess[]; + functions: Readonly>; + revisions: DeclarationRevisions; +} + +export interface ActiveDeclarations { + readonly snapshot: DeclarationSnapshot; + readonly receipt: DeclarationReceipt; + readonly stateIds: ReadonlyMap; + readonly topicIds: ReadonlyMap; + readonly functionIds: ReadonlyMap; +} + +export interface DeclarationHost { + nextRequestId(): number; + currentSession(): RelaySession | undefined; + synchronizing(): void; + activeDeclarationsChanged(active: ActiveDeclarations): void; + declarationsReady(receipt: DeclarationReceipt): void; + declarationFailure( + failure: CoordinatorError, + hasActiveConfiguration: boolean, + hasQueuedSnapshot: boolean, + ): void; + transportFailure(error: unknown): void; +} + +interface PendingDeclaration { + domain: DeclarationDomain; + revision: number; + deferred: Deferred; + signal?: AbortSignal; + abort?: () => void; +} + +interface DeclarationTransaction { + token: number; + snapshot: DeclarationSnapshot; + session: RelaySession; + receipt: DeclarationReceipt; + index: number; + handedOff: boolean; + stateIds: Map; + topicIds: Map; + functionIds: Map; +} + +interface DeclarationRequest { + token: number; + domain: DeclarationDomain; +} + +const EMPTY_REVISIONS: DeclarationRevisions = Object.freeze({ + state: 0, + state_access: 0, + events: 0, + event_access: 0, + functions: 0, +}); + +function emptySnapshot(): DeclarationSnapshot { + return Object.freeze({ + state: Object.freeze({}), + stateAccess: Object.freeze([]), + events: Object.freeze([]), + eventAccess: Object.freeze([]), + functions: Object.freeze({}), + revisions: EMPTY_REVISIONS, + }); +} + +function copyRevisions(value: DeclarationRevisions): DeclarationRevisions { + return Object.freeze({ ...value }); +} + +function copySnapshot(value: DeclarationSnapshot): DeclarationSnapshot { + return Object.freeze({ + state: value.state, + stateAccess: value.stateAccess, + events: value.events, + eventAccess: value.eventAccess, + functions: value.functions, + revisions: copyRevisions(value.revisions), + }); +} + +function revisionsEqual(left: DeclarationRevisions, right: DeclarationRevisions): boolean { + return DECLARATION_DOMAINS.every((domain) => left[domain] === right[domain]); +} + +function frameRequestId(frame: Frame): number | undefined { + const value = frame.payload[0]; + return typeof value === 'number' && Number.isSafeInteger(value) ? value : undefined; +} + +function dictionary( + value: ProtocolValue | undefined, + expectedNames: readonly string[], +): Map { + if (!Array.isArray(value)) throw new TypeError('Relay dictionary is not an array'); + const result = new Map(); + const ids = new Set(); + for (const raw of value) { + if (!Array.isArray(raw)) throw new TypeError('Relay dictionary entry is not an array'); + const id = raw[0]; + const name = raw[1]; + if (typeof id !== 'number' || !Number.isSafeInteger(id) || typeof name !== 'string') { + throw new TypeError('Relay dictionary entry is invalid'); + } + if (ids.has(id) || result.has(name)) throw new TypeError('Relay dictionary contains a duplicate'); + ids.add(id); + result.set(name, id); + } + if (result.size !== expectedNames.length + || expectedNames.some((name) => !result.has(name))) { + throw new TypeError('Relay dictionary does not match the declared snapshot'); + } + return result; +} + +function ackDomain(opcode: number): DeclarationDomain | undefined { + if (opcode === Opcode.StateSyncOk) return 'state'; + if (opcode === Opcode.StateAclOk) return 'state_access'; + if (opcode === Opcode.EventSyncOk) return 'events'; + if (opcode === Opcode.EventAclOk) return 'event_access'; + if (opcode === Opcode.FunctionSyncOk) return 'functions'; + return undefined; +} + +export class DeclarationManager { + readonly #host: DeclarationHost; + readonly #pending: PendingDeclaration[] = []; + readonly #requests = new Map(); + #desired = emptySnapshot(); + #active: ActiveDeclarations | undefined; + #current: DeclarationTransaction | undefined; + #transactionToken = 0; + #stopped = false; + + constructor(host: DeclarationHost) { + this.#host = host; + } + + get desired(): DeclarationSnapshot { + return this.#desired; + } + + get active(): ActiveDeclarations | undefined { + return this.#active; + } + + configure(configuration: CoordinatorConfiguration): void { + for (const domain of DECLARATION_DOMAINS) this.#supersedeUnprotected(domain); + const revisions = { + state: this.#desired.revisions.state + 1, + state_access: this.#desired.revisions.state_access + 1, + events: this.#desired.revisions.events + 1, + event_access: this.#desired.revisions.event_access + 1, + functions: this.#desired.revisions.functions + 1, + }; + this.#desired = Object.freeze({ + ...configuration, + revisions: Object.freeze(revisions), + }); + } + + declareState( + state: Readonly>, + signal?: AbortSignal, + ): Promise { + return this.#replace( + 'state', + (snapshot, revisions) => copySnapshot({ ...snapshot, state, revisions }), + signal, + ); + } + + declareStateAccess( + stateAccess: readonly UserStateAccess[], + signal?: AbortSignal, + ): Promise { + return this.#replace( + 'state_access', + (snapshot, revisions) => copySnapshot({ ...snapshot, stateAccess, revisions }), + signal, + ); + } + + declareEvents( + events: readonly EventDeclaration[], + signal?: AbortSignal, + ): Promise { + return this.#replace( + 'events', + (snapshot, revisions) => copySnapshot({ ...snapshot, events, revisions }), + signal, + ); + } + + declareEventAccess( + eventAccess: readonly UserEventAccess[], + signal?: AbortSignal, + ): Promise { + return this.#replace( + 'event_access', + (snapshot, revisions) => copySnapshot({ ...snapshot, eventAccess, revisions }), + signal, + ); + } + + declareFunctions( + functions: Readonly>, + signal?: AbortSignal, + ): Promise { + return this.#replace( + 'functions', + (snapshot, revisions) => copySnapshot({ ...snapshot, functions, revisions }), + signal, + ); + } + + synchronize(session: RelaySession): void { + this.#requests.clear(); + this.#current = undefined; + this.#begin(session); + } + + handleFrame(frame: Frame): boolean { + const domain = ackDomain(frame.opcode); + if (domain === undefined) return false; + const requestId = frameRequestId(frame); + if (requestId === undefined) throw new TypeError('Declaration acknowledgement has no request ID'); + const request = this.#requests.get(requestId); + if (request === undefined) throw new TypeError('Declaration acknowledgement is not correlated'); + this.#requests.delete(requestId); + const current = this.#current; + if (current === undefined || request.token !== current.token) return true; + const expectedDomain = DECLARATION_DOMAINS[current.index]; + if (request.domain !== domain || expectedDomain !== domain) { + throw new TypeError('Declaration acknowledgement is out of order'); + } + if (domain === 'state') { + const epoch = frame.payload[1]; + const expectedEpoch = current.session.welcome.epoch; + if (!(epoch instanceof Uint8Array) + || epoch.length !== expectedEpoch.length + || epoch.some((value, index) => value !== expectedEpoch[index])) { + throw new TypeError('STATE_SYNC_OK uses a stale epoch'); + } + current.stateIds = dictionary(frame.payload[3], Object.keys(current.snapshot.state)); + } + if (domain === 'events') { + current.topicIds = dictionary( + frame.payload[1], + current.snapshot.events.map(({ topic }) => topic), + ); + } + if (domain === 'functions') { + current.functionIds = dictionary(frame.payload[1], Object.keys(current.snapshot.functions)); + } + current.index += 1; + if (current.index === DECLARATION_DOMAINS.length) this.#activate(current); + else void this.#sendCurrentDomain(current); + return true; + } + + handleError(requestId: number, code: number, retryable: boolean): boolean { + const request = this.#requests.get(requestId); + if (request === undefined) return false; + this.#requests.delete(requestId); + const current = this.#current; + if (current === undefined || request.token !== current.token) return true; + if (retryable) { + this.#host.transportFailure(relayFailure(code, true, 'not_dispatched')); + return true; + } + this.#rejectTransaction(current, relayFailure(code, false, 'not_dispatched')); + return true; + } + + disconnected(): void { + this.#requests.clear(); + this.#current = undefined; + } + + stop(): void { + this.#stopped = true; + this.#requests.clear(); + this.#current = undefined; + for (const pending of this.#pending.splice(0)) { + this.#removePendingAbort(pending); + pending.deferred.reject(cancelled('not_dispatched', 'Coordinator stopped before declaration activation')); + } + } + + #replace( + domain: DeclarationDomain, + update: ( + snapshot: DeclarationSnapshot, + revisions: DeclarationRevisions, + ) => DeclarationSnapshot, + signal?: AbortSignal, + ): Promise { + if (this.#stopped) return Promise.reject(invalidLifecycle('Coordinator is stopped')); + if (signal?.aborted === true) return Promise.reject(cancelled('not_dispatched')); + if (this.#pending.length >= LIMITS.declarationsPerCoordinator + || this.#requests.size >= LIMITS.declarationsPerCoordinator) { + return Promise.reject(new RangeError('Too many pending declarations')); + } + this.#supersedeUnprotected(domain); + const revision = this.#desired.revisions[domain] + 1; + const revisions = copyRevisions({ ...this.#desired.revisions, [domain]: revision }); + this.#desired = update(this.#desired, revisions); + const deferred = createDeferred(); + const pending: PendingDeclaration = { domain, revision, deferred }; + if (signal !== undefined) { + const abort = () => { + const index = this.#pending.indexOf(pending); + if (index !== -1) this.#pending.splice(index, 1); + deferred.reject(cancelled('not_dispatched')); + }; + pending.signal = signal; + pending.abort = abort; + signal.addEventListener('abort', abort, { once: true }); + } + this.#pending.push(pending); + + const session = this.#host.currentSession(); + if (session !== undefined) { + if (this.#current === undefined) this.#begin(session); + else if (!this.#current.handedOff) { + this.#current = undefined; + this.#begin(session); + } + } + return deferred.promise; + } + + #supersedeUnprotected(domain: DeclarationDomain): void { + const protectedRevision = this.#current?.handedOff === true + ? this.#current.snapshot.revisions[domain] + : 0; + for (let index = this.#pending.length - 1; index >= 0; index -= 1) { + const pending = this.#pending[index]; + if (pending?.domain !== domain || pending.revision <= protectedRevision) continue; + this.#pending.splice(index, 1); + this.#removePendingAbort(pending); + pending.deferred.reject(superseded()); + } + } + + #begin(session: RelaySession): void { + this.#transactionToken += 1; + const welcome = session.welcome; + this.#current = { + token: this.#transactionToken, + snapshot: copySnapshot(this.#desired), + session, + receipt: Object.freeze({ + sessionId: welcome.readySession.sessionId, + generation: welcome.readySession.generation, + }), + index: 0, + handedOff: false, + stateIds: new Map(), + topicIds: new Map(), + functionIds: new Map(), + }; + this.#host.synchronizing(); + if (this.#stopped) { + this.#current = undefined; + return; + } + void this.#sendCurrentDomain(this.#current); + } + + async #sendCurrentDomain(transaction: DeclarationTransaction): Promise { + const domain = DECLARATION_DOMAINS[transaction.index]; + if (domain === undefined || this.#current?.token !== transaction.token) return; + const requestId = this.#host.nextRequestId(); + this.#requests.set(requestId, { token: transaction.token, domain }); + const frame = this.#frameFor(transaction.snapshot, domain, requestId); + if (domain === 'functions') transaction.handedOff = true; + try { + await transaction.session.send(frame); + } catch (error) { + if (this.#current?.token === transaction.token) this.#host.transportFailure(error); + } + } + + #frameFor(snapshot: DeclarationSnapshot, domain: DeclarationDomain, requestId: number): Frame { + if (domain === 'state') { + return { + opcode: Opcode.StateSync, + payload: [requestId, Object.entries(snapshot.state).map(([path, value]) => [path, value])], + }; + } + if (domain === 'state_access') { + return { + opcode: Opcode.StateAclSync, + payload: [requestId, snapshot.stateAccess.map(({ userId, patterns }) => [ + userId, + [...patterns], + ])], + }; + } + if (domain === 'events') { + return { + opcode: Opcode.EventSync, + payload: [requestId, snapshot.events.map(({ topic, directions }) => [topic, directions])], + }; + } + if (domain === 'event_access') { + return { + opcode: Opcode.EventAclSync, + payload: [requestId, snapshot.eventAccess.map(({ userId, publish, subscribe }) => [ + userId, + [...publish], + [...subscribe], + ])], + }; + } + return { + opcode: Opcode.FunctionSync, + payload: [requestId, Object.keys(snapshot.functions)], + }; + } + + #activate(transaction: DeclarationTransaction): void { + if (this.#current?.token !== transaction.token) return; + this.#active = Object.freeze({ + snapshot: transaction.snapshot, + receipt: transaction.receipt, + stateIds: transaction.stateIds, + topicIds: transaction.topicIds, + functionIds: transaction.functionIds, + }); + this.#current = undefined; + this.#host.activeDeclarationsChanged(this.#active); + this.#settleThrough(transaction.snapshot.revisions, transaction.receipt, undefined); + if (!revisionsEqual(this.#desired.revisions, transaction.snapshot.revisions)) { + this.#begin(transaction.session); + } else { + this.#host.declarationsReady(transaction.receipt); + } + } + + #rejectTransaction(transaction: DeclarationTransaction, failure: CoordinatorError): void { + if (this.#current?.token !== transaction.token) return; + this.#current = undefined; + this.#settleThrough(transaction.snapshot.revisions, transaction.receipt, failure); + const activeSnapshot = this.#active?.snapshot ?? emptySnapshot(); + const desired = this.#desired; + const revisions = { ...desired.revisions }; + const next = { + state: desired.state, + stateAccess: desired.stateAccess, + events: desired.events, + eventAccess: desired.eventAccess, + functions: desired.functions, + }; + for (const domain of DECLARATION_DOMAINS) { + if (desired.revisions[domain] > transaction.snapshot.revisions[domain]) continue; + revisions[domain] = activeSnapshot.revisions[domain]; + if (domain === 'state') next.state = activeSnapshot.state; + if (domain === 'state_access') next.stateAccess = activeSnapshot.stateAccess; + if (domain === 'events') next.events = activeSnapshot.events; + if (domain === 'event_access') next.eventAccess = activeSnapshot.eventAccess; + if (domain === 'functions') next.functions = activeSnapshot.functions; + } + this.#desired = copySnapshot({ ...next, revisions: copyRevisions(revisions) }); + const hasQueuedSnapshot = !revisionsEqual(this.#desired.revisions, activeSnapshot.revisions); + const hasActiveConfiguration = this.#active?.receipt.sessionId === transaction.receipt.sessionId + && this.#active.receipt.generation === transaction.receipt.generation; + this.#host.declarationFailure(failure, hasActiveConfiguration, hasQueuedSnapshot); + if (hasQueuedSnapshot) { + this.#begin(transaction.session); + } + } + + #settleThrough( + revisions: DeclarationRevisions, + receipt: DeclarationReceipt, + failure: CoordinatorError | undefined, + ): void { + for (let index = this.#pending.length - 1; index >= 0; index -= 1) { + const pending = this.#pending[index]; + if (pending === undefined || pending.revision > revisions[pending.domain]) continue; + this.#pending.splice(index, 1); + this.#removePendingAbort(pending); + if (failure === undefined) pending.deferred.resolve(receipt); + else pending.deferred.reject(failure); + } + } + + #removePendingAbort(pending: PendingDeclaration): void { + if (pending.signal !== undefined && pending.abort !== undefined) { + pending.signal.removeEventListener('abort', pending.abort); + } + } +} + +export class AccessManager implements CoordinatorAccess { + readonly #declarations: DeclarationManager; + + constructor(declarations: DeclarationManager) { + this.#declarations = declarations; + } + + declareState( + entries: readonly UserStateAccess[], + options: import('../api.js').DeclarationOptions = {}, + ): Promise { + return this.#declarations.declareStateAccess( + validateStateAccess(entries), + validateDeclarationOptions(options, 'state access declaration'), + ); + } + + declareEvents( + entries: readonly UserEventAccess[], + options: import('../api.js').DeclarationOptions = {}, + ): Promise { + return this.#declarations.declareEventAccess( + validateEventAccess(entries), + validateDeclarationOptions(options, 'event access declaration'), + ); + } +} diff --git a/src/internal/errors.ts b/src/internal/errors.ts new file mode 100644 index 0000000..7d28169 --- /dev/null +++ b/src/internal/errors.ts @@ -0,0 +1,109 @@ +import type { + CoordinatorFailure, + CoordinatorLogRecord, + CoordinatorLogger, + DispatchOutcome, +} from '../api.js'; + +interface FailureOptions { + code?: number; + correlation?: { + kind: 'event' | 'call'; + localId: string; + }; + retryable?: boolean; +} + +export class CoordinatorError extends Error implements CoordinatorFailure { + readonly kind: CoordinatorFailure['kind']; + readonly retryable: boolean; + readonly outcome: DispatchOutcome; + readonly code?: number; + readonly correlation?: { + kind: 'event' | 'call'; + localId: string; + }; + + constructor( + kind: CoordinatorFailure['kind'], + outcome: DispatchOutcome, + message: string, + options: FailureOptions = {}, + ) { + super(message); + this.name = 'CoordinatorFailure'; + this.kind = kind; + this.outcome = outcome; + this.retryable = options.retryable ?? false; + if (options.code !== undefined) this.code = options.code; + if (options.correlation !== undefined) { + this.correlation = Object.freeze({ ...options.correlation }); + } + } +} + +export function invalidLifecycle(message: string): CoordinatorError { + return new CoordinatorError('invalid_lifecycle', 'not_dispatched', message); +} + +export function unavailable(message = 'Coordinator is not ready'): CoordinatorError { + return new CoordinatorError('unavailable', 'not_dispatched', message, { retryable: true }); +} + +export function cancelled( + outcome: 'not_dispatched' | 'outcome_unknown', + message = 'Operation was cancelled', +): CoordinatorError { + return new CoordinatorError('cancelled', outcome, message); +} + +export function superseded(): CoordinatorError { + return new CoordinatorError( + 'superseded', + 'not_dispatched', + 'Declaration was superseded by a newer desired snapshot', + ); +} + +export function outcomeUnknown(message = 'Transport closed after operation handoff'): CoordinatorError { + return new CoordinatorError('unavailable', 'outcome_unknown', message, { retryable: false }); +} + +export function internalFailure(message = 'Coordinator internal failure'): CoordinatorError { + return new CoordinatorError('internal', 'not_dispatched', message); +} + +export function protocolFailure(message = 'Relay protocol violation'): CoordinatorError { + return new CoordinatorError('protocol', 'not_dispatched', message); +} + +function kindFromCode(code: number): CoordinatorFailure['kind'] { + if (code >= 1100 && code <= 1102) return 'authentication'; + if (code >= 1200 && code <= 1203) return 'authorization'; + if (code >= 1300 && code <= 1305) return 'conflict'; + if (code === 1405) return 'cancelled'; + if (code >= 1000 && code <= 1005) return 'protocol'; + if (code === 1500) return 'internal'; + return 'unavailable'; +} + +export function relayFailure( + code: number, + retryable: boolean, + outcome: DispatchOutcome, + correlation?: FailureOptions['correlation'], +): CoordinatorError { + const options: FailureOptions = correlation === undefined + ? { code, retryable } + : { code, retryable, correlation }; + return new CoordinatorError(kindFromCode(code), outcome, 'Relay rejected the operation', options); +} + +export function safeLog(logger: CoordinatorLogger | undefined, record: CoordinatorLogRecord): void { + if (logger === undefined) return; + try { + logger.write(Object.freeze({ ...record })); + } catch { + // A diagnostic sink must never control coordinator execution. + } +} diff --git a/src/internal/events.ts b/src/internal/events.ts new file mode 100644 index 0000000..8a60fe2 --- /dev/null +++ b/src/internal/events.ts @@ -0,0 +1,250 @@ +import type { + CoordinatorEvents, + CoordinatorLogger, + DeclarationOptions, + EventDeclaration, + EventHandle, + EventTarget, + IncomingEvent, + OperationOptions, + Principal, + ProtocolValue, + SentEvent, + Unsubscribe, +} from '../api.js'; +import { Opcode, type Frame } from '../protocol/codec.js'; +import type { ActiveDeclarations, DeclarationManager } from './declarations.js'; +import { cancelled, outcomeUnknown, relayFailure, safeLog, unavailable } from './errors.js'; +import { createDeferred, ListenerSet, type Deferred } from './resources.js'; +import type { RelaySession } from './session.js'; +import { + targetFields, + validateDeclarationOptions, + validateEventDeclarations, + validateEventPublishOptions, + validateProtocolValue, + validateStructuredName, +} from './validation.js'; + +export interface EventHost { + readySession(): RelaySession | undefined; + activeDeclarations(): ActiveDeclarations | undefined; + nextEventId(): number; + nextLocalEventId(): number; + emitFailure(failure: ReturnType): void; + logger(): CoordinatorLogger | undefined; +} + +interface PublishedEvent { + localId: string; + sent: Deferred; + handedOff: boolean; + signal?: AbortSignal; + abort?: () => void; +} + +function principal(value: ProtocolValue | undefined): Principal { + if (!Array.isArray(value)) throw new TypeError('EVENT source is not a principal'); + const kind = value[0] === 1 ? 'user' : value[0] === 2 ? 'coordinator' : value[0] === 3 ? 'cli' : undefined; + if (kind === undefined + || typeof value[1] !== 'string' + || typeof value[2] !== 'number' + || (value[3] !== null && typeof value[3] !== 'string') + || (value[4] !== null && typeof value[4] !== 'string')) { + throw new TypeError('EVENT source principal is invalid'); + } + return Object.freeze({ + kind, + id: value[1], + sessionId: value[2], + coordinatorName: value[3], + verifiedEmail: value[4], + }); +} + +function topicForId(active: ActiveDeclarations, id: number): string | undefined { + for (const [topic, topicId] of active.topicIds) { + if (topicId === id) return topic; + } + return undefined; +} + +export class EventManager implements CoordinatorEvents { + readonly #host: EventHost; + readonly #declarations: DeclarationManager; + readonly #listeners = new Map>(); + readonly #published = new Map(); + #eventSessionId: number | undefined; + #lastEventId = 0; + + constructor(host: EventHost, declarations: DeclarationManager) { + this.#host = host; + this.#declarations = declarations; + } + + declare( + entries: readonly EventDeclaration[], + options: DeclarationOptions = {}, + ): Promise { + const signal = validateDeclarationOptions(options, 'event declaration'); + return this.#declarations.declareEvents(validateEventDeclarations(entries), signal); + } + + publish( + topic: string, + value: ProtocolValue, + options: OperationOptions & { target?: EventTarget } = {}, + ): EventHandle { + const validatedTopic = validateStructuredName(topic, 'event topic'); + const validatedValue = validateProtocolValue(value, 'event value'); + const validatedOptions = validateEventPublishOptions(options); + const signal = validatedOptions.signal; + const defaultTarget: EventTarget = Object.freeze({ kind: 'default' }); + const target = validatedOptions.target === undefined + ? defaultTarget + : validatedOptions.target; + if (signal?.aborted === true) { + const sent = createDeferred(); + const handle = Object.freeze({ + localId: `event:local:${this.#host.nextLocalEventId()}`, + sent: sent.promise, + }); + sent.reject(cancelled('not_dispatched')); + return handle; + } + const session = this.#host.readySession(); + const active = this.#host.activeDeclarations(); + const topicId = active?.topicIds.get(validatedTopic); + if (session === undefined || active === undefined || topicId === undefined) { + const sent = createDeferred(); + const handle = Object.freeze({ + localId: `event:local:${this.#host.nextLocalEventId()}`, + sent: sent.promise, + }); + sent.reject(unavailable('Event topic is not active in a ready session')); + return handle; + } + const eventId = this.#host.nextEventId(); + const sessionId = session.welcome.readySession.sessionId; + if (this.#eventSessionId !== sessionId) { + this.#eventSessionId = sessionId; + this.#lastEventId = 0; + } + this.#lastEventId = eventId; + const localId = `event:${sessionId}:${eventId}`; + const sent = createDeferred(); + const handle = Object.freeze({ localId, sent: sent.promise }); + const pending: PublishedEvent = { localId, sent, handedOff: false }; + if (signal !== undefined) { + const abort = () => { + if (!this.#published.delete(eventId)) return; + sent.reject(pending.handedOff ? outcomeUnknown() : cancelled('not_dispatched')); + }; + pending.signal = signal; + pending.abort = abort; + signal.addEventListener('abort', abort, { once: true }); + } + this.#published.set(eventId, pending); + const [targetKind, targetValue] = targetFields(target); + pending.handedOff = true; + void session.send({ + opcode: Opcode.Event, + payload: [eventId, topicId, targetKind, targetValue, validatedValue], + }).then(() => { + if (this.#published.get(eventId) !== pending) return; + this.#published.delete(eventId); + this.#removeAbort(pending); + sent.resolve(Object.freeze({ outcome: 'sent' })); + }, () => { + if (this.#published.get(eventId) !== pending) return; + this.#published.delete(eventId); + this.#removeAbort(pending); + sent.reject(unavailable('Event was not handed to the transport')); + }); + return handle; + } + + subscribe(topic: string, listener: (event: IncomingEvent) => void): Unsubscribe { + const validatedTopic = validateStructuredName(topic, 'event subscription topic'); + if (typeof listener !== 'function') throw new TypeError('event listener must be a function'); + let listeners = this.#listeners.get(validatedTopic); + if (listeners === undefined) { + listeners = new ListenerSet(); + this.#listeners.set(validatedTopic, listeners); + } + const unsubscribe = listeners.subscribe(listener); + let active = true; + return () => { + if (!active) return; + active = false; + unsubscribe(); + if (listeners?.size === 0) this.#listeners.delete(validatedTopic); + }; + } + + handleFrame(frame: Frame): boolean { + if (frame.opcode !== Opcode.Event) return false; + const topicId = frame.payload[1]; + if (typeof topicId !== 'number') throw new TypeError('EVENT has no topic ID'); + const active = this.#host.activeDeclarations(); + if (active === undefined) throw new TypeError('EVENT arrived without active declarations'); + const topic = topicForId(active, topicId); + if (topic === undefined) throw new TypeError('EVENT uses an unknown topic ID'); + const event = Object.freeze({ + source: principal(frame.payload[4]), + topic, + value: validateProtocolValue(frame.payload[5], 'incoming event value'), + }); + this.#listeners.get(topic)?.emit(event, () => { + safeLog(this.#host.logger(), { level: 'error', event: 'event_listener_failed' }); + }); + return true; + } + + handleError(eventId: number, code: number, retryable: boolean): boolean { + const pending = this.#published.get(eventId); + let localId: string; + if (pending === undefined) { + if (this.#eventSessionId === undefined || eventId < 1 || eventId > this.#lastEventId) { + return false; + } + localId = `event:${this.#eventSessionId}:${eventId}`; + } else { + this.#published.delete(eventId); + this.#removeAbort(pending); + pending.handedOff = true; + pending.sent.resolve(Object.freeze({ outcome: 'sent' })); + localId = pending.localId; + } + const failure = relayFailure(code, retryable, 'sent', { + kind: 'event', + localId, + }); + if (pending === undefined) this.#host.emitFailure(failure); + else void pending.sent.promise.then(() => this.#host.emitFailure(failure)); + return true; + } + + disconnected(): void { + for (const pending of this.#published.values()) { + this.#removeAbort(pending); + if (!pending.sent.settled) { + pending.sent.reject(pending.handedOff ? outcomeUnknown() : unavailable()); + } + } + this.#published.clear(); + this.#eventSessionId = undefined; + this.#lastEventId = 0; + } + + stop(): void { + this.disconnected(); + this.#listeners.clear(); + } + + #removeAbort(pending: PublishedEvent): void { + if (pending.signal !== undefined && pending.abort !== undefined) { + pending.signal.removeEventListener('abort', pending.abort); + } + } +} diff --git a/src/internal/presence.ts b/src/internal/presence.ts new file mode 100644 index 0000000..0cf4f63 --- /dev/null +++ b/src/internal/presence.ts @@ -0,0 +1,112 @@ +import type { + CoordinatorLogger, + CoordinatorPresence, + PresenceEntry, + Unsubscribe, +} from '../api.js'; +import { Opcode, type Frame, type ProtocolValue } from '../protocol/codec.js'; +import { safeLog } from './errors.js'; +import { ListenerSet } from './resources.js'; + +export interface PresenceHost { + logger(): CoordinatorLogger | undefined; +} + +function entry(value: ProtocolValue, label: string): PresenceEntry { + if (!Array.isArray(value) + || typeof value[0] !== 'number' + || !Number.isSafeInteger(value[0]) + || typeof value[1] !== 'string') { + throw new TypeError(`${label} is invalid`); + } + return Object.freeze({ sessionId: value[0], userId: value[1] }); +} + +export class PresenceManager implements CoordinatorPresence { + readonly #host: PresenceHost; + readonly #listeners = new ListenerSet(); + readonly #entries = new Map(); + + constructor(host: PresenceHost) { + this.#host = host; + } + + snapshot(): readonly PresenceEntry[] { + return Object.freeze( + [...this.#entries.values()] + .sort((left, right) => left.sessionId - right.sessionId) + .map((value) => Object.freeze({ ...value })), + ); + } + + subscribe(listener: (entries: readonly PresenceEntry[]) => void): Unsubscribe { + if (typeof listener !== 'function') throw new TypeError('presence listener must be a function'); + const unsubscribe = this.#listeners.subscribe(listener); + try { + listener(this.snapshot()); + } catch { + safeLog(this.#host.logger(), { level: 'error', event: 'presence_listener_failed' }); + } + return unsubscribe; + } + + handleFrame(frame: Frame): boolean { + if (frame.opcode === Opcode.PresenceSnapshot) { + const values = frame.payload[0]; + if (!Array.isArray(values)) throw new TypeError('PRESENCE_SNAPSHOT entries are invalid'); + const next = new Map(); + values.forEach((value, index) => { + const presence = entry(value, `PRESENCE_SNAPSHOT[${index}]`); + if (next.has(presence.sessionId)) { + throw new TypeError('PRESENCE_SNAPSHOT contains a duplicate session'); + } + next.set(presence.sessionId, presence); + }); + this.#entries.clear(); + for (const [sessionId, presence] of next) this.#entries.set(sessionId, presence); + this.#emit(); + return true; + } + if (frame.opcode === Opcode.PresenceChange) { + const sessionId = frame.payload[0]; + const userId = frame.payload[1]; + const change = frame.payload[2]; + if (typeof sessionId !== 'number' + || !Number.isSafeInteger(sessionId) + || typeof userId !== 'string' + || (change !== 1 && change !== 2)) { + throw new TypeError('PRESENCE_CHANGE is invalid'); + } + const existing = this.#entries.get(sessionId); + if (change === 1) { + if (existing !== undefined) throw new TypeError('PRESENCE_CHANGE duplicates a session'); + this.#entries.set(sessionId, Object.freeze({ sessionId, userId })); + } else { + if (existing === undefined || existing.userId !== userId) { + throw new TypeError('PRESENCE_CHANGE disconnects an unknown session'); + } + this.#entries.delete(sessionId); + } + this.#emit(); + return true; + } + return false; + } + + disconnected(): void { + if (this.#entries.size === 0) return; + this.#entries.clear(); + this.#emit(); + } + + stop(): void { + this.disconnected(); + this.#listeners.clear(); + } + + #emit(): void { + this.#listeners.emit(this.snapshot(), () => { + safeLog(this.#host.logger(), { level: 'error', event: 'presence_listener_failed' }); + }); + } +} diff --git a/src/internal/resources.ts b/src/internal/resources.ts new file mode 100644 index 0000000..6768ae9 --- /dev/null +++ b/src/internal/resources.ts @@ -0,0 +1,155 @@ +import type { Unsubscribe } from '../api.js'; + +export interface Deferred { + readonly promise: Promise; + readonly settled: boolean; + resolve(value: T | PromiseLike): void; + reject(reason: unknown): void; +} + +export function createDeferred(): Deferred { + let resolvePromise: ((value: T | PromiseLike) => void) | undefined; + let rejectPromise: ((reason: unknown) => void) | undefined; + let settled = false; + const promise = new Promise((resolve, reject) => { + resolvePromise = resolve; + rejectPromise = reject; + }); + return { + promise, + get settled() { return settled; }, + resolve(value) { + if (settled) return; + settled = true; + resolvePromise?.(value); + }, + reject(reason) { + if (settled) return; + settled = true; + rejectPromise?.(reason); + }, + }; +} + +export class ListenerSet { + readonly #listeners = new Set<(value: T) => void>(); + + subscribe(listener: (value: T) => void): Unsubscribe { + this.#listeners.add(listener); + let active = true; + return () => { + if (!active) return; + active = false; + this.#listeners.delete(listener); + }; + } + + emit(value: T, onError?: (error: unknown) => void): void { + for (const listener of [...this.#listeners]) { + try { + listener(value); + } catch (error) { + onError?.(error); + } + } + } + + clear(): void { + this.#listeners.clear(); + } + + get size(): number { + return this.#listeners.size; + } +} + +export class IdSequence { + #next = 1; + + take(): number { + if (!Number.isSafeInteger(this.#next)) { + throw new RangeError('Identifier namespace is exhausted'); + } + const value = this.#next; + this.#next += 1; + return value; + } +} + +interface QueueWaiter { + resolve(value: IteratorResult): void; + reject(reason: unknown): void; +} + +export class AsyncValueQueue implements AsyncIterableIterator { + readonly #values: Array<{ value: T }> = []; + readonly #waiters: QueueWaiter[] = []; + readonly #onConsume: (() => void) | undefined; + #terminal: 'open' | 'closed' | 'failed' = 'open'; + #failure: unknown; + + constructor(onConsume?: () => void) { + this.#onConsume = onConsume; + } + + push(value: T): boolean { + if (this.#terminal !== 'open') return false; + const waiter = this.#waiters.shift(); + if (waiter !== undefined) { + waiter.resolve({ done: false, value }); + this.#onConsume?.(); + } else { + this.#values.push({ value }); + } + return true; + } + + close(): void { + if (this.#terminal !== 'open') return; + this.#terminal = 'closed'; + for (const waiter of this.#waiters.splice(0)) { + waiter.resolve({ done: true, value: undefined }); + } + } + + fail(reason: unknown): void { + if (this.#terminal !== 'open') return; + this.#terminal = 'failed'; + this.#failure = reason; + this.#values.length = 0; + for (const waiter of this.#waiters.splice(0)) waiter.reject(reason); + } + + next(): Promise> { + const entry = this.#values.shift(); + if (entry !== undefined) { + this.#onConsume?.(); + return Promise.resolve({ done: false, value: entry.value }); + } + if (this.#terminal === 'closed') { + return Promise.resolve({ done: true, value: undefined }); + } + if (this.#terminal === 'failed') return Promise.reject(this.#failure); + return new Promise>((resolve, reject) => { + this.#waiters.push({ resolve, reject }); + }); + } + + [Symbol.asyncIterator](): AsyncIterableIterator { + return this; + } +} + +export function childAbortController(parent?: AbortSignal): { + controller: AbortController; + dispose: Unsubscribe; +} { + const controller = new AbortController(); + const abort = () => controller.abort(parent?.reason); + if (parent?.aborted === true) abort(); + else parent?.addEventListener('abort', abort, { once: true }); + return { + controller, + dispose() { parent?.removeEventListener('abort', abort); }, + }; +} diff --git a/src/internal/runtime.ts b/src/internal/runtime.ts new file mode 100644 index 0000000..3f5de32 --- /dev/null +++ b/src/internal/runtime.ts @@ -0,0 +1,52 @@ +import type { Frame } from '../protocol/codec.js'; + +export interface SocketHandlers { + message(bytes: Uint8Array): void; + close(code: number, reason: string): void; + error(error: Error): void; +} + +export interface ManagedSocket { + readonly bufferedBytes: number; + write(bytes: Uint8Array): Promise; + close(code?: number, reason?: string): void; + terminate(): void; + detach(): void; +} + +export interface SocketFactory { + connect(url: string, handlers: SocketHandlers, signal: AbortSignal): Promise; +} + +export interface RuntimeTimer { + cancel(): void; +} + +export interface CoordinatorRuntime { + readonly socketFactory: SocketFactory; + now(): number; + random(): number; + setTimer(callback: () => void, delayMs: number): RuntimeTimer; +} + +export interface SessionTransport { + readonly generation: number; + readonly epoch: Uint8Array; + send(frame: Frame): Promise; +} + +export function delay(runtime: CoordinatorRuntime, delayMs: number, signal: AbortSignal): Promise { + if (signal.aborted) return Promise.reject(signal.reason); + return new Promise((resolve, reject) => { + const timer = runtime.setTimer(() => { + signal.removeEventListener('abort', abort); + resolve(); + }, delayMs); + const abort = () => { + timer.cancel(); + signal.removeEventListener('abort', abort); + reject(signal.reason); + }; + signal.addEventListener('abort', abort, { once: true }); + }); +} diff --git a/src/internal/session.ts b/src/internal/session.ts new file mode 100644 index 0000000..d97bfd1 --- /dev/null +++ b/src/internal/session.ts @@ -0,0 +1,243 @@ +import type { ReadySession } from '../api.js'; +import { + Opcode, + type Frame, + type ProtocolValue, +} from '../protocol/codec.js'; +import { CoordinatorProtocolSession } from '../protocol/session.js'; +import { CoordinatorError, protocolFailure } from './errors.js'; +import { createDeferred } from './resources.js'; +import type { CoordinatorRuntime, ManagedSocket, SocketHandlers } from './runtime.js'; + +export interface RelayLimits { + frameBytes: number; + inflightCalls: number; + subscriptions: number; + queuedBytes: number; +} + +export interface RelayWelcome { + readySession: ReadySession; + epoch: Uint8Array; + expiresAtMs: number; + limits: RelayLimits; +} + +export interface RelaySessionCallbacks { + frame(frame: Frame): void; + closed(code: number, reason: string): void; + failed(error: Error): void; +} + +function numberField(value: ProtocolValue | undefined, label: string): number { + if (typeof value !== 'number' || !Number.isSafeInteger(value)) { + throw protocolFailure(`${label} is not an integer`); + } + return value; +} + +function bytesField(value: ProtocolValue | undefined, label: string): Uint8Array { + if (!(value instanceof Uint8Array)) throw protocolFailure(`${label} is not binary`); + return value.slice(); +} + +function arrayField(value: ProtocolValue | undefined, label: string): ProtocolValue[] { + if (!Array.isArray(value)) throw protocolFailure(`${label} is not an array`); + return value; +} + +function generationForCoordinator(value: ProtocolValue | undefined, name: string): number { + const coordinators = arrayField(value, 'WELCOME.coordinators'); + for (const raw of coordinators) { + const entry = arrayField(raw, 'WELCOME.coordinator'); + if (entry[0] === name) return numberField(entry[1], 'WELCOME.generation'); + } + throw protocolFailure('WELCOME does not contain the authenticated coordinator'); +} + +function parseWelcome( + frame: Frame, + name: string, + connectedAtMs: number, + receivedAtMs: number, +): RelayWelcome { + if (frame.opcode !== Opcode.Welcome) throw protocolFailure('Expected WELCOME'); + const limits = arrayField(frame.payload[6], 'WELCOME.limits'); + const expiresAtMs = numberField(frame.payload[7], 'WELCOME.expiresAtMs'); + if (expiresAtMs <= receivedAtMs) throw protocolFailure('WELCOME expiry is not in the future'); + return Object.freeze({ + readySession: Object.freeze({ + sessionId: numberField(frame.payload[2], 'WELCOME.sessionId'), + generation: generationForCoordinator(frame.payload[5], name), + connectedAtMs, + }), + epoch: bytesField(frame.payload[3], 'WELCOME.epoch'), + expiresAtMs, + limits: Object.freeze({ + frameBytes: numberField(limits[0], 'WELCOME.maxFrameBytes'), + inflightCalls: numberField(limits[1], 'WELCOME.maxInflightCalls'), + subscriptions: numberField(limits[2], 'WELCOME.maxSubscriptions'), + queuedBytes: numberField(limits[3], 'WELCOME.maxQueuedBytes'), + }), + }); +} + +export class RelaySession { + readonly #name: string; + readonly #callbacks: RelaySessionCallbacks; + readonly #now: () => number; + readonly #protocol = new CoordinatorProtocolSession(); + readonly #welcome = createDeferred(); + #socket: ManagedSocket | undefined; + #closed = false; + #connectedAtMs = 0; + #welcomeValue: RelayWelcome | undefined; + #deliverFrames = false; + #queuedFrameBytes = 0; + readonly #queuedFrames: Frame[] = []; + + private constructor( + name: string, + callbacks: RelaySessionCallbacks, + now: () => number, + ) { + this.#name = name; + this.#callbacks = callbacks; + this.#now = now; + } + + static async connect( + runtime: CoordinatorRuntime, + name: string, + relayUrl: string, + token: string, + signal: AbortSignal, + callbacks: RelaySessionCallbacks, + ): Promise { + const session = new RelaySession(name, callbacks, () => runtime.now()); + const handlers: SocketHandlers = { + message: (bytes) => session.#receive(bytes), + close: (code, reason) => session.#didClose(code, reason), + error: (error) => session.#didFail(error), + }; + try { + session.#socket = await runtime.socketFactory.connect(relayUrl, handlers, signal); + session.#connectedAtMs = runtime.now(); + await session.#socket.write(session.#protocol.encode({ + opcode: Opcode.Hello, + payload: [1, 0, 0, 2, token, [name]], + })); + await session.#welcome.promise; + return session; + } catch (error) { + session.terminate(); + session.detach(); + throw error; + } + } + + get welcome(): RelayWelcome { + if (this.#welcomeValue === undefined) throw new Error('Relay session is not authenticated'); + return this.#welcomeValue; + } + + get bufferedBytes(): number { + return this.#socket?.bufferedBytes ?? 0; + } + + async send(frame: Frame): Promise { + if (this.#closed || this.#socket === undefined) throw new Error('Relay session is closed'); + if (this.#socket.bufferedBytes > this.welcome.limits.queuedBytes) { + throw new RangeError('Relay session outbound queue limit exceeded'); + } + const bytes = this.#protocol.encode(frame); + if (bytes.byteLength > this.welcome.limits.frameBytes + || this.#socket.bufferedBytes + bytes.byteLength > this.welcome.limits.queuedBytes) { + throw new RangeError('Relay session outbound queue limit exceeded'); + } + await this.#socket.write(bytes); + } + + startDelivery(): void { + if (this.#deliverFrames) return; + this.#deliverFrames = true; + this.#queuedFrameBytes = 0; + for (const frame of this.#queuedFrames.splice(0)) { + if (this.#closed) break; + this.#callbacks.frame(frame); + } + } + + close(): void { + if (this.#closed) return; + this.#closed = true; + this.#protocol.close(); + this.#socket?.close(); + } + + terminate(): void { + if (this.#closed) return; + this.#closed = true; + this.#protocol.close(); + this.#socket?.terminate(); + } + + detach(): void { + this.#socket?.detach(); + } + + #receive(bytes: Uint8Array): void { + if (this.#closed) return; + try { + const frame = this.#protocol.decode(bytes); + if (frame.opcode === Opcode.Welcome) { + if (this.#welcomeValue !== undefined) throw protocolFailure('Relay sent WELCOME twice'); + this.#welcomeValue = parseWelcome( + frame, + this.#name, + this.#connectedAtMs, + this.#now(), + ); + this.#welcome.resolve(this.#welcomeValue); + } else if (!this.#welcome.settled && frame.opcode === Opcode.Fatal) { + this.#callbacks.frame(frame); + if (!this.#welcome.settled) { + this.#welcome.reject(protocolFailure('Relay sent FATAL before WELCOME')); + } + this.terminate(); + } else { + if (this.#deliverFrames) this.#callbacks.frame(frame); + else { + this.#queuedFrameBytes += bytes.byteLength; + if (this.#queuedFrames.length >= 256 + || this.#queuedFrameBytes > this.welcome.limits.queuedBytes) { + throw protocolFailure('Relay sent too many frames before session activation'); + } + this.#queuedFrames.push(frame); + } + } + } catch (error) { + const failure = error instanceof CoordinatorError ? error : protocolFailure(); + this.#welcome.reject(failure); + this.#callbacks.failed(failure); + this.terminate(); + } + } + + #didClose(code: number, reason: string): void { + const wasClosed = this.#closed; + this.#closed = true; + this.#protocol.close(); + if (!this.#welcome.settled) { + this.#welcome.reject(new Error('Relay closed before WELCOME')); + } + if (!wasClosed) this.#callbacks.closed(code, reason); + } + + #didFail(error: Error): void { + if (this.#closed) return; + this.#welcome.reject(error); + this.#callbacks.failed(error); + this.terminate(); + } +} diff --git a/src/internal/socket.ts b/src/internal/socket.ts new file mode 100644 index 0000000..467bc5e --- /dev/null +++ b/src/internal/socket.ts @@ -0,0 +1,190 @@ +import WebSocket, { type ClientOptions, type RawData } from 'ws'; +import { LIMITS } from '../protocol/codec.js'; +import type { + CoordinatorRuntime, + ManagedSocket, + RuntimeTimer, + SocketFactory, + SocketHandlers, +} from './runtime.js'; + +const MAX_QUEUED_BYTES = 1_048_576; +const HANDSHAKE_TIMEOUT_MS = 10_000; + +interface BoundedClientOptions extends ClientOptions { + maxBufferedChunks: number; + maxFragments: number; +} + +function messageBytes(data: RawData): Uint8Array { + if (data instanceof ArrayBuffer) return new Uint8Array(data); + if (Array.isArray(data)) return new Uint8Array(Buffer.concat(data)); + return new Uint8Array(data.buffer, data.byteOffset, data.byteLength); +} + +function websocketError(value: unknown): Error { + if (value instanceof Error) return value; + if (value !== null + && typeof value === 'object' + && 'error' in value + && value.error instanceof Error) { + return value.error; + } + return new Error('WebSocket transport error'); +} + +class WsManagedSocket implements ManagedSocket { + readonly #socket: WebSocket; + readonly #handlers: SocketHandlers; + readonly #signal: AbortSignal; + readonly #ready: Promise; + #resolveReady: (() => void) | undefined; + #rejectReady: ((reason: unknown) => void) | undefined; + #readySettled = false; + #detached = false; + + constructor(socket: WebSocket, handlers: SocketHandlers, signal: AbortSignal) { + this.#socket = socket; + this.#handlers = handlers; + this.#signal = signal; + this.#ready = new Promise((resolve, reject) => { + this.#resolveReady = resolve; + this.#rejectReady = reject; + }); + socket.on('open', this.#onOpen); + socket.on('message', this.#onMessage); + socket.on('close', this.#onClose); + socket.on('error', this.#onError); + signal.addEventListener('abort', this.#onAbort, { once: true }); + } + + readonly #onOpen = (): void => { + if (this.#readySettled) return; + this.#readySettled = true; + this.#resolveReady?.(); + }; + + readonly #onMessage = (data: RawData, isBinary: boolean): void => { + if (this.#detached) return; + if (!isBinary) { + this.#handlers.error(new Error('Relay sent a non-binary WebSocket message')); + this.terminate(); + return; + } + this.#handlers.message(messageBytes(data)); + }; + + readonly #onClose = (code: number, reason: Buffer): void => { + if (!this.#readySettled) { + this.#readySettled = true; + this.#rejectReady?.(new Error('WebSocket closed before authentication')); + } + if (!this.#detached) this.#handlers.close(code, reason.toString('utf8')); + }; + + readonly #onError = (value: unknown): void => { + const error = websocketError(value); + if (!this.#readySettled) { + this.#readySettled = true; + this.#rejectReady?.(error); + } + if (!this.#detached) this.#handlers.error(error); + }; + + readonly #onAbort = (): void => { + if (!this.#readySettled) { + this.#readySettled = true; + this.#rejectReady?.(this.#signal.reason); + } + this.terminate(); + }; + + ready(): Promise { + return this.#ready; + } + + get bufferedBytes(): number { + return this.#socket.bufferedAmount; + } + + write(bytes: Uint8Array): Promise { + if (this.#socket.readyState !== WebSocket.OPEN) { + return Promise.reject(new Error('WebSocket is not open')); + } + if (bytes.byteLength > LIMITS.frameBytes + || this.#socket.bufferedAmount + bytes.byteLength > MAX_QUEUED_BYTES) { + return Promise.reject(new RangeError('WebSocket outbound queue limit exceeded')); + } + try { + this.#socket.send(bytes, { binary: true, compress: false }, (error) => { + if (error instanceof Error && !this.#detached) this.#handlers.error(error); + }); + return Promise.resolve(); + } catch (error) { + return Promise.reject(error); + } + } + + close(code = 1000, reason = 'shutdown'): void { + if (this.#socket.readyState === WebSocket.OPEN) this.#socket.close(code, reason); + else if (this.#socket.readyState === WebSocket.CONNECTING) this.#socket.terminate(); + } + + terminate(): void { + if (this.#socket.readyState !== WebSocket.CLOSED) this.#socket.terminate(); + } + + detach(): void { + if (this.#detached) return; + this.#detached = true; + this.#signal.removeEventListener('abort', this.#onAbort); + this.#socket.off('open', this.#onOpen); + this.#socket.off('message', this.#onMessage); + this.#socket.off('close', this.#onClose); + } +} + +export class WsSocketFactory implements SocketFactory { + async connect( + url: string, + handlers: SocketHandlers, + signal: AbortSignal, + ): Promise { + if (signal.aborted) throw signal.reason; + const options: BoundedClientOptions = { + followRedirects: false, + handshakeTimeout: HANDSHAKE_TIMEOUT_MS, + maxBufferedChunks: 4_096, + maxFragments: 1_024, + maxPayload: LIMITS.frameBytes, + perMessageDeflate: false, + }; + const managed = new WsManagedSocket(new WebSocket(url, options), handlers, signal); + await managed.ready(); + return managed; + } +} + +class NodeRuntimeTimer implements RuntimeTimer { + readonly #timer: NodeJS.Timeout; + + constructor(callback: () => void, delayMs: number) { + this.#timer = setTimeout(callback, delayMs); + } + + cancel(): void { + clearTimeout(this.#timer); + } +} + +export function createProductionRuntime(): CoordinatorRuntime { + const socketFactory = new WsSocketFactory(); + return Object.freeze({ + socketFactory, + now: () => Date.now(), + random: () => Math.random(), + setTimer: (callback: () => void, delayMs: number) => ( + new NodeRuntimeTimer(callback, delayMs) + ), + }); +} diff --git a/src/internal/state.ts b/src/internal/state.ts new file mode 100644 index 0000000..31b1423 --- /dev/null +++ b/src/internal/state.ts @@ -0,0 +1,166 @@ +import type { + CoordinatorState, + DeclarationOptions, + OperationOptions, + ProtocolValue, + StateMutation, + StateReceipt, +} from '../api.js'; +import { LIMITS, Opcode, type Frame } from '../protocol/codec.js'; +import { cancelled, relayFailure, unavailable, outcomeUnknown } from './errors.js'; +import type { CoordinatorError } from './errors.js'; +import type { ActiveDeclarations, DeclarationManager } from './declarations.js'; +import { createDeferred, type Deferred } from './resources.js'; +import type { RelaySession } from './session.js'; +import { + validateDeclarationOptions, + validateOperationOptions, + validateStateEntries, + validateStateMutations, +} from './validation.js'; + +export interface StateHost { + readySession(): RelaySession | undefined; + activeDeclarations(): ActiveDeclarations | undefined; + nextRequestId(): number; +} + +interface PendingStateSet { + deferred: Deferred; + epoch: Uint8Array; + handedOff: boolean; + abandoned: boolean; + signal?: AbortSignal; + abort?: () => void; +} + +function requestId(frame: Frame): number | undefined { + const value = frame.payload[0]; + return typeof value === 'number' && Number.isSafeInteger(value) ? value : undefined; +} + +export class StateManager implements CoordinatorState { + readonly #host: StateHost; + readonly #declarations: DeclarationManager; + readonly #pending = new Map(); + + constructor(host: StateHost, declarations: DeclarationManager) { + this.#host = host; + this.#declarations = declarations; + } + + declare( + entries: Readonly>, + options: DeclarationOptions = {}, + ): Promise { + const signal = validateDeclarationOptions(options, 'state declaration'); + return this.#declarations.declareState(validateStateEntries(entries), signal); + } + + set(mutations: readonly StateMutation[], options: OperationOptions = {}): Promise { + const validated = validateStateMutations(mutations); + const signal = validateOperationOptions(options, 'state operation'); + if (signal?.aborted === true) return Promise.reject(cancelled('not_dispatched')); + const session = this.#host.readySession(); + const active = this.#host.activeDeclarations(); + if (session === undefined || active === undefined) return Promise.reject(unavailable()); + if (this.#pending.size >= LIMITS.declarationsPerCoordinator) { + return Promise.reject(unavailable('State mutation concurrency limit is reached')); + } + + const wireMutations = validated.map((mutation) => { + const pathId = active.stateIds.get(mutation.path); + if (pathId === undefined) throw new TypeError(`State path ${mutation.path} is not active`); + return 'delete' in mutation + ? [pathId, 1] + : [pathId, 0, mutation.value]; + }); + const id = this.#host.nextRequestId(); + const deferred = createDeferred(); + const pending: PendingStateSet = { + deferred, + epoch: session.welcome.epoch.slice(), + handedOff: false, + abandoned: false, + }; + if (signal !== undefined) { + const abort = () => { + if (!this.#pending.has(id)) return; + if (pending.handedOff) { + pending.abandoned = true; + this.#removeAbort(pending); + deferred.reject(outcomeUnknown()); + } else { + this.#pending.delete(id); + deferred.reject(cancelled('not_dispatched')); + } + }; + pending.signal = signal; + pending.abort = abort; + signal.addEventListener('abort', abort, { once: true }); + } + this.#pending.set(id, pending); + pending.handedOff = true; + void session.send({ + opcode: Opcode.StateSet, + payload: [id, session.welcome.epoch, wireMutations], + }).then(() => undefined, () => { + if (this.#pending.get(id) !== pending) return; + this.#pending.delete(id); + this.#removeAbort(pending); + deferred.reject(unavailable('State mutation was not handed to the transport')); + }); + return deferred.promise; + } + + handleFrame(frame: Frame): boolean { + if (frame.opcode !== Opcode.StateSetOk) return false; + const id = requestId(frame); + if (id === undefined) throw new TypeError('STATE_SET_OK has no request ID'); + const pending = this.#pending.get(id); + if (pending === undefined) throw new TypeError('STATE_SET_OK is not correlated'); + const epoch = frame.payload[1]; + if (!(epoch instanceof Uint8Array) + || epoch.length !== pending.epoch.length + || epoch.some((value, index) => value !== pending.epoch[index])) { + throw new TypeError('STATE_SET_OK uses a stale epoch'); + } + this.#pending.delete(id); + this.#removeAbort(pending); + if (!pending.abandoned) pending.deferred.resolve(Object.freeze({ outcome: 'applied' })); + return true; + } + + handleError(id: number, code: number, retryable: boolean): boolean { + const pending = this.#pending.get(id); + if (pending === undefined) return false; + this.#pending.delete(id); + this.#removeAbort(pending); + if (!pending.abandoned) { + pending.deferred.reject(relayFailure(code, retryable, 'not_dispatched')); + } + return true; + } + + disconnected(): void { + for (const pending of this.#pending.values()) { + this.#removeAbort(pending); + pending.deferred.reject(pending.handedOff ? outcomeUnknown() : unavailable()); + } + this.#pending.clear(); + } + + stop(failure: CoordinatorError): void { + for (const pending of this.#pending.values()) { + this.#removeAbort(pending); + pending.deferred.reject(pending.handedOff ? outcomeUnknown() : failure); + } + this.#pending.clear(); + } + + #removeAbort(pending: PendingStateSet): void { + if (pending.signal !== undefined && pending.abort !== undefined) { + pending.signal.removeEventListener('abort', pending.abort); + } + } +} diff --git a/src/internal/validation.ts b/src/internal/validation.ts new file mode 100644 index 0000000..dfdd743 --- /dev/null +++ b/src/internal/validation.ts @@ -0,0 +1,573 @@ +import type { + AccessTokenProvider, + AccessToken, + CallTarget, + CoordinatorConfiguration, + CoordinatorOptions, + CoordinatorLogger, + EventDeclaration, + EventTarget, + FunctionHandler, + ProtocolObject, + ProtocolValue, + StartCallOptions, + StateMutation, + UserEventAccess, + UserStateAccess, +} from '../api.js'; +import { LIMITS } from '../protocol/codec.js'; + +const UTF8 = new TextEncoder(); +const CONTROL_CHARACTER = /\p{Cc}/u; +const FORBIDDEN_KEYS = new Set(['__proto__', 'prototype', 'constructor']); +const COORDINATOR_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/; + +interface ValueBudget { + values: number; + bytes: number; +} + +function accountValueBytes(budget: ValueBudget, bytes: number, label: string): void { + budget.bytes += bytes; + if (budget.bytes > LIMITS.frameBytes) { + throw new RangeError(`${label} exceeds the aggregate value byte limit`); + } +} + +function accountDeclarationValue( + budget: ValueBudget, + valueBytes: number, + label: string, +): void { + budget.values += 1; + if (budget.values > LIMITS.values) { + throw new RangeError(`${label} exceeds the aggregate value count limit`); + } + accountValueBytes(budget, valueBytes + 1, label); +} + +function isPlainRecord(value: unknown): value is Record { + if (value === null || typeof value !== 'object' || Array.isArray(value)) return false; + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +} + +function exactObject( + value: unknown, + required: readonly string[], + optional: readonly string[], + label: string, +): Record { + if (!isPlainRecord(value)) throw new TypeError(`${label} must be an object`); + const allowed = new Set([...required, ...optional]); + const keys = Object.keys(value); + if (Reflect.ownKeys(value).length !== keys.length + || keys.some((key) => !allowed.has(key)) + || required.some((key) => !Object.hasOwn(value, key))) { + throw new TypeError(`${label} has an invalid shape`); + } + return value; +} + +function hasUnpairedSurrogate(value: string): boolean { + for (let index = 0; index < value.length; index += 1) { + const unit = value.charCodeAt(index); + if (unit >= 0xd800 && unit <= 0xdbff) { + const following = value.charCodeAt(index + 1); + if (index + 1 >= value.length || following < 0xdc00 || following > 0xdfff) return true; + index += 1; + } else if (unit >= 0xdc00 && unit <= 0xdfff) { + return true; + } + } + return false; +} + +function boundedString( + value: unknown, + minimumBytes: number, + maximumBytes: number, + label: string, + allowControlCharacters = false, +): string { + if (typeof value !== 'string' + || hasUnpairedSurrogate(value) + || (!allowControlCharacters && CONTROL_CHARACTER.test(value))) { + throw new TypeError(`${label} must be a safe UTF-8 string`); + } + const bytes = UTF8.encode(value).byteLength; + if (bytes < minimumBytes || bytes > maximumBytes) { + throw new RangeError(`${label} must contain ${minimumBytes} to ${maximumBytes} UTF-8 bytes`); + } + return value; +} + +export function validateCoordinatorName(value: unknown, label = 'coordinator name'): string { + const name = boundedString(value, 1, 64, label); + if (!COORDINATOR_NAME.test(name)) throw new TypeError(`${label} is invalid`); + return name; +} + +export function validateStructuredName(value: unknown, label: string): string { + const name = boundedString(value, 1, 256, label); + if (name.includes('*') || name.startsWith('.') || name.endsWith('.') || name.includes('..')) { + throw new TypeError(`${label} must be a dotted name without empty segments`); + } + return name; +} + +export function validatePattern(value: unknown, label: string): string { + const pattern = boundedString(value, 1, 256, label); + validateStructuredName(pattern.endsWith('.*') ? pattern.slice(0, -2) : pattern, label); + return pattern; +} + +function cloneProtocolValue( + value: unknown, + label: string, + depth: number, + budget: ValueBudget, + ancestors: Set, +): ProtocolValue { + if (depth > LIMITS.depth) throw new RangeError(`${label} exceeds the value depth limit`); + budget.values += 1; + if (budget.values > LIMITS.values) throw new RangeError(`${label} exceeds the value count limit`); + accountValueBytes(budget, 1, label); + if (value === null || typeof value === 'boolean') return value; + if (typeof value === 'number') { + if (!Number.isFinite(value) + || Object.is(value, -0) + || (Number.isInteger(value) && !Number.isSafeInteger(value))) { + throw new TypeError(`${label} contains an invalid number`); + } + accountValueBytes(budget, 8, label); + return value; + } + if (typeof value === 'string') { + const result = boundedString(value, 0, LIMITS.stringBytes, label, true); + accountValueBytes(budget, UTF8.encode(result).byteLength, label); + return result; + } + if (value instanceof Uint8Array) { + if (value.byteLength > LIMITS.binaryBytes) { + throw new RangeError(`${label} exceeds the binary value limit`); + } + accountValueBytes(budget, value.byteLength, label); + return value.slice(); + } + if (typeof value !== 'object') throw new TypeError(`${label} is not a protocol value`); + if (ancestors.has(value)) throw new TypeError(`${label} contains a cycle`); + ancestors.add(value); + try { + if (Array.isArray(value)) { + if (value.length > LIMITS.arrayItems) throw new RangeError(`${label} exceeds the array limit`); + accountValueBytes(budget, 4, label); + const output: ProtocolValue[] = []; + for (let index = 0; index < value.length; index += 1) { + if (!Object.hasOwn(value, index)) throw new TypeError(`${label} contains a sparse array`); + output.push(cloneProtocolValue( + value[index], + `${label}[${index}]`, + depth + 1, + budget, + ancestors, + )); + } + Object.freeze(output); + return output; + } + if (!isPlainRecord(value)) throw new TypeError(`${label} contains a non-plain object`); + const keys = Object.keys(value); + if (Reflect.ownKeys(value).length !== keys.length) { + throw new TypeError(`${label} contains symbolic or non-enumerable keys`); + } + if (keys.length > LIMITS.mapEntries) throw new RangeError(`${label} exceeds the map limit`); + accountValueBytes(budget, 4, label); + const output: ProtocolObject = {}; + for (const key of keys) { + boundedString(key, 0, LIMITS.mapKeyBytes, `${label} key`, true); + if (FORBIDDEN_KEYS.has(key)) throw new TypeError(`${label} contains a reserved key`); + accountValueBytes(budget, UTF8.encode(key).byteLength + 1, label); + output[key] = cloneProtocolValue(value[key], `${label}.${key}`, depth + 1, budget, ancestors); + } + Object.freeze(output); + return output; + } finally { + ancestors.delete(value); + } +} + +export function validateProtocolValue(value: unknown, label = 'value'): ProtocolValue { + return cloneProtocolValue(value, label, 1, { values: 0, bytes: 0 }, new Set()); +} + +function uniqueStrings( + values: unknown, + validator: (value: unknown, label: string) => string, + label: string, + budget?: ValueBudget, +): readonly string[] { + if (!Array.isArray(values)) throw new TypeError(`${label} must be an array`); + if (values.length > LIMITS.declarationsPerCoordinator) { + throw new RangeError(`${label} exceeds the declaration limit`); + } + const output: string[] = []; + const seen = new Set(); + for (let index = 0; index < values.length; index += 1) { + const entry = validator(values[index], `${label}[${index}]`); + if (seen.has(entry)) throw new TypeError(`${label} contains a duplicate`); + if (budget !== undefined) { + accountDeclarationValue(budget, UTF8.encode(entry).byteLength, label); + } + seen.add(entry); + output.push(entry); + } + return Object.freeze(output); +} + +function validateOpaqueId(value: unknown, label: string): string { + return boundedString(value, 1, 128, label); +} + +export function validateStateEntries(value: unknown): Readonly> { + if (!isPlainRecord(value)) throw new TypeError('state declaration must be an object'); + const keys = Object.keys(value); + if (Reflect.ownKeys(value).length !== keys.length) { + throw new TypeError('state declaration contains invalid keys'); + } + if (keys.length > LIMITS.statePathsPerCoordinator) { + throw new RangeError('state declaration exceeds the path limit'); + } + const output: Record = Object.create(null); + const budget: ValueBudget = { values: 0, bytes: 0 }; + for (const path of keys) { + validateStructuredName(path, `state path ${path}`); + accountValueBytes(budget, UTF8.encode(path).byteLength + 5, 'state declaration'); + output[path] = cloneProtocolValue(value[path], `state.${path}`, 1, budget, new Set()); + } + return Object.freeze(output); +} + +export function validateStateMutations(value: unknown): readonly StateMutation[] { + if (!Array.isArray(value) || value.length === 0 || value.length > LIMITS.statePathsPerCoordinator) { + throw new TypeError('state mutations must be a non-empty bounded array'); + } + const paths = new Set(); + const output: StateMutation[] = []; + const budget: ValueBudget = { values: 0, bytes: 0 }; + for (let index = 0; index < value.length; index += 1) { + const entry = exactObject(value[index], ['path'], ['value', 'delete'], `mutation[${index}]`); + const path = validateStructuredName(entry.path, `mutation[${index}].path`); + if (paths.has(path)) throw new TypeError('state mutations contain a duplicate path'); + paths.add(path); + accountValueBytes(budget, UTF8.encode(path).byteLength + 6, 'state mutations'); + if (Object.hasOwn(entry, 'value') === Object.hasOwn(entry, 'delete')) { + throw new TypeError(`mutation[${index}] must set or delete exactly once`); + } + if (Object.hasOwn(entry, 'value')) { + output.push(Object.freeze({ + path, + value: cloneProtocolValue( + entry.value, + `mutation[${index}].value`, + 1, + budget, + new Set(), + ), + })); + } else { + if (entry.delete !== true) throw new TypeError(`mutation[${index}].delete must be true`); + output.push(Object.freeze({ path, delete: true })); + } + } + return Object.freeze(output); +} + +export function validateStateAccess(value: unknown): readonly UserStateAccess[] { + if (!Array.isArray(value) || value.length > LIMITS.declarationsPerCoordinator) { + throw new TypeError('state access declarations must be a bounded array'); + } + const users = new Set(); + const budget: ValueBudget = { values: 1, bytes: 5 }; + return Object.freeze(value.map((raw, index) => { + const entry = exactObject(raw, ['userId', 'patterns'], [], `stateAccess[${index}]`); + const userId = validateOpaqueId(entry.userId, `stateAccess[${index}].userId`); + if (users.has(userId)) throw new TypeError('state access contains a duplicate user'); + users.add(userId); + accountDeclarationValue(budget, UTF8.encode(userId).byteLength + 5, 'state access'); + return Object.freeze({ + userId, + patterns: uniqueStrings( + entry.patterns, + validatePattern, + `stateAccess[${index}].patterns`, + budget, + ), + }); + })); +} + +export function validateEventAccess(value: unknown): readonly UserEventAccess[] { + if (!Array.isArray(value) || value.length > LIMITS.declarationsPerCoordinator) { + throw new TypeError('event access declarations must be a bounded array'); + } + const users = new Set(); + const budget: ValueBudget = { values: 1, bytes: 5 }; + return Object.freeze(value.map((raw, index) => { + const entry = exactObject(raw, ['userId', 'publish', 'subscribe'], [], `eventAccess[${index}]`); + const userId = validateOpaqueId(entry.userId, `eventAccess[${index}].userId`); + if (users.has(userId)) throw new TypeError('event access contains a duplicate user'); + users.add(userId); + accountDeclarationValue(budget, UTF8.encode(userId).byteLength + 9, 'event access'); + return Object.freeze({ + userId, + publish: uniqueStrings( + entry.publish, + validatePattern, + `eventAccess[${index}].publish`, + budget, + ), + subscribe: uniqueStrings( + entry.subscribe, + validatePattern, + `eventAccess[${index}].subscribe`, + budget, + ), + }); + })); +} + +export function validateEventDeclarations(value: unknown): readonly EventDeclaration[] { + if (!Array.isArray(value) || value.length > LIMITS.declarationsPerCoordinator) { + throw new TypeError('event declarations must be a bounded array'); + } + const topics = new Set(); + const budget: ValueBudget = { values: 1, bytes: 5 }; + return Object.freeze(value.map((raw, index) => { + const entry = exactObject(raw, ['topic', 'directions'], [], `events[${index}]`); + const topic = validateStructuredName(entry.topic, `events[${index}].topic`); + if (topics.has(topic)) throw new TypeError('event declarations contain a duplicate topic'); + topics.add(topic); + accountDeclarationValue(budget, UTF8.encode(topic).byteLength + 10, 'event declarations'); + if (!Number.isInteger(entry.directions) + || typeof entry.directions !== 'number' + || entry.directions < 1 + || entry.directions > 0x0f) { + throw new TypeError(`events[${index}].directions is invalid`); + } + return Object.freeze({ topic, directions: entry.directions }); + })); +} + +export function validateFunctions(value: unknown): Readonly> { + if (!isPlainRecord(value)) throw new TypeError('function declaration must be an object'); + const names = Object.keys(value); + if (Reflect.ownKeys(value).length !== names.length + || names.length > LIMITS.declarationsPerCoordinator) { + throw new TypeError('function declaration has an invalid shape'); + } + const output: Record = Object.create(null); + const budget: ValueBudget = { values: 1, bytes: 5 }; + for (const name of names) { + validateStructuredName(name, `function ${name}`); + if (name === 'miakapp.join') throw new TypeError('miakapp.join is reserved'); + const handler = value[name]; + if (!isFunctionHandler(handler)) throw new TypeError(`function ${name} has no handler`); + accountDeclarationValue(budget, UTF8.encode(name).byteLength + 5, 'function declaration'); + output[name] = handler; + } + return Object.freeze(output); +} + +function isFunctionHandler(value: unknown): value is FunctionHandler { + return typeof value === 'function'; +} + +function isAccessTokenProvider(value: unknown): value is AccessTokenProvider { + return value !== null + && typeof value === 'object' + && 'getAccessToken' in value + && typeof value.getAccessToken === 'function'; +} + +function isCoordinatorLogger(value: unknown): value is CoordinatorLogger { + return value !== null + && typeof value === 'object' + && 'write' in value + && typeof value.write === 'function'; +} + +export function validateConfiguration(value: unknown): CoordinatorConfiguration { + const configuration = exactObject( + value, + ['state', 'stateAccess', 'events', 'eventAccess', 'functions'], + [], + 'configuration', + ); + return Object.freeze({ + state: validateStateEntries(configuration.state), + stateAccess: validateStateAccess(configuration.stateAccess), + events: validateEventDeclarations(configuration.events), + eventAccess: validateEventAccess(configuration.eventAccess), + functions: validateFunctions(configuration.functions), + }); +} + +export function validateCoordinatorOptions(value: unknown): CoordinatorOptions { + const options = exactObject(value, ['name', 'accessTokenProvider'], ['logger'], 'options'); + const provider = options.accessTokenProvider; + if (!isAccessTokenProvider(provider)) { + throw new TypeError('options.accessTokenProvider must implement getAccessToken'); + } + const logger = options.logger; + if (logger !== undefined && !isCoordinatorLogger(logger)) { + throw new TypeError('options.logger must implement write'); + } + const name = validateCoordinatorName(options.name); + return logger === undefined + ? Object.freeze({ name, accessTokenProvider: provider }) + : Object.freeze({ name, accessTokenProvider: provider, logger }); +} + +export function validateAccessToken(value: unknown, now: number): AccessToken { + const token = exactObject(value, ['relayUrl', 'token', 'expiresAtMs'], [], 'access token'); + const relayUrl = boundedString(token.relayUrl, 1, 2_048, 'access token relayUrl'); + let url: URL; + try { + url = new URL(relayUrl); + } catch { + throw new TypeError('access token relayUrl is invalid'); + } + if (url.protocol !== 'wss:' + || !url.hostname + || url.username + || url.password + || url.hash + || url.search + || !url.pathname.endsWith('/ws')) { + throw new TypeError('access token relayUrl must be a secure WebSocket URL ending in /ws'); + } + const expiresAtMs = token.expiresAtMs; + if (!Number.isSafeInteger(expiresAtMs) + || typeof expiresAtMs !== 'number' + || expiresAtMs <= now) { + throw new RangeError('access token expiry must be a future safe integer'); + } + return Object.freeze({ + relayUrl: url.href, + token: boundedString(token.token, 1, 16_384, 'access token token', true), + expiresAtMs, + }); +} + +function validateTarget(value: unknown, label: string): EventTarget | CallTarget { + const entry = exactObject(value, ['kind'], ['id'], label); + if (entry.kind === 'default') { + if (Object.hasOwn(entry, 'id')) throw new TypeError(`${label} default target has no id`); + return Object.freeze({ kind: 'default' }); + } + if (entry.kind === 'user_session') { + if (!Number.isSafeInteger(entry.id) || typeof entry.id !== 'number' || entry.id < 1) { + throw new TypeError(`${label}.id must be a positive session identifier`); + } + return Object.freeze({ kind: 'user_session', id: entry.id }); + } + if (entry.kind === 'coordinator') { + return Object.freeze({ kind: 'coordinator', id: validateCoordinatorName(entry.id, `${label}.id`) }); + } + throw new TypeError(`${label}.kind is invalid`); +} + +export function validateEventTarget(value: unknown): EventTarget { + return validateTarget(value, 'event target'); +} + +export function validateStartCallOptions(value: unknown): StartCallOptions { + const options = exactObject( + value, + ['function', 'arguments', 'timeoutMs'], + ['target', 'idempotencyKey', 'signal'], + 'call options', + ); + if (!Number.isInteger(options.timeoutMs) + || typeof options.timeoutMs !== 'number' + || options.timeoutMs < 1 + || options.timeoutMs > LIMITS.callTimeoutMs) { + throw new RangeError('call options timeoutMs is out of range'); + } + if (options.signal !== undefined && !(options.signal instanceof AbortSignal)) { + throw new TypeError('call options signal must be an AbortSignal'); + } + const base = { + function: validateStructuredName(options.function, 'call options function'), + arguments: validateProtocolValue(options.arguments, 'call options arguments'), + timeoutMs: options.timeoutMs, + }; + const target = options.target === undefined ? undefined : validateTarget(options.target, 'call target'); + const idempotencyKey = options.idempotencyKey === undefined + ? undefined + : boundedString(options.idempotencyKey, 1, 128, 'call options idempotencyKey', true); + if (target === undefined && idempotencyKey === undefined && options.signal === undefined) { + return Object.freeze(base); + } + return Object.freeze({ + ...base, + ...(target === undefined ? {} : { target }), + ...(idempotencyKey === undefined ? {} : { idempotencyKey }), + ...(options.signal === undefined ? {} : { signal: options.signal }), + }); +} + +export function validateSignal(value: unknown, label: string): AbortSignal | undefined { + if (value === undefined) return undefined; + if (!(value instanceof AbortSignal)) throw new TypeError(`${label} must be an AbortSignal`); + return value; +} + +export function validateStartOptions(value: unknown): AbortSignal | undefined { + const options = exactObject(value, [], ['signal'], 'start options'); + return validateSignal(options.signal, 'start signal'); +} + +export function validateStopOptions(value: unknown): number { + const options = exactObject(value, [], ['deadlineMs'], 'stop options'); + const deadlineMs = options.deadlineMs ?? 5_000; + if (!Number.isSafeInteger(deadlineMs) + || typeof deadlineMs !== 'number' + || deadlineMs < 0 + || deadlineMs > 300_000) { + throw new RangeError('stop deadlineMs must be between 0 and 300000'); + } + return deadlineMs; +} + +export function validateDeclarationOptions(value: unknown, label: string): AbortSignal | undefined { + const options = exactObject(value, [], ['signal'], `${label} options`); + return validateSignal(options.signal, `${label} signal`); +} + +export function validateOperationOptions(value: unknown, label: string): AbortSignal | undefined { + const options = exactObject(value, [], ['signal'], `${label} options`); + return validateSignal(options.signal, `${label} signal`); +} + +export function validateEventPublishOptions(value: unknown): { + signal?: AbortSignal; + target?: EventTarget; +} { + const options = exactObject(value, [], ['signal', 'target'], 'event options'); + const signal = validateSignal(options.signal, 'event signal'); + const target = options.target === undefined ? undefined : validateEventTarget(options.target); + if (signal === undefined) { + if (target === undefined) return Object.freeze({}); + return Object.freeze({ target }); + } + if (target === undefined) return Object.freeze({ signal }); + return Object.freeze({ signal, target }); +} + +export function targetFields(target: EventTarget | CallTarget | undefined): [number, ProtocolValue] { + if (target === undefined || target.kind === 'default') return [0, null]; + if (target.kind === 'user_session') return [1, target.id]; + return [2, target.id]; +} diff --git a/src/protocol/codec.ts b/src/protocol/codec.ts new file mode 100644 index 0000000..6c191a7 --- /dev/null +++ b/src/protocol/codec.ts @@ -0,0 +1,1114 @@ +import { decode } from '@msgpack/msgpack'; + +export const LIMITS = { + frameBytes: 262_144, + depth: 16, + values: 16_384, + stringBytes: 65_536, + binaryBytes: 131_072, + arrayItems: 4_096, + mapEntries: 4_096, + mapKeyBytes: 256, + statePathsPerCoordinator: 4_096, + statePathsPerHome: 16_384, + declarationsPerCoordinator: 1_024, + subscriptions: 256, + inflightCalls: 128, + streamCredit: 32, + callTimeoutMs: 300_000, +} as const; + +export const Opcode = { + Hello: 0x00, + Welcome: 0x01, + Error: 0x02, + Fatal: 0x03, + Reauth: 0x04, + ReauthOk: 0x05, + HomeStatus: 0x06, + Goaway: 0x07, + StateSync: 0x10, + StateSyncOk: 0x11, + StateDict: 0x12, + StateSnapshot: 0x13, + StatePatch: 0x14, + StateSet: 0x15, + StateSetOk: 0x16, + StateAclSync: 0x17, + StateAclOk: 0x18, + StateResync: 0x19, + EventSync: 0x20, + EventSyncOk: 0x21, + TopicDict: 0x22, + EventAclSync: 0x23, + EventAclOk: 0x24, + Subscribe: 0x25, + SubscribeOk: 0x26, + Unsubscribe: 0x27, + UnsubscribeOk: 0x28, + Event: 0x29, + FunctionSync: 0x30, + FunctionSyncOk: 0x31, + FunctionDict: 0x32, + Call: 0x33, + CallDispatch: 0x34, + CallAccepted: 0x35, + CallResult: 0x36, + CallError: 0x37, + CallCancel: 0x38, + CallCredit: 0x39, + PresenceSnapshot: 0x40, + PresenceChange: 0x41, +} as const; + +const KNOWN_CORE_OPCODES = new Set(Object.values(Opcode)); +const ERROR_CORRELATION_SOURCE_OPCODES = new Set([ + Opcode.Reauth, + Opcode.StateSync, + Opcode.StateSet, + Opcode.StateAclSync, + Opcode.StateResync, + Opcode.EventSync, + Opcode.EventAclSync, + Opcode.Subscribe, + Opcode.Unsubscribe, + Opcode.Event, + Opcode.FunctionSync, + Opcode.Call, + Opcode.CallResult, + Opcode.CallError, + Opcode.CallCancel, + Opcode.CallCredit, +]); +const KNOWN_CORE_ERROR_CODES = new Set([ + 1000, 1001, 1002, 1003, 1004, 1005, + 1100, 1101, 1102, + 1200, 1201, 1202, 1203, + 1300, 1301, 1302, 1303, 1304, 1305, + 1400, 1401, 1402, 1403, 1404, 1405, + 1500, +]); +const APPLICATION_ERROR_CODE_MINIMUM = 2000; +const APPLICATION_ERROR_CODE_MAXIMUM = 2999; +const MAX_SAFE_INTEGER = Number.MAX_SAFE_INTEGER; +const MIN_SAFE_INTEGER = Number.MIN_SAFE_INTEGER; +const textEncoder = new TextEncoder(); +const textDecoder = new TextDecoder('utf-8', { fatal: true }); + +export type ProtocolErrorKind = + | 'malformed' + | 'non_canonical' + | 'invalid_value' + | 'limit' + | 'unknown_opcode' + | 'invalid_frame' + | 'frame_too_large'; + +export class ProtocolError extends Error { + readonly kind: ProtocolErrorKind; + + constructor(kind: ProtocolErrorKind, message: string) { + super(message); + this.name = 'ProtocolError'; + this.kind = kind; + } +} + +export type ProtocolObject = { [key: string]: ProtocolValue }; +export type ProtocolValue = + | null + | boolean + | number + | string + | Uint8Array + | ProtocolValue[] + | ProtocolObject; + +export interface Frame { + opcode: number; + payload: ProtocolValue[]; +} + +interface ScanState { + nodes: number; +} + +interface ScanResult { + end: number; + kind: 'null' | 'boolean' | 'integer' | 'float' | 'string' | 'binary' | 'array' | 'map'; + stringBytes?: Uint8Array; + stringValue?: string; +} + +interface EncodeState { + nodes: number; + bytes: number; +} + +function fail(kind: ProtocolErrorKind, message: string): never { + throw new ProtocolError(kind, message); +} + +function accountEncodedBytes(state: EncodeState, length: number): void { + state.bytes += length; + if (state.bytes > LIMITS.frameBytes - 1) { + fail('frame_too_large', 'encoded frame exceeds byte limit'); + } +} + +function encodedAtom(state: EncodeState, value: Uint8Array): Uint8Array { + accountEncodedBytes(state, value.length); + return value; +} + +function ensureAvailable(input: Uint8Array, offset: number, length: number): void { + if (length < 0 || offset < 0 || offset + length > input.length) { + fail('malformed', 'truncated MessagePack value'); + } +} + +function view(input: Uint8Array, offset: number, length: number): DataView { + ensureAvailable(input, offset, length); + return new DataView(input.buffer, input.byteOffset + offset, length); +} + +function compareBytes(left: Uint8Array, right: Uint8Array): number { + const common = Math.min(left.length, right.length); + for (let index = 0; index < common; index += 1) { + const difference = left[index]! - right[index]!; + if (difference !== 0) return difference; + } + return left.length - right.length; +} + +function scanString( + input: Uint8Array, + start: number, + length: number, + next: number, +): ScanResult { + if (length > LIMITS.stringBytes) fail('limit', 'string length exceeds limit'); + ensureAvailable(input, start, length); + const bytes = input.subarray(start, start + length); + let value: string; + try { + value = textDecoder.decode(bytes); + } catch { + fail('invalid_value', 'string is not valid UTF-8'); + } + return { + end: next + length, + kind: 'string', + stringBytes: bytes, + stringValue: value, + }; +} + +function scanBinary(input: Uint8Array, start: number, length: number, next: number): ScanResult { + if (length > LIMITS.binaryBytes) fail('limit', 'binary length exceeds limit'); + ensureAvailable(input, start, length); + return { end: next + length, kind: 'binary' }; +} + +function scanArray( + input: Uint8Array, + offset: number, + length: number, + depth: number, + state: ScanState, +): ScanResult { + if (length > LIMITS.arrayItems) fail('limit', 'array length exceeds limit'); + let cursor = offset; + for (let index = 0; index < length; index += 1) { + cursor = scanValue(input, cursor, depth + 1, state).end; + } + return { end: cursor, kind: 'array' }; +} + +function scanMap( + input: Uint8Array, + offset: number, + length: number, + depth: number, + state: ScanState, +): ScanResult { + if (length > LIMITS.mapEntries) fail('limit', 'map length exceeds limit'); + let cursor = offset; + let previous: Uint8Array | undefined; + for (let index = 0; index < length; index += 1) { + const key = scanValue(input, cursor, depth + 1, state); + if (key.kind !== 'string' || !key.stringBytes || key.stringValue === undefined) { + fail('invalid_value', 'map keys must be strings'); + } + if (key.stringBytes.length > LIMITS.mapKeyBytes) fail('limit', 'map key exceeds limit'); + if (key.stringValue === '__proto__') fail('invalid_value', 'reserved map key'); + if (previous) { + const order = compareBytes(previous, key.stringBytes); + if (order === 0) fail('invalid_value', 'duplicate map key'); + if (order > 0) fail('non_canonical', 'map keys are not in UTF-8 byte order'); + } + previous = key.stringBytes; + cursor = scanValue(input, key.end, depth + 1, state).end; + } + return { end: cursor, kind: 'map' }; +} + +function scanValue( + input: Uint8Array, + offset: number, + depth: number, + state: ScanState, +): ScanResult { + if (depth > LIMITS.depth) fail('limit', 'value nesting exceeds limit'); + state.nodes += 1; + if (state.nodes > LIMITS.values) fail('limit', 'payload value count exceeds limit'); + ensureAvailable(input, offset, 1); + const marker = input[offset]!; + const next = offset + 1; + + if (marker <= 0x7f || marker >= 0xe0) return { end: next, kind: 'integer' }; + if (marker >= 0x80 && marker <= 0x8f) { + return scanMap(input, next, marker & 0x0f, depth, state); + } + if (marker >= 0x90 && marker <= 0x9f) { + return scanArray(input, next, marker & 0x0f, depth, state); + } + if (marker >= 0xa0 && marker <= 0xbf) { + return scanString(input, next, marker & 0x1f, next); + } + + switch (marker) { + case 0xc0: + return { end: next, kind: 'null' }; + case 0xc1: + return fail('malformed', 'reserved MessagePack marker'); + case 0xc2: + case 0xc3: + return { end: next, kind: 'boolean' }; + case 0xc4: { + ensureAvailable(input, next, 1); + const length = input[next]!; + return scanBinary(input, next + 1, length, next + 1); + } + case 0xc5: { + const length = view(input, next, 2).getUint16(0); + if (length <= 0xff) fail('non_canonical', 'non-shortest binary length'); + return scanBinary(input, next + 2, length, next + 2); + } + case 0xc6: { + const length = view(input, next, 4).getUint32(0); + if (length <= 0xffff) fail('non_canonical', 'non-shortest binary length'); + return scanBinary(input, next + 4, length, next + 4); + } + case 0xca: + ensureAvailable(input, next, 4); + return fail('non_canonical', 'float32 is forbidden'); + case 0xcb: { + const value = view(input, next, 8).getFloat64(0); + if (!Number.isFinite(value) || Object.is(value, -0)) { + fail('invalid_value', 'non-finite and negative-zero floats are forbidden'); + } + if (Number.isInteger(value)) { + if (Number.isSafeInteger(value)) fail('non_canonical', 'integral float must be an integer'); + fail('invalid_value', 'integral float exceeds the safe-integer range'); + } + return { end: next + 8, kind: 'float' }; + } + case 0xcc: { + ensureAvailable(input, next, 1); + const value = input[next]!; + if (value <= 0x7f) fail('non_canonical', 'non-shortest unsigned integer'); + return { end: next + 1, kind: 'integer' }; + } + case 0xcd: { + const value = view(input, next, 2).getUint16(0); + if (value <= 0xff) fail('non_canonical', 'non-shortest unsigned integer'); + return { end: next + 2, kind: 'integer' }; + } + case 0xce: { + const value = view(input, next, 4).getUint32(0); + if (value <= 0xffff) fail('non_canonical', 'non-shortest unsigned integer'); + return { end: next + 4, kind: 'integer' }; + } + case 0xcf: { + const value = view(input, next, 8).getBigUint64(0); + if (value <= 0xffff_ffffn) fail('non_canonical', 'non-shortest unsigned integer'); + if (value > BigInt(MAX_SAFE_INTEGER)) fail('invalid_value', 'integer exceeds safe range'); + return { end: next + 8, kind: 'integer' }; + } + case 0xd0: { + const value = view(input, next, 1).getInt8(0); + if (value >= -32) fail('non_canonical', 'non-shortest signed integer'); + return { end: next + 1, kind: 'integer' }; + } + case 0xd1: { + const value = view(input, next, 2).getInt16(0); + if (value >= -128) fail('non_canonical', 'non-shortest signed integer'); + return { end: next + 2, kind: 'integer' }; + } + case 0xd2: { + const value = view(input, next, 4).getInt32(0); + if (value >= -32_768) fail('non_canonical', 'non-shortest signed integer'); + return { end: next + 4, kind: 'integer' }; + } + case 0xd3: { + const value = view(input, next, 8).getBigInt64(0); + if (value >= -2_147_483_648n) fail('non_canonical', 'non-shortest signed integer'); + if (value < BigInt(MIN_SAFE_INTEGER)) fail('invalid_value', 'integer exceeds safe range'); + return { end: next + 8, kind: 'integer' }; + } + case 0xd9: { + ensureAvailable(input, next, 1); + const length = input[next]!; + if (length <= 31) fail('non_canonical', 'non-shortest string length'); + return scanString(input, next + 1, length, next + 1); + } + case 0xda: { + const length = view(input, next, 2).getUint16(0); + if (length <= 0xff) fail('non_canonical', 'non-shortest string length'); + return scanString(input, next + 2, length, next + 2); + } + case 0xdb: { + const length = view(input, next, 4).getUint32(0); + if (length <= 0xffff) fail('non_canonical', 'non-shortest string length'); + return scanString(input, next + 4, length, next + 4); + } + case 0xdc: { + const length = view(input, next, 2).getUint16(0); + if (length <= 15) fail('non_canonical', 'non-shortest array length'); + return scanArray(input, next + 2, length, depth, state); + } + case 0xdd: { + const length = view(input, next, 4).getUint32(0); + if (length > LIMITS.arrayItems) fail('limit', 'array length exceeds limit'); + if (length <= 0xffff) fail('non_canonical', 'non-shortest array length'); + return scanArray(input, next + 4, length, depth, state); + } + case 0xde: { + const length = view(input, next, 2).getUint16(0); + if (length <= 15) fail('non_canonical', 'non-shortest map length'); + return scanMap(input, next + 2, length, depth, state); + } + case 0xdf: { + const length = view(input, next, 4).getUint32(0); + if (length > LIMITS.mapEntries) fail('limit', 'map length exceeds limit'); + if (length <= 0xffff) fail('non_canonical', 'non-shortest map length'); + return scanMap(input, next + 4, length, depth, state); + } + default: + if ((marker >= 0xc7 && marker <= 0xc9) || (marker >= 0xd4 && marker <= 0xd8)) { + return fail('invalid_value', 'MessagePack extensions are forbidden'); + } + return fail('malformed', `unsupported MessagePack marker 0x${marker.toString(16)}`); + } +} + +function byte(value: number): Uint8Array { + return Uint8Array.of(value); +} + +function concat(parts: readonly Uint8Array[]): Uint8Array { + const length = parts.reduce((total, part) => total + part.length, 0); + const output = new Uint8Array(length); + let offset = 0; + for (const part of parts) { + output.set(part, offset); + offset += part.length; + } + return output; +} + +function numberBytes(marker: number, length: 1 | 2 | 4 | 8, setter: (view: DataView) => void): Uint8Array { + const output = new Uint8Array(1 + length); + output[0] = marker; + setter(new DataView(output.buffer, 1, length)); + return output; +} + +function encodeInteger(value: number): Uint8Array { + if (value >= 0) { + if (value <= 0x7f) return byte(value); + if (value <= 0xff) return Uint8Array.of(0xcc, value); + if (value <= 0xffff) return numberBytes(0xcd, 2, (target) => target.setUint16(0, value)); + if (value <= 0xffff_ffff) return numberBytes(0xce, 4, (target) => target.setUint32(0, value)); + return numberBytes(0xcf, 8, (target) => target.setBigUint64(0, BigInt(value))); + } + if (value >= -32) return byte(0x100 + value); + if (value >= -128) return numberBytes(0xd0, 1, (target) => target.setInt8(0, value)); + if (value >= -32_768) return numberBytes(0xd1, 2, (target) => target.setInt16(0, value)); + if (value >= -2_147_483_648) return numberBytes(0xd2, 4, (target) => target.setInt32(0, value)); + return numberBytes(0xd3, 8, (target) => target.setBigInt64(0, BigInt(value))); +} + +function encodeLength( + length: number, + fixedBase: number, + fixedMaximum: number, + marker8: number | null, + marker16: number, + marker32: number, +): Uint8Array { + if (length <= fixedMaximum) return byte(fixedBase + length); + if (marker8 !== null && length <= 0xff) return Uint8Array.of(marker8, length); + if (length <= 0xffff) return numberBytes(marker16, 2, (target) => target.setUint16(0, length)); + return numberBytes(marker32, 4, (target) => target.setUint32(0, length)); +} + +function hasUnpairedSurrogate(value: string): boolean { + for (let index = 0; index < value.length; index += 1) { + const unit = value.charCodeAt(index); + if (unit >= 0xd800 && unit <= 0xdbff) { + const following = value.charCodeAt(index + 1); + if (index + 1 >= value.length || following < 0xdc00 || following > 0xdfff) return true; + index += 1; + } else if (unit >= 0xdc00 && unit <= 0xdfff) return true; + } + return false; +} + +function validUtf8String(value: string, maximum: number, label: string): Uint8Array { + if (hasUnpairedSurrogate(value)) fail('invalid_value', `${label} contains an unpaired surrogate`); + if (value.length > maximum) fail('limit', `${label} exceeds byte limit`); + const encoded = textEncoder.encode(value); + if (encoded.length > maximum) fail('limit', `${label} exceeds byte limit`); + return encoded; +} + +function encodeValue(value: ProtocolValue, depth: number, state: EncodeState): Uint8Array { + if (depth > LIMITS.depth) fail('limit', 'value nesting exceeds limit'); + state.nodes += 1; + if (state.nodes > LIMITS.values) fail('limit', 'payload value count exceeds limit'); + + if (value === null) return encodedAtom(state, byte(0xc0)); + if (value === false) return encodedAtom(state, byte(0xc2)); + if (value === true) return encodedAtom(state, byte(0xc3)); + if (typeof value === 'number') { + if (!Number.isFinite(value) || Object.is(value, -0)) { + fail('invalid_value', 'non-finite and negative-zero numbers are forbidden'); + } + if (Number.isInteger(value)) { + if (!Number.isSafeInteger(value)) fail('invalid_value', 'integer exceeds safe range'); + return encodedAtom(state, encodeInteger(value)); + } + return encodedAtom(state, numberBytes(0xcb, 8, (target) => target.setFloat64(0, value))); + } + if (typeof value === 'string') { + const encoded = validUtf8String(value, LIMITS.stringBytes, 'string'); + const prefix = encodeLength(encoded.length, 0xa0, 31, 0xd9, 0xda, 0xdb); + accountEncodedBytes(state, prefix.length + encoded.length); + return concat([prefix, encoded]); + } + if (value instanceof Uint8Array) { + if (value.length > LIMITS.binaryBytes) fail('limit', 'binary length exceeds limit'); + const prefix = encodeLength(value.length, 0, -1, 0xc4, 0xc5, 0xc6); + accountEncodedBytes(state, prefix.length + value.length); + return concat([prefix, value]); + } + if (Array.isArray(value)) { + if (value.length > LIMITS.arrayItems) fail('limit', 'array length exceeds limit'); + const prefix = encodeLength(value.length, 0x90, 15, null, 0xdc, 0xdd); + accountEncodedBytes(state, prefix.length); + const encoded = value.map((item) => encodeValue(item, depth + 1, state)); + return concat([prefix, ...encoded]); + } + if (typeof value !== 'object') fail('invalid_value', 'unsupported protocol value'); + const prototype = Object.getPrototypeOf(value) as unknown; + if (prototype !== Object.prototype && prototype !== null) { + fail('invalid_value', 'only plain objects are protocol maps'); + } + const keys = Object.keys(value); + if (Reflect.ownKeys(value).length !== keys.length) { + fail('invalid_value', 'symbolic or non-enumerable map keys are forbidden'); + } + if (keys.length > LIMITS.mapEntries) fail('limit', 'map length exceeds limit'); + const sorted = keys.map((key) => ({ + key, + bytes: validUtf8String(key, LIMITS.mapKeyBytes, 'map key'), + })).sort((left, right) => compareBytes(left.bytes, right.bytes)); + const prefix = encodeLength(sorted.length, 0x80, 15, null, 0xde, 0xdf); + accountEncodedBytes(state, prefix.length); + const parts: Uint8Array[] = [prefix]; + for (const { key } of sorted) { + if (key === '__proto__') fail('invalid_value', 'reserved map key'); + parts.push(encodeValue(key, depth + 1, state)); + const child = value[key]; + if (child === undefined) fail('invalid_value', 'undefined map values are forbidden'); + parts.push(encodeValue(child, depth + 1, state)); + } + return concat(parts); +} + +function asArray(value: unknown, label: string, maximum: number = LIMITS.arrayItems): ProtocolValue[] { + if (!Array.isArray(value) || value.length > maximum) fail('invalid_frame', `${label} must be an array`); + return value as ProtocolValue[]; +} + +function exactTuple(value: unknown, length: number, label: string): ProtocolValue[] { + const tuple = asArray(value, label); + if (tuple.length !== length) fail('invalid_frame', `${label} must contain ${length} fields`); + return tuple; +} + +function integer(value: unknown, minimum: number, maximum: number, label: string): number { + if (!Number.isSafeInteger(value) || (value as number) < minimum || (value as number) > maximum) { + fail('invalid_frame', `${label} must be an integer in range`); + } + return value as number; +} + +function coreErrorCode(value: unknown, label: string): number { + const code = integer(value, 1, 0xffff, label); + if (!KNOWN_CORE_ERROR_CODES.has(code)) fail('invalid_frame', `${label} is not a known core error code`); + return code; +} + +function callErrorCode(value: unknown, label: string): number { + const code = integer(value, 1, 0xffff, label); + const isApplicationCode = code >= APPLICATION_ERROR_CODE_MINIMUM + && code <= APPLICATION_ERROR_CODE_MAXIMUM; + if (!KNOWN_CORE_ERROR_CODES.has(code) && !isApplicationCode) { + fail('invalid_frame', `${label} is not a permitted call error code`); + } + return code; +} + +function positiveId(value: unknown, label: string): number { + return integer(value, 1, MAX_SAFE_INTEGER, label); +} + +function correlationIdOrZero(value: unknown, label: string): number { + return integer(value, 0, MAX_SAFE_INTEGER, label); +} + +function errorCorrelation(correlationValue: unknown, sourceOpcodeValue: unknown): void { + const correlationId = correlationIdOrZero(correlationValue, 'ERROR.correlationId'); + const sourceOpcode = integer(sourceOpcodeValue, 0, 0xff, 'ERROR.sourceOpcode'); + if (correlationId === 0) { + if (sourceOpcode !== 0) { + fail('invalid_frame', 'ERROR.sourceOpcode must be zero without an origin'); + } + return; + } + if (!ERROR_CORRELATION_SOURCE_OPCODES.has(sourceOpcode)) { + fail('invalid_frame', 'ERROR.sourceOpcode is not an originating frame'); + } +} + +function booleanValue(value: unknown, label: string): boolean { + if (typeof value !== 'boolean') fail('invalid_frame', `${label} must be a boolean`); + return value; +} + +function stringValue(value: unknown, minimum: number, maximum: number, label: string): string { + if (typeof value !== 'string') fail('invalid_frame', `${label} must be a string`); + if (hasUnpairedSurrogate(value)) fail('invalid_frame', `${label} is not valid UTF-8`); + if (value.length > maximum) fail('invalid_frame', `${label} exceeds byte limit`); + const bytes = textEncoder.encode(value); + if (bytes.length < minimum || bytes.length > maximum) { + fail('invalid_frame', `${label} must be a string in range`); + } + return value; +} + +function binaryValue(value: unknown, length: number, label: string): Uint8Array { + if (!(value instanceof Uint8Array) || value.length !== length) { + fail('invalid_frame', `${label} must be ${length} binary bytes`); + } + return value; +} + +function nullableString(value: unknown, maximum: number, label: string): string | null { + if (value === null) return null; + return stringValue(value, 1, maximum, label); +} + +function opaqueId(value: unknown, label: string): string { + const result = stringValue(value, 1, 128, label); + if (/\p{Cc}/u.test(result)) fail('invalid_frame', `${label} contains a control character`); + return result; +} + +function coordinatorName(value: unknown, label: string): string { + const result = stringValue(value, 1, 64, label); + if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(result)) { + fail('invalid_frame', `${label} is not a coordinator name`); + } + return result; +} + +function structuredName(value: unknown, label: string): string { + const result = stringValue(value, 1, 256, label); + if (/\p{Cc}/u.test(result) || result.includes('*') || result.startsWith('.') || result.endsWith('.') || result.includes('..')) { + fail('invalid_frame', `${label} is not a valid dotted name`); + } + return result; +} + +function pattern(value: unknown, label: string): string { + const result = stringValue(value, 1, 256, label); + structuredName(result.endsWith('.*') ? result.slice(0, -2) : result, label); + return result; +} + +function uniqueStrings(values: ProtocolValue[], validator: (value: unknown, label: string) => string, label: string): void { + const seen = new Set(); + values.forEach((value, index) => { + const item = validator(value, `${label}[${index}]`); + if (seen.has(item)) fail('invalid_frame', `${label} contains a duplicate`); + seen.add(item); + }); +} + +function uniqueIds(values: ProtocolValue[], label: string): void { + const seen = new Set(); + values.forEach((value, index) => { + const item = positiveId(value, `${label}[${index}]`); + if (seen.has(item)) fail('invalid_frame', `${label} contains a duplicate`); + seen.add(item); + }); +} + +function epoch(value: unknown, label: string): void { + binaryValue(value, 16, label); +} + +function coordinators(value: unknown, label: string): void { + const entries = asArray(value, label, 64); + const names = new Set(); + entries.forEach((entry, index) => { + const fields = exactTuple(entry, 3, `${label}[${index}]`); + const name = coordinatorName(fields[0], `${label}[${index}].name`); + if (names.has(name)) fail('invalid_frame', `${label} contains a duplicate coordinator`); + names.add(name); + positiveId(fields[1], `${label}[${index}].generation`); + integer(fields[2], 1, 2, `${label}[${index}].status`); + }); +} + +function dictionary(value: unknown, maximum: number, nameLabel: string): void { + const entries = asArray(value, 'dictionary', maximum); + const ids = new Set(); + const names = new Set(); + entries.forEach((entry, index) => { + const fields = exactTuple(entry, 2, `dictionary[${index}]`); + const id = positiveId(fields[0], `dictionary[${index}].id`); + const name = structuredName(fields[1], `dictionary[${index}].${nameLabel}`); + if (ids.has(id) || names.has(name)) fail('invalid_frame', 'dictionary entries must be unique'); + ids.add(id); + names.add(name); + }); +} + +function mutations(value: unknown, label: string): void { + const entries = asArray(value, label, LIMITS.statePathsPerCoordinator); + const ids = new Set(); + entries.forEach((entry, index) => { + const fields = asArray(entry, `${label}[${index}]`); + if (fields.length !== 2 && fields.length !== 3) fail('invalid_frame', 'mutation has wrong arity'); + const id = positiveId(fields[0], `${label}[${index}].pathId`); + if (ids.has(id)) fail('invalid_frame', `${label} contains a duplicate path ID`); + ids.add(id); + const operation = integer(fields[1], 0, 1, `${label}[${index}].operation`); + if ((operation === 0 && fields.length !== 3) || (operation === 1 && fields.length !== 2)) { + fail('invalid_frame', 'mutation arity does not match operation'); + } + }); +} + +function aclPatterns(value: unknown, label: string): void { + const values = asArray(value, label); + uniqueStrings(values, pattern, label); +} + +function target(kindValue: unknown, targetValue: unknown, label: string): void { + const kind = integer(kindValue, 0, 2, `${label}.kind`); + if (kind === 0 && targetValue !== null) fail('invalid_frame', `${label} default target must be null`); + if (kind === 1) positiveId(targetValue, `${label}.session`); + if (kind === 2) coordinatorName(targetValue, `${label}.coordinator`); +} + +function principal(value: unknown, label: string): void { + const fields = exactTuple(value, 5, label); + const kind = integer(fields[0], 1, 3, `${label}.kind`); + opaqueId(fields[1], `${label}.id`); + positiveId(fields[2], `${label}.sessionId`); + if (kind === 2) coordinatorName(fields[3], `${label}.coordinatorName`); + else if (fields[3] !== null) fail('invalid_frame', `${label}.coordinatorName must be null`); + nullableString(fields[4], 320, `${label}.email`); +} + +function subscriptionPayload(payload: ProtocolValue[], label: string): void { + const fields = exactTuple(payload, 2, label); + positiveId(fields[0], `${label}.requestId`); + const ids = asArray(fields[1], `${label}.topicIds`, LIMITS.subscriptions); + uniqueIds(ids, `${label}.topicIds`); +} + +function validateFrame(frame: Frame): void { + const { opcode, payload } = frame; + if (opcode >= 0x80) return; + if (!KNOWN_CORE_OPCODES.has(opcode)) fail('unknown_opcode', 'unknown core opcode'); + + switch (opcode) { + case Opcode.Hello: { + const fields = exactTuple(payload, 6, 'HELLO'); + integer(fields[0], 0, 0xff, 'HELLO.major'); + const minimum = integer(fields[1], 0, 0xff, 'HELLO.minMinor'); + const maximum = integer(fields[2], 0, 0xff, 'HELLO.maxMinor'); + if (minimum > maximum) fail('invalid_frame', 'HELLO minor range is inverted'); + const role = integer(fields[3], 1, 3, 'HELLO.role'); + stringValue(fields[4], 1, 16_384, 'HELLO.token'); + const context = asArray(fields[5], 'HELLO.context'); + if (role === 1) { + const roleFields = exactTuple(context, 1, 'HELLO.userContext'); + opaqueId(roleFields[0], 'HELLO.homeId'); + } else if (role === 2) { + const roleFields = exactTuple(context, 1, 'HELLO.coordinatorContext'); + coordinatorName(roleFields[0], 'HELLO.coordinatorName'); + } else if (context.length !== 0) fail('invalid_frame', 'HELLO CLI context must be empty'); + return; + } + case Opcode.Welcome: { + const fields = exactTuple(payload, 8, 'WELCOME'); + integer(fields[0], 0, 0xff, 'WELCOME.major'); + integer(fields[1], 0, 0xff, 'WELCOME.minor'); + positiveId(fields[2], 'WELCOME.sessionId'); + epoch(fields[3], 'WELCOME.epoch'); + booleanValue(fields[4], 'WELCOME.enrolled'); + coordinators(fields[5], 'WELCOME.coordinators'); + const limits = exactTuple(fields[6], 4, 'WELCOME.limits'); + integer(limits[0], 1, LIMITS.frameBytes, 'WELCOME.maxFrameBytes'); + integer(limits[1], 1, LIMITS.inflightCalls, 'WELCOME.maxInflightCalls'); + integer(limits[2], 1, LIMITS.subscriptions, 'WELCOME.maxSubscriptions'); + integer(limits[3], 1, 1_048_576, 'WELCOME.maxQueuedBytes'); + positiveId(fields[7], 'WELCOME.expiresAtMs'); + return; + } + case Opcode.Error: { + const fields = exactTuple(payload, 5, 'ERROR'); + errorCorrelation(fields[0], fields[1]); + coreErrorCode(fields[2], 'ERROR.code'); + booleanValue(fields[3], 'ERROR.retryable'); + stringValue(fields[4], 1, 256, 'ERROR.message'); + return; + } + case Opcode.Fatal: { + const fields = exactTuple(payload, 4, 'FATAL'); + integer(fields[0], 0, 0xff, 'FATAL.sourceOpcode'); + coreErrorCode(fields[1], 'FATAL.code'); + booleanValue(fields[2], 'FATAL.retryable'); + stringValue(fields[3], 1, 256, 'FATAL.message'); + return; + } + case Opcode.Reauth: { + const fields = exactTuple(payload, 2, 'REAUTH'); + positiveId(fields[0], 'REAUTH.requestId'); + stringValue(fields[1], 1, 16_384, 'REAUTH.token'); + return; + } + case Opcode.ReauthOk: { + const fields = exactTuple(payload, 2, 'REAUTH_OK'); + positiveId(fields[0], 'REAUTH_OK.requestId'); + positiveId(fields[1], 'REAUTH_OK.expiresAtMs'); + return; + } + case Opcode.HomeStatus: { + const fields = exactTuple(payload, 2, 'HOME_STATUS'); + booleanValue(fields[0], 'HOME_STATUS.enrolled'); + coordinators(fields[1], 'HOME_STATUS.coordinators'); + return; + } + case Opcode.Goaway: { + const fields = exactTuple(payload, 2, 'GOAWAY'); + integer(fields[0], 0, LIMITS.callTimeoutMs, 'GOAWAY.retryAfterMs'); + integer(fields[1], 0, 0xffff, 'GOAWAY.reasonCode'); + return; + } + case Opcode.StateSync: { + const fields = exactTuple(payload, 2, 'STATE_SYNC'); + positiveId(fields[0], 'STATE_SYNC.requestId'); + const entries = asArray(fields[1], 'STATE_SYNC.entries', LIMITS.statePathsPerCoordinator); + const paths = new Set(); + entries.forEach((entry, index) => { + const item = exactTuple(entry, 2, `STATE_SYNC.entries[${index}]`); + const path = structuredName(item[0], `STATE_SYNC.entries[${index}].path`); + if (paths.has(path)) fail('invalid_frame', 'STATE_SYNC contains a duplicate path'); + paths.add(path); + }); + return; + } + case Opcode.StateSyncOk: { + const fields = exactTuple(payload, 4, 'STATE_SYNC_OK'); + positiveId(fields[0], 'STATE_SYNC_OK.requestId'); + epoch(fields[1], 'STATE_SYNC_OK.epoch'); + positiveId(fields[2], 'STATE_SYNC_OK.revision'); + dictionary(fields[3], LIMITS.statePathsPerCoordinator, 'path'); + return; + } + case Opcode.StateDict: { + const fields = exactTuple(payload, 3, 'STATE_DICT'); + epoch(fields[0], 'STATE_DICT.epoch'); + booleanValue(fields[1], 'STATE_DICT.replace'); + dictionary(fields[2], LIMITS.statePathsPerHome, 'path'); + return; + } + case Opcode.StateSnapshot: { + const fields = exactTuple(payload, 3, 'STATE_SNAPSHOT'); + epoch(fields[0], 'STATE_SNAPSHOT.epoch'); + positiveId(fields[1], 'STATE_SNAPSHOT.revision'); + const entries = asArray(fields[2], 'STATE_SNAPSHOT.entries', LIMITS.statePathsPerHome); + const ids = entries.map((entry, index) => exactTuple(entry, 2, `STATE_SNAPSHOT.entries[${index}]`)[0]!); + uniqueIds(ids, 'STATE_SNAPSHOT.pathIds'); + return; + } + case Opcode.StatePatch: { + const fields = exactTuple(payload, 4, 'STATE_PATCH'); + epoch(fields[0], 'STATE_PATCH.epoch'); + const base = positiveId(fields[1], 'STATE_PATCH.baseRevision'); + const revision = positiveId(fields[2], 'STATE_PATCH.revision'); + if (revision <= base) fail('invalid_frame', 'STATE_PATCH revision must advance'); + mutations(fields[3], 'STATE_PATCH.mutations'); + return; + } + case Opcode.StateSet: { + const fields = exactTuple(payload, 3, 'STATE_SET'); + positiveId(fields[0], 'STATE_SET.requestId'); + epoch(fields[1], 'STATE_SET.epoch'); + mutations(fields[2], 'STATE_SET.mutations'); + return; + } + case Opcode.StateSetOk: { + const fields = exactTuple(payload, 3, 'STATE_SET_OK'); + positiveId(fields[0], 'STATE_SET_OK.requestId'); + epoch(fields[1], 'STATE_SET_OK.epoch'); + positiveId(fields[2], 'STATE_SET_OK.revision'); + return; + } + case Opcode.StateAclSync: { + const fields = exactTuple(payload, 2, 'STATE_ACL_SYNC'); + positiveId(fields[0], 'STATE_ACL_SYNC.requestId'); + const entries = asArray(fields[1], 'STATE_ACL_SYNC.declarations', LIMITS.declarationsPerCoordinator); + const users = new Set(); + entries.forEach((entry, index) => { + const item = exactTuple(entry, 2, `STATE_ACL_SYNC.declarations[${index}]`); + const user = opaqueId(item[0], `STATE_ACL_SYNC.declarations[${index}].userId`); + if (users.has(user)) fail('invalid_frame', 'STATE_ACL_SYNC contains a duplicate user'); + users.add(user); + aclPatterns(item[1], `STATE_ACL_SYNC.declarations[${index}].patterns`); + }); + return; + } + case Opcode.StateAclOk: { + const fields = exactTuple(payload, 2, 'STATE_ACL_OK'); + positiveId(fields[0], 'STATE_ACL_OK.requestId'); + positiveId(fields[1], 'STATE_ACL_OK.policyRevision'); + return; + } + case Opcode.StateResync: { + const fields = exactTuple(payload, 1, 'STATE_RESYNC'); + positiveId(fields[0], 'STATE_RESYNC.requestId'); + return; + } + case Opcode.EventSync: { + const fields = exactTuple(payload, 2, 'EVENT_SYNC'); + positiveId(fields[0], 'EVENT_SYNC.requestId'); + const entries = asArray(fields[1], 'EVENT_SYNC.declarations', LIMITS.declarationsPerCoordinator); + const topics = new Set(); + entries.forEach((entry, index) => { + const item = exactTuple(entry, 2, `EVENT_SYNC.declarations[${index}]`); + const topic = structuredName(item[0], `EVENT_SYNC.declarations[${index}].topic`); + if (topics.has(topic)) fail('invalid_frame', 'EVENT_SYNC contains a duplicate topic'); + topics.add(topic); + integer(item[1], 1, 0x0f, `EVENT_SYNC.declarations[${index}].flags`); + }); + return; + } + case Opcode.EventSyncOk: { + const fields = exactTuple(payload, 2, 'EVENT_SYNC_OK'); + positiveId(fields[0], 'EVENT_SYNC_OK.requestId'); + dictionary(fields[1], LIMITS.declarationsPerCoordinator, 'topic'); + return; + } + case Opcode.TopicDict: { + const fields = exactTuple(payload, 3, 'TOPIC_DICT'); + epoch(fields[0], 'TOPIC_DICT.epoch'); + booleanValue(fields[1], 'TOPIC_DICT.replace'); + dictionary(fields[2], LIMITS.statePathsPerHome, 'topic'); + return; + } + case Opcode.EventAclSync: { + const fields = exactTuple(payload, 2, 'EVENT_ACL_SYNC'); + positiveId(fields[0], 'EVENT_ACL_SYNC.requestId'); + const entries = asArray(fields[1], 'EVENT_ACL_SYNC.declarations', LIMITS.declarationsPerCoordinator); + const users = new Set(); + entries.forEach((entry, index) => { + const item = exactTuple(entry, 3, `EVENT_ACL_SYNC.declarations[${index}]`); + const user = opaqueId(item[0], `EVENT_ACL_SYNC.declarations[${index}].userId`); + if (users.has(user)) fail('invalid_frame', 'EVENT_ACL_SYNC contains a duplicate user'); + users.add(user); + aclPatterns(item[1], `EVENT_ACL_SYNC.declarations[${index}].publishPatterns`); + aclPatterns(item[2], `EVENT_ACL_SYNC.declarations[${index}].subscribePatterns`); + }); + return; + } + case Opcode.EventAclOk: { + const fields = exactTuple(payload, 2, 'EVENT_ACL_OK'); + positiveId(fields[0], 'EVENT_ACL_OK.requestId'); + positiveId(fields[1], 'EVENT_ACL_OK.policyRevision'); + return; + } + case Opcode.Subscribe: + return subscriptionPayload(payload, 'SUBSCRIBE'); + case Opcode.SubscribeOk: + return subscriptionPayload(payload, 'SUBSCRIBE_OK'); + case Opcode.Unsubscribe: + return subscriptionPayload(payload, 'UNSUBSCRIBE'); + case Opcode.UnsubscribeOk: + return subscriptionPayload(payload, 'UNSUBSCRIBE_OK'); + case Opcode.Event: { + if (payload.length !== 5 && payload.length !== 6) fail('invalid_frame', 'EVENT has wrong arity'); + positiveId(payload[0], 'EVENT.eventId'); + positiveId(payload[1], 'EVENT.topicId'); + target(payload[2], payload[3], 'EVENT.target'); + if (payload.length === 6) principal(payload[4], 'EVENT.source'); + return; + } + case Opcode.FunctionSync: { + const fields = exactTuple(payload, 2, 'FUNCTION_SYNC'); + positiveId(fields[0], 'FUNCTION_SYNC.requestId'); + const names = asArray(fields[1], 'FUNCTION_SYNC.names', LIMITS.declarationsPerCoordinator); + uniqueStrings(names, structuredName, 'FUNCTION_SYNC.names'); + return; + } + case Opcode.FunctionSyncOk: { + const fields = exactTuple(payload, 2, 'FUNCTION_SYNC_OK'); + positiveId(fields[0], 'FUNCTION_SYNC_OK.requestId'); + dictionary(fields[1], LIMITS.declarationsPerCoordinator, 'function'); + return; + } + case Opcode.FunctionDict: { + const fields = exactTuple(payload, 3, 'FUNCTION_DICT'); + epoch(fields[0], 'FUNCTION_DICT.epoch'); + booleanValue(fields[1], 'FUNCTION_DICT.replace'); + dictionary(fields[2], LIMITS.statePathsPerHome, 'function'); + return; + } + case Opcode.Call: { + const fields = exactTuple(payload, 8, 'CALL'); + positiveId(fields[0], 'CALL.callId'); + target(fields[1], fields[2], 'CALL.target'); + positiveId(fields[3], 'CALL.functionId'); + integer(fields[4], 1, LIMITS.callTimeoutMs, 'CALL.timeoutMs'); + nullableString(fields[5], 128, 'CALL.idempotencyKey'); + integer(fields[6], 0, LIMITS.streamCredit, 'CALL.initialCredit'); + return; + } + case Opcode.CallDispatch: { + const fields = exactTuple(payload, 9, 'CALL_DISPATCH'); + positiveId(fields[0], 'CALL_DISPATCH.callId'); + principal(fields[1], 'CALL_DISPATCH.source'); + target(fields[2], fields[3], 'CALL_DISPATCH.target'); + positiveId(fields[4], 'CALL_DISPATCH.functionId'); + integer(fields[5], 1, LIMITS.callTimeoutMs, 'CALL_DISPATCH.timeoutMs'); + nullableString(fields[6], 128, 'CALL_DISPATCH.idempotencyKey'); + integer(fields[7], 0, LIMITS.streamCredit, 'CALL_DISPATCH.initialCredit'); + return; + } + case Opcode.CallAccepted: { + const fields = exactTuple(payload, 1, 'CALL_ACCEPTED'); + positiveId(fields[0], 'CALL_ACCEPTED.callId'); + return; + } + case Opcode.CallResult: { + const fields = exactTuple(payload, 3, 'CALL_RESULT'); + positiveId(fields[0], 'CALL_RESULT.callId'); + booleanValue(fields[1], 'CALL_RESULT.final'); + return; + } + case Opcode.CallError: { + const fields = exactTuple(payload, 5, 'CALL_ERROR'); + positiveId(fields[0], 'CALL_ERROR.callId'); + callErrorCode(fields[1], 'CALL_ERROR.code'); + booleanValue(fields[2], 'CALL_ERROR.retryable'); + stringValue(fields[3], 1, 256, 'CALL_ERROR.message'); + return; + } + case Opcode.CallCancel: { + const fields = exactTuple(payload, 2, 'CALL_CANCEL'); + positiveId(fields[0], 'CALL_CANCEL.callId'); + integer(fields[1], 0, 0xffff, 'CALL_CANCEL.reasonCode'); + return; + } + case Opcode.CallCredit: { + const fields = exactTuple(payload, 2, 'CALL_CREDIT'); + positiveId(fields[0], 'CALL_CREDIT.callId'); + integer(fields[1], 1, LIMITS.streamCredit, 'CALL_CREDIT.additionalCredit'); + return; + } + case Opcode.PresenceSnapshot: { + const fields = exactTuple(payload, 1, 'PRESENCE_SNAPSHOT'); + const entries = asArray(fields[0], 'PRESENCE_SNAPSHOT.entries'); + const sessions = new Set(); + entries.forEach((entry, index) => { + const item = exactTuple(entry, 2, `PRESENCE_SNAPSHOT.entries[${index}]`); + const session = positiveId(item[0], `PRESENCE_SNAPSHOT.entries[${index}].sessionId`); + if (sessions.has(session)) fail('invalid_frame', 'PRESENCE_SNAPSHOT contains a duplicate session'); + sessions.add(session); + opaqueId(item[1], `PRESENCE_SNAPSHOT.entries[${index}].userId`); + }); + return; + } + case Opcode.PresenceChange: { + const fields = exactTuple(payload, 3, 'PRESENCE_CHANGE'); + positiveId(fields[0], 'PRESENCE_CHANGE.sessionId'); + opaqueId(fields[1], 'PRESENCE_CHANGE.userId'); + integer(fields[2], 1, 2, 'PRESENCE_CHANGE.event'); + return; + } + default: + return fail('unknown_opcode', 'unknown core opcode'); + } +} + +function preflight(input: Uint8Array): void { + if (input.length > LIMITS.frameBytes) fail('frame_too_large', 'frame exceeds byte limit'); + if (input.length < 2) fail('malformed', 'frame is missing its payload'); + const result = scanValue(input, 1, 1, { nodes: 0 }); + if (result.kind !== 'array') fail('invalid_frame', 'frame payload must be an array'); + if (result.end !== input.length) fail('malformed', 'frame has trailing bytes'); +} + +export function encodeFrame(frame: Frame): Uint8Array { + if (!Number.isInteger(frame.opcode) || frame.opcode < 0 || frame.opcode > 0xff) { + fail('unknown_opcode', 'opcode must be one byte'); + } + validateFrame(frame); + const payload = encodeValue(frame.payload, 1, { nodes: 0, bytes: 0 }); + const output = concat([byte(frame.opcode), payload]); + preflight(output); + return output; +} + +export function decodeFrame(input: Uint8Array): Frame { + preflight(input); + const opcode = input[0]!; + if (opcode < 0x80 && !KNOWN_CORE_OPCODES.has(opcode)) { + fail('unknown_opcode', `unknown core opcode 0x${opcode.toString(16)}`); + } + let payload: unknown; + try { + payload = decode(input.subarray(1), { + useBigInt64: false, + maxStrLength: LIMITS.stringBytes, + maxBinLength: LIMITS.binaryBytes, + maxArrayLength: LIMITS.arrayItems, + maxMapLength: LIMITS.mapEntries, + maxExtLength: 0, + }); + } catch (error) { + fail('malformed', `MessagePack decoder rejected a preflighted payload: ${String(error)}`); + } + const frame: Frame = { opcode, payload: asArray(payload, 'frame payload') }; + validateFrame(frame); + return frame; +} diff --git a/src/protocol/session.ts b/src/protocol/session.ts new file mode 100644 index 0000000..7d2a523 --- /dev/null +++ b/src/protocol/session.ts @@ -0,0 +1,147 @@ +import { + decodeFrame, + encodeFrame, + Opcode, + type Frame, +} from './codec.js'; + +export type ProtocolSessionPhase = + | 'fresh' + | 'awaiting_welcome' + | 'active' + | 'draining' + | 'closed'; + +export class ProtocolSessionError extends Error { + readonly kind: 'wrong_direction' | 'unexpected_frame'; + + constructor(kind: 'wrong_direction' | 'unexpected_frame', message: string) { + super(message); + this.name = 'ProtocolSessionError'; + this.kind = kind; + } +} + +const ACTIVE_OUTGOING = new Set([ + Opcode.Reauth, + Opcode.StateSync, + Opcode.StateSet, + Opcode.StateAclSync, + Opcode.EventSync, + Opcode.EventAclSync, + Opcode.Subscribe, + Opcode.Unsubscribe, + Opcode.Event, + Opcode.FunctionSync, + Opcode.Call, + Opcode.CallResult, + Opcode.CallError, + Opcode.CallCancel, + Opcode.CallCredit, +]); + +const ACTIVE_INCOMING = new Set([ + Opcode.Error, + Opcode.Fatal, + Opcode.ReauthOk, + Opcode.Goaway, + Opcode.StateSyncOk, + Opcode.StateDict, + Opcode.StateSetOk, + Opcode.StateAclOk, + Opcode.EventSyncOk, + Opcode.TopicDict, + Opcode.EventAclOk, + Opcode.SubscribeOk, + Opcode.UnsubscribeOk, + Opcode.Event, + Opcode.FunctionSyncOk, + Opcode.FunctionDict, + Opcode.CallDispatch, + Opcode.CallAccepted, + Opcode.CallResult, + Opcode.CallError, + Opcode.CallCancel, + Opcode.CallCredit, + Opcode.PresenceSnapshot, + Opcode.PresenceChange, +]); + +const DRAINING_OUTGOING = new Set([ + Opcode.CallResult, + Opcode.CallError, +]); + +function wrongDirection(opcode: number, direction: 'outgoing' | 'incoming'): never { + throw new ProtocolSessionError( + 'wrong_direction', + `Opcode 0x${opcode.toString(16).padStart(2, '0')} is not valid ${direction} coordinator traffic`, + ); +} + +function unexpected(opcode: number, phase: ProtocolSessionPhase): never { + throw new ProtocolSessionError( + 'unexpected_frame', + `Opcode 0x${opcode.toString(16).padStart(2, '0')} is not valid during ${phase}`, + ); +} + +function validateEventArity(frame: Frame, direction: 'outgoing' | 'incoming'): void { + if (frame.opcode !== Opcode.Event) return; + const expected = direction === 'outgoing' ? 5 : 6; + if (frame.payload.length !== expected) wrongDirection(frame.opcode, direction); +} + +export class CoordinatorProtocolSession { + #phase: ProtocolSessionPhase = 'fresh'; + + get phase(): ProtocolSessionPhase { + return this.#phase; + } + + encode(frame: Frame): Uint8Array { + validateEventArity(frame, 'outgoing'); + if (this.#phase === 'fresh') { + if (frame.opcode !== Opcode.Hello) unexpected(frame.opcode, this.#phase); + const encoded = encodeFrame(frame); + this.#phase = 'awaiting_welcome'; + return encoded; + } + if (this.#phase === 'active') { + if (!ACTIVE_OUTGOING.has(frame.opcode)) wrongDirection(frame.opcode, 'outgoing'); + return encodeFrame(frame); + } + if (this.#phase === 'draining') { + if (!DRAINING_OUTGOING.has(frame.opcode)) unexpected(frame.opcode, this.#phase); + return encodeFrame(frame); + } + return unexpected(frame.opcode, this.#phase); + } + + decode(bytes: Uint8Array): Frame { + const frame = decodeFrame(bytes); + validateEventArity(frame, 'incoming'); + if (this.#phase === 'awaiting_welcome') { + if (frame.opcode === Opcode.Welcome) { + this.#phase = 'active'; + return frame; + } + if (frame.opcode === Opcode.Fatal) { + this.#phase = 'closed'; + return frame; + } + return unexpected(frame.opcode, this.#phase); + } + if (this.#phase === 'active' || this.#phase === 'draining') { + if (!ACTIVE_INCOMING.has(frame.opcode)) wrongDirection(frame.opcode, 'incoming'); + if (frame.opcode === Opcode.Goaway) this.#phase = 'draining'; + if (frame.opcode === Opcode.Fatal) this.#phase = 'closed'; + return frame; + } + return unexpected(frame.opcode, this.#phase); + } + + close(): void { + this.#phase = 'closed'; + } +} diff --git a/test.js b/test.js deleted file mode 100644 index ead3f5b..0000000 --- a/test.js +++ /dev/null @@ -1,45 +0,0 @@ -const Miakapi = require('./main'); -const credentials = require('./miakapiCredentials.json'); - -const home = Miakapi( - credentials.home, - credentials.coordID, - credentials.coordSecret, -); - -console.log('Connecting...'); - -home.onReady(() => { - console.log('Ready !', home); - home.users.forEach((user) => { - console.log('Sending notification to', user.displayName); - - user.sendPush({ - title: 'Notification test 1', - body: 'Test user.sendPush', - tag: 'test1', - }); - - home.sendNotif(user.id, { - title: 'Notification test 2', - body: 'Test home.sendNotif', - tag: 'test2', - }); - }); -}); - -home.onUpdate((users) => { - console.log('Home update !', users); -}); - -home.onUserAction((action) => { - console.log('User action :', action); -}); - -setInterval(() => { - home.variables.timestamp = Date.now(); - home.commit({ - 'global.timestamp': Date.now(), - 'global.invert': !home.variables['global.invert'], - }); -}, 5000); diff --git a/test/calls-presence.test.ts b/test/calls-presence.test.ts new file mode 100644 index 0000000..06cf633 --- /dev/null +++ b/test/calls-presence.test.ts @@ -0,0 +1,411 @@ +import { describe, expect, test } from 'bun:test'; +import { + ApplicationCallError, + type CoordinatorFailure, + type CoordinatorLogRecord, + type IncomingCall, + type PresenceEntry, + type ProtocolValue, +} from '../src/api.js'; +import { Opcode } from '../src/protocol/codec.js'; +import { flushMicrotasks } from './fakes/runtime.js'; +import { + configuration, + createTestHarness, + isCoordinatorFailure, + startReady, +} from './helpers.js'; + +function integer(value: ProtocolValue | undefined, label: string): number { + if (typeof value !== 'number' || !Number.isSafeInteger(value)) { + throw new TypeError(`${label} is invalid`); + } + return value; +} + +const USER_PRINCIPAL: ProtocolValue[] = [ + 1, + 'user-1', + 71, + null, + 'user@example.test', +]; + +describe('outgoing calls', () => { + test('streams under explicit credit and resolves one terminal result', async () => { + const harness = createTestHarness(); + const { connection } = await startReady(harness); + const handle = harness.coordinator.calls.start({ + function: 'home.echo', + arguments: { requested: true }, + timeoutMs: 5_000, + idempotencyKey: 'intent-1', + target: { kind: 'coordinator', id: 'target-coordinator' }, + }); + const call = await connection.nextClientFrame(Opcode.Call); + const callId = integer(call.payload[0], 'CALL.callId'); + expect(call.payload.slice(1)).toEqual([ + 2, + 'target-coordinator', + 301, + 5_000, + 'intent-1', + 1, + { requested: true }, + ]); + + connection.send({ opcode: Opcode.CallAccepted, payload: [callId] }); + await handle.accepted; + connection.send({ opcode: Opcode.CallResult, payload: [callId, false, 'progress-1'] }); + const stream = handle.stream[Symbol.asyncIterator](); + expect(await stream.next()).toEqual({ done: false, value: 'progress-1' }); + const credit = await connection.nextClientFrame(Opcode.CallCredit); + expect(credit.payload).toEqual([callId, 1]); + + connection.send({ opcode: Opcode.CallResult, payload: [callId, false, 'progress-2'] }); + expect(await stream.next()).toEqual({ done: false, value: 'progress-2' }); + await connection.nextClientFrame(Opcode.CallCredit); + connection.send({ opcode: Opcode.CallResult, payload: [callId, true, { complete: true }] }); + expect(await handle.result).toEqual({ complete: true }); + expect(await stream.next()).toEqual({ done: true, value: undefined }); + await harness.coordinator.stop(); + }); + + test('makes CALL_ERROR terminal and emits the same correlated failure', async () => { + const harness = createTestHarness(); + const observed: CoordinatorFailure[] = []; + harness.coordinator.errors.subscribe((failure) => observed.push(failure)); + const { connection } = await startReady(harness); + const handle = harness.coordinator.calls.start({ + function: 'home.echo', + arguments: null, + timeoutMs: 5_000, + }); + const call = await connection.nextClientFrame(Opcode.Call); + const callId = integer(call.payload[0], 'CALL.callId'); + connection.send({ opcode: Opcode.CallAccepted, payload: [callId] }); + await handle.accepted; + connection.send({ + opcode: Opcode.CallError, + payload: [callId, 2_001, false, 'Device refused', null], + }); + const resultFailure = await handle.result.catch((error: unknown) => error); + const streamFailure = await handle.stream[Symbol.asyncIterator]().next() + .catch((error: unknown) => error); + + expect(isCoordinatorFailure(resultFailure) && resultFailure.outcome).toBe('accepted'); + expect(streamFailure).toBe(resultFailure); + expect(observed[0]?.correlation).toEqual({ kind: 'call', localId: handle.localId }); + await harness.coordinator.stop(); + }); + + test('can cancel synchronously before any CALL handoff', async () => { + const harness = createTestHarness(); + const { connection } = await startReady(harness); + const handle = harness.coordinator.calls.start({ + function: 'home.echo', + arguments: null, + timeoutMs: 5_000, + }); + handle.cancel('not needed'); + const acceptedFailure = await handle.accepted.catch((error: unknown) => error); + const resultFailure = await handle.result.catch((error: unknown) => error); + await flushMicrotasks(); + + expect(isCoordinatorFailure(acceptedFailure) && acceptedFailure.outcome).toBe('not_dispatched'); + expect(resultFailure).toBe(acceptedFailure); + expect(connection.queuedClientFrameCount).toBe(0); + await harness.coordinator.stop(); + }); + + test('accepts an explicit pre-accept cancellation terminal as proof of non-dispatch', async () => { + const harness = createTestHarness(); + const { connection } = await startReady(harness); + const handle = harness.coordinator.calls.start({ + function: 'home.echo', + arguments: null, + timeoutMs: 5_000, + }); + const call = await connection.nextClientFrame(Opcode.Call); + handle.cancel(); + const cancel = await connection.nextClientFrame(Opcode.CallCancel); + expect(cancel.payload).toEqual([integer(call.payload[0], 'CALL.callId'), 1405]); + connection.send({ + opcode: Opcode.CallError, + payload: [integer(call.payload[0], 'CALL.callId'), 1405, false, 'Cancelled', null], + }); + const acceptedFailure = await handle.accepted.catch((error: unknown) => error); + const resultFailure = await handle.result.catch((error: unknown) => error); + + expect(isCoordinatorFailure(acceptedFailure) && acceptedFailure.outcome).toBe('not_dispatched'); + expect(resultFailure).toBe(acceptedFailure); + await harness.coordinator.stop(); + }); + + test('never retries a sent-before-accept call even when it carries an idempotency key', async () => { + const harness = createTestHarness(); + const { connection } = await startReady(harness); + const handle = harness.coordinator.calls.start({ + function: 'home.echo', + arguments: 'change-device', + timeoutMs: 5_000, + idempotencyKey: 'stable-intent', + }); + const call = await connection.nextClientFrame(Opcode.Call); + expect(call.payload[5]).toBe('stable-intent'); + connection.close(); + const acceptedFailure = await handle.accepted.catch((error: unknown) => error); + const resultFailure = await handle.result.catch((error: unknown) => error); + await flushMicrotasks(); + + expect(isCoordinatorFailure(acceptedFailure) && acceptedFailure.outcome).toBe('outcome_unknown'); + expect(resultFailure).toBe(acceptedFailure); + expect(harness.relay.connections).toHaveLength(1); + await harness.coordinator.stop(); + }); + + test('turns a local deadline into one cancel and an unknown outcome after handoff', async () => { + const harness = createTestHarness(); + const { connection } = await startReady(harness); + const handle = harness.coordinator.calls.start({ + function: 'home.echo', + arguments: null, + timeoutMs: 250, + }); + const call = await connection.nextClientFrame(Opcode.Call); + await harness.runtime.advanceBy(250); + const cancel = await connection.nextClientFrame(Opcode.CallCancel); + expect(cancel.payload).toEqual([integer(call.payload[0], 'CALL.callId'), 1403]); + const failure = await handle.result.catch((error: unknown) => error); + void handle.accepted.catch(() => undefined); + + expect(isCoordinatorFailure(failure) && failure.outcome).toBe('outcome_unknown'); + await harness.coordinator.stop(); + }); + + test('settles at the deadline when an earlier cancellation receives no relay terminal', async () => { + const harness = createTestHarness(); + const { connection } = await startReady(harness); + const handle = harness.coordinator.calls.start({ + function: 'home.echo', + arguments: null, + timeoutMs: 250, + }); + await connection.nextClientFrame(Opcode.Call); + handle.cancel(); + await connection.nextClientFrame(Opcode.CallCancel); + await harness.runtime.advanceBy(250); + const failure = await handle.result.catch((error: unknown) => error); + void handle.accepted.catch(() => undefined); + + expect(isCoordinatorFailure(failure) && failure.outcome).toBe('outcome_unknown'); + expect(connection.queuedClientFrameCount).toBe(0); + await harness.coordinator.stop(); + }); + + test('routes a correlated CALL_CREDIT error to the active call', async () => { + const harness = createTestHarness(); + const { connection } = await startReady(harness); + const handle = harness.coordinator.calls.start({ + function: 'home.echo', + arguments: null, + timeoutMs: 5_000, + }); + const call = await connection.nextClientFrame(Opcode.Call); + const callId = integer(call.payload[0], 'CALL.callId'); + connection.send({ opcode: Opcode.CallAccepted, payload: [callId] }); + await handle.accepted; + connection.send({ opcode: Opcode.CallResult, payload: [callId, false, 'progress'] }); + await handle.stream[Symbol.asyncIterator]().next(); + await connection.nextClientFrame(Opcode.CallCredit); + connection.send({ + opcode: Opcode.Error, + payload: [callId, Opcode.CallCredit, 1201, false, 'Credit rejected'], + }); + const failure = await handle.result.catch((error: unknown) => error); + + expect(isCoordinatorFailure(failure) && failure.outcome).toBe('accepted'); + expect(harness.coordinator.status).toBe('ready'); + await harness.coordinator.stop(); + }); +}); + +describe('incoming calls', () => { + test('continues routing against the captured active handler during live synchronization', async () => { + const harness = createTestHarness(); + const { connection } = await startReady(harness, configuration(() => 'active-handler')); + const declaration = harness.coordinator.state.declare({ 'home.temperature': 26 }); + const state = await connection.nextClientFrame(Opcode.StateSync); + expect(harness.coordinator.status).toBe('synchronizing'); + + connection.send({ + opcode: Opcode.CallDispatch, + payload: [90, USER_PRINCIPAL, 0, null, 301, 5_000, null, 0, null], + }); + const result = await connection.nextClientFrame(Opcode.CallResult); + expect(result.payload).toEqual([90, true, 'active-handler']); + + await connection.acknowledgeDeclarations(state); + await declaration; + await harness.coordinator.stop(); + }); + + test('serializes progress under relay credit and captures an immutable principal', async () => { + const observedCalls: IncomingCall[] = []; + const handler = async (incoming: IncomingCall): Promise => { + observedCalls.push(incoming); + await incoming.emit('progress-1'); + await incoming.emit('progress-2'); + return { echoed: incoming.arguments }; + }; + const harness = createTestHarness(); + const { connection } = await startReady(harness, configuration(handler)); + connection.send({ + opcode: Opcode.CallDispatch, + payload: [91, USER_PRINCIPAL, 0, null, 301, 5_000, 'intent-91', 1, 'input'], + }); + const first = await connection.nextClientFrame(Opcode.CallResult); + expect(first.payload).toEqual([91, false, 'progress-1']); + expect(observedCalls[0]?.source).toEqual({ + kind: 'user', + id: 'user-1', + sessionId: 71, + coordinatorName: null, + verifiedEmail: 'user@example.test', + }); + expect(Object.isFrozen(observedCalls[0]?.source)).toBe(true); + expect(observedCalls[0]?.idempotencyKey).toBe('intent-91'); + expect(connection.queuedClientFrameCount).toBe(0); + + connection.send({ opcode: Opcode.CallCredit, payload: [91, 1] }); + const second = await connection.nextClientFrame(Opcode.CallResult); + const final = await connection.nextClientFrame(Opcode.CallResult); + expect(second.payload).toEqual([91, false, 'progress-2']); + expect(final.payload).toEqual([91, true, { echoed: 'input' }]); + await harness.coordinator.stop(); + }); + + test('preserves safe application errors and redacts unexpected handler failures', async () => { + const appHarness = createTestHarness(); + const app = await startReady(appHarness, configuration(() => { + throw new ApplicationCallError(2_042, 'Device is locked', true); + })); + app.connection.send({ + opcode: Opcode.CallDispatch, + payload: [92, USER_PRINCIPAL, 0, null, 301, 5_000, null, 0, null], + }); + const applicationError = await app.connection.nextClientFrame(Opcode.CallError); + expect(applicationError.payload).toEqual([92, 2_042, true, 'Device is locked', null]); + await appHarness.coordinator.stop(); + + const logs: CoordinatorLogRecord[] = []; + const genericHarness = createTestHarness({}, { write: (record) => logs.push(record) }); + const generic = await startReady(genericHarness, configuration(() => { + throw new Error('secret credential value'); + })); + generic.connection.send({ + opcode: Opcode.CallDispatch, + payload: [93, USER_PRINCIPAL, 0, null, 301, 5_000, null, 0, null], + }); + const genericError = await generic.connection.nextClientFrame(Opcode.CallError); + expect(genericError.payload).toEqual([93, 1500, false, 'Application handler failed', null]); + expect(JSON.stringify(logs)).not.toContain('secret credential value'); + expect(logs.some((record) => record.event === 'function_handler_failed')).toBe(true); + await genericHarness.coordinator.stop(); + }); + + test('turns an invalid fulfilled handler result into a generic CALL_ERROR', async () => { + const declarations = configuration(); + Object.defineProperty(declarations.functions, 'home.echo', { + configurable: true, + enumerable: true, + value: () => undefined, + writable: true, + }); + const harness = createTestHarness(); + const observed: CoordinatorFailure[] = []; + harness.coordinator.errors.subscribe((failure) => observed.push(failure)); + const { connection } = await startReady(harness, declarations); + connection.send({ + opcode: Opcode.CallDispatch, + payload: [95, USER_PRINCIPAL, 0, null, 301, 5_000, null, 0, null], + }); + const failure = await connection.nextClientFrame(Opcode.CallError); + + expect(failure.payload).toEqual([95, 1500, false, 'Application handler failed', null]); + connection.send({ + opcode: Opcode.Error, + payload: [95, Opcode.CallError, 1201, false, 'Reply rejected'], + }); + await flushMicrotasks(); + expect(observed.some((entry) => entry.code === 1201 && entry.outcome === 'outcome_unknown')) + .toBe(true); + expect(harness.coordinator.status).toBe('ready'); + await harness.coordinator.stop(); + }); + + test('aborts an active handler when the caller cancels', async () => { + let observedSignal: AbortSignal | undefined; + const handler = (incoming: IncomingCall): Promise => { + observedSignal = incoming.signal; + return new Promise((_resolve, reject) => { + incoming.signal.addEventListener('abort', () => reject(incoming.signal.reason), { once: true }); + }); + }; + const harness = createTestHarness(); + const { connection } = await startReady(harness, configuration(handler)); + connection.send({ + opcode: Opcode.CallDispatch, + payload: [94, USER_PRINCIPAL, 0, null, 301, 5_000, null, 0, null], + }); + await flushMicrotasks(); + connection.send({ opcode: Opcode.CallCancel, payload: [94, 1405] }); + await flushMicrotasks(); + + expect(observedSignal?.aborted).toBe(true); + expect(connection.queuedClientFrameCount).toBe(0); + await harness.coordinator.stop(); + }); +}); + +describe('presence', () => { + test('publishes immutable sorted snapshots and clears them on disconnect', async () => { + const harness = createTestHarness(); + const { connection } = await startReady(harness); + const observed: Array = []; + harness.coordinator.presence.subscribe((entries) => observed.push(entries)); + expect(observed).toEqual([[]]); + + connection.send({ + opcode: Opcode.PresenceSnapshot, + payload: [[[72, 'user-2'], [71, 'user-1']]], + }); + expect(harness.coordinator.presence.snapshot()).toEqual([ + { sessionId: 71, userId: 'user-1' }, + { sessionId: 72, userId: 'user-2' }, + ]); + expect(Object.isFrozen(harness.coordinator.presence.snapshot())).toBe(true); + connection.send({ opcode: Opcode.PresenceChange, payload: [71, 'user-1', 2] }); + expect(harness.coordinator.presence.snapshot()).toEqual([{ sessionId: 72, userId: 'user-2' }]); + + connection.close(); + await flushMicrotasks(); + expect(harness.coordinator.presence.snapshot()).toEqual([]); + expect(observed.at(-1)).toEqual([]); + await harness.coordinator.stop(); + }); + + test('fails the session on conflicting presence changes', async () => { + const harness = createTestHarness(); + const failures: CoordinatorFailure[] = []; + harness.coordinator.errors.subscribe((failure) => failures.push(failure)); + const { connection } = await startReady(harness); + connection.send({ opcode: Opcode.PresenceChange, payload: [71, 'user-1', 1] }); + connection.send({ opcode: Opcode.PresenceChange, payload: [71, 'user-1', 1] }); + await flushMicrotasks(); + + expect(failures.some((failure) => failure.kind === 'protocol')).toBe(true); + expect(harness.coordinator.status).toBe('reconnecting'); + await harness.coordinator.stop(); + }); +}); diff --git a/test/contract/recorder.ts b/test/contract/recorder.ts new file mode 100644 index 0000000..928d787 --- /dev/null +++ b/test/contract/recorder.ts @@ -0,0 +1,272 @@ +import type { + CoordinatorFailure, + CoordinatorStatus, + DispatchOutcome, +} from '../../src/api.js'; + +export type DeclarationDomain = + | 'state' + | 'state_access' + | 'events' + | 'event_access' + | 'functions'; + +export interface DeclarationRevisions { + state: number; + state_access: number; + events: number; + event_access: number; + functions: number; +} + +export type JsonValue = + | null + | boolean + | number + | string + | JsonValue[] + | { [key: string]: JsonValue }; + +export type ContractStimulus = + | { kind: 'construct' } + | { kind: 'start'; promise_id: string } + | { kind: 'welcome'; session_id: number; generation: number } + | { + kind: 'declaration_update'; + transaction: number; + promise_ids: string[]; + changed_domains: DeclarationDomain[]; + revisions: DeclarationRevisions; + } + | { kind: 'declaration_handoff'; transaction: number } + | { kind: 'declaration_ack'; domain: DeclarationDomain; transaction: number } + | { + kind: 'declaration_error'; + domain: DeclarationDomain; + transaction: number; + code: string; + } + | { kind: 'declaration_probe' } + | { kind: 'disconnect'; phase: 'before_send' | 'sent' | 'accepted' | 'ready' } + | { + kind: 'operation'; + operation_id: string; + operation: 'state_set' | 'event' | 'call'; + phase: 'before_send' | 'sent' | 'accepted'; + idempotency_key?: string | null; + } + | { kind: 'call_progress'; operation_id: string; value: JsonValue } + | { kind: 'call_result'; operation_id: string; value: JsonValue } + | { kind: 'call_cancel'; operation_id: string } + | { + kind: 'operation_terminal'; + operation_id: string; + outcome: 'not_dispatched' | 'applied' | 'failed' | 'outcome_unknown'; + } + | { kind: 'operation_error'; operation_id: string; code: string } + | { kind: 'presence'; entries: PresenceTrace[] } + | { kind: 'state_publish'; paths: string[] } + | { kind: 'effect'; effect: string } + | { kind: 'stop'; promise_id: string }; + +export interface ContractReplaySetup { + scenario_id: string; + mode: 'sdk' | 'observe' | 'shadow_state' | 'recorded_action'; + desired_declarations: DeclarationDomain[]; +} + +export interface PresenceTrace { + session_id: number; + user_id: string; +} + +export interface LifecyclePromiseTrace { + operation: 'start' | 'stop'; + promise_id: string; + invocation_stimulus_index: number; + settlement_stimulus_index: number; + outcome: 'resolved' | 'rejected'; + code?: string; + session_id?: number; + generation?: number; +} + +export interface DeclarationPromiseTrace { + promise_id: string; + transaction: number; + stimulus_index: number; + outcome: 'activated' | 'rejected'; + code?: string; +} + +export interface OperationTrace { + operation_id: string; + operation: 'state_set' | 'event' | 'call'; + attempts: number; + outcome: 'not_dispatched' | 'sent' | 'applied' | 'succeeded' | 'failed' | 'outcome_unknown'; + idempotency_key?: string | null; +} + +export interface CallStreamTrace { + operation_id: string; + acceptance: 'resolved' | 'rejected'; + progress: JsonValue[]; + terminal: + | { kind: 'result'; value: JsonValue } + | { kind: 'error'; code: 'not_dispatched' | 'failed' | 'outcome_unknown' }; +} + +export interface ContractObservation { + statuses: CoordinatorStatus[]; + status_checkpoints: Array<{ stimulus_index: number; status: CoordinatorStatus }>; + lifecycle_promises: LifecyclePromiseTrace[]; + access_reasons: Array<'initial' | 'reauth' | 'reconnect'>; + declarations: Array<{ + domain: DeclarationDomain; + generation: number; + transaction: number; + }>; + declaration_snapshots: Array<{ + generation: number; + transaction: number; + stimulus_index: number; + revisions: DeclarationRevisions; + }>; + declaration_promises: DeclarationPromiseTrace[]; + declaration_visibility: Array<'none' | 'previous' | 'desired'>; + operations: OperationTrace[]; + call_streams: CallStreamTrace[]; + presence: PresenceTrace[][]; + state_publications: number; + effects: Array<{ effect: string; destination: 'recorder' | 'live' | 'rejected' }>; + errors: Array<{ + code: string; + stimulus_index: number; + correlation?: { kind: 'event' | 'call'; local_id: string }; + }>; + resources: { + sockets: number; + socket_high_water: number; + timers: number | 'not_asserted'; + listeners: number | 'not_asserted'; + iterators: number | 'not_asserted'; + }; +} + +export interface ResourceObservation { + sockets: number; + socketHighWater: number; + timers: number; + terminal: boolean; +} + +function errorCode(failure: CoordinatorFailure): string { + return failure.kind; +} + +export function terminalCode(failure: CoordinatorFailure): 'not_dispatched' | 'failed' | 'outcome_unknown' { + if (failure.outcome === 'outcome_unknown') return 'outcome_unknown'; + if (failure.outcome === 'not_dispatched' && failure.kind !== 'internal') { + return 'not_dispatched'; + } + return 'failed'; +} + +export function operationOutcome( + failure: CoordinatorFailure, +): 'not_dispatched' | 'failed' | 'outcome_unknown' { + return terminalCode(failure); +} + +export function isFailure(value: unknown): value is CoordinatorFailure { + return value instanceof Error + && 'kind' in value + && 'outcome' in value + && 'retryable' in value; +} + +export class ContractRecorder { + readonly statuses: CoordinatorStatus[] = []; + readonly statusCheckpoints: Array<{ stimulus_index: number; status: CoordinatorStatus }> = []; + readonly lifecyclePromises: LifecyclePromiseTrace[] = []; + readonly accessReasons: Array<'initial' | 'reauth' | 'reconnect'> = []; + readonly declarations: ContractObservation['declarations'] = []; + readonly declarationSnapshots: ContractObservation['declaration_snapshots'] = []; + readonly declarationPromises: DeclarationPromiseTrace[] = []; + readonly declarationVisibility: Array<'none' | 'previous' | 'desired'> = []; + readonly operations: OperationTrace[] = []; + readonly callStreams: CallStreamTrace[] = []; + readonly presence: PresenceTrace[][] = []; + readonly effects: ContractObservation['effects'] = []; + readonly errors: ContractObservation['errors'] = []; + readonly #operationIdsByLocalId = new Map(); + stimulusIndex = -1; + statePublications = 0; + recording = true; + injectedErrorCode: string | undefined; + + recordInitialIdle(): void { + this.statuses.push('idle'); + this.statusCheckpoints.push({ stimulus_index: this.stimulusIndex, status: 'idle' }); + } + + recordStatus(status: CoordinatorStatus): void { + if (!this.recording) return; + this.statuses.push(status); + this.statusCheckpoints.push({ stimulus_index: this.stimulusIndex, status }); + } + + correlate(localId: string, operationId: string): void { + this.#operationIdsByLocalId.set(localId, operationId); + } + + recordFailure(failure: CoordinatorFailure): void { + if (!this.recording) return; + const code = this.injectedErrorCode ?? errorCode(failure); + const correlation = failure.correlation; + if (correlation === undefined) { + this.errors.push({ code, stimulus_index: this.stimulusIndex }); + return; + } + const operationId = this.#operationIdsByLocalId.get(correlation.localId); + if (operationId === undefined) { + throw new Error('SDK emitted an error for an unknown local operation'); + } + this.errors.push({ + code, + stimulus_index: this.stimulusIndex, + correlation: { kind: correlation.kind, local_id: operationId }, + }); + } + + observe(resources: ResourceObservation): ContractObservation { + const unasserted = resources.terminal ? resources.timers : 'not_asserted'; + return { + statuses: [...this.statuses], + status_checkpoints: [...this.statusCheckpoints], + lifecycle_promises: [...this.lifecyclePromises], + access_reasons: [...this.accessReasons], + declarations: [...this.declarations], + declaration_snapshots: [...this.declarationSnapshots], + declaration_promises: [...this.declarationPromises], + declaration_visibility: [...this.declarationVisibility], + operations: [...this.operations], + call_streams: [...this.callStreams], + presence: this.presence.map((entries) => entries.map((entry) => ({ ...entry }))), + state_publications: this.statePublications, + effects: [...this.effects], + errors: [...this.errors], + resources: { + sockets: resources.sockets, + socket_high_water: resources.socketHighWater, + timers: unasserted, + listeners: resources.terminal ? 0 : 'not_asserted', + iterators: resources.terminal ? 0 : 'not_asserted', + }, + }; + } +} + +export function failureOutcome(value: unknown): DispatchOutcome | undefined { + return isFailure(value) ? value.outcome : undefined; +} diff --git a/test/contract/subject.ts b/test/contract/subject.ts new file mode 100644 index 0000000..d578434 --- /dev/null +++ b/test/contract/subject.ts @@ -0,0 +1,1038 @@ +import type { + AccessToken, + AccessTokenRequest, + CallHandle, + Coordinator, + DeclarationReceipt, + EventHandle, + ProtocolValue, + StateReceipt, +} from '../../src/api.js'; +import { EventDirection } from '../../src/api.js'; +import { createCoordinatorWithRuntime } from '../../src/coordinator.js'; +import { Opcode, type Frame } from '../../src/protocol/codec.js'; +import { FakeRelay, type FakeRelayConnection } from '../fakes/relay.js'; +import { FakeRuntime, flushMicrotasks } from '../fakes/runtime.js'; +import { + ContractRecorder, + type CallStreamTrace, + type ContractObservation, + type ContractReplaySetup, + type ContractStimulus, + type DeclarationDomain, + type DeclarationRevisions, + type JsonValue, + isFailure, + operationOutcome, +} from './recorder.js'; + +const COORDINATOR_NAME = 'contract-coordinator'; +const TOKEN_EXPIRY_MS = 8_000_000_000_000_000; +const DOMAIN_OPCODE: Record = { + state: Opcode.StateSync, + state_access: Opcode.StateAclSync, + events: Opcode.EventSync, + event_access: Opcode.EventAclSync, + functions: Opcode.FunctionSync, +}; + +interface PendingToken { + request: AccessTokenRequest; + resolve(value: AccessToken): void; + reject(reason: unknown): void; + abort(): void; +} + +interface TransactionSnapshot { + generation: number; + transaction: number; + revisions: DeclarationRevisions; +} + +interface OperationRecord { + operationId: string; + operation: 'state_set' | 'event' | 'call'; + trace: ContractObservation['operations'][number]; + wireId?: number; + statePromise?: Promise; + eventHandle?: EventHandle; + callHandle?: CallHandle; + callStream?: CallStreamTrace; +} + +function cloneRevisions(revisions: DeclarationRevisions): DeclarationRevisions { + return { ...revisions }; +} + +function revisionsEqual(left: DeclarationRevisions, right: DeclarationRevisions): boolean { + return left.state === right.state + && left.state_access === right.state_access + && left.events === right.events + && left.event_access === right.event_access + && left.functions === right.functions; +} + +function integer(value: ProtocolValue | undefined, label: string): number { + if (typeof value !== 'number' || !Number.isSafeInteger(value)) { + throw new TypeError(`${label} is invalid`); + } + return value; +} + +function array(value: ProtocolValue | undefined, label: string): ProtocolValue[] { + if (!Array.isArray(value)) throw new TypeError(`${label} is invalid`); + return value; +} + +function dictionaryFromNames(names: readonly string[], firstId: number): ProtocolValue[] { + return names.map((name, index) => [firstId + index, name]); +} + +function namesFromTuples(value: ProtocolValue | undefined, label: string): string[] { + return array(value, label).map((raw, index) => { + const tuple = array(raw, `${label}[${index}]`); + const name = tuple[0]; + if (typeof name !== 'string') throw new TypeError(`${label}[${index}] has no name`); + return name; + }); +} + +function names(value: ProtocolValue | undefined, label: string): string[] { + return array(value, label).map((raw, index) => { + if (typeof raw !== 'string') throw new TypeError(`${label}[${index}] is invalid`); + return raw; + }); +} + +function relayCode(code: string): number { + if (code === 'authorization') return 1201; + if (code === 'ownership_collision') return 1301; + if (code === 'not_declared') return 1304; + if (code === 'cancelled') return 1405; + if (code === 'outcome_unknown') return 1404; + if (code === 'application_failure') return 2_001; + return 1500; +} + +function sourceOpcode(domain: DeclarationDomain): number { + return DOMAIN_OPCODE[domain]; +} + +class TokenGate { + readonly #recorder: ContractRecorder; + readonly #pending: PendingToken[] = []; + + constructor(recorder: ContractRecorder) { + this.#recorder = recorder; + } + + request(request: AccessTokenRequest): Promise { + this.#recorder.accessReasons.push(request.reason); + return new Promise((resolve, reject) => { + const abort = () => { + const index = this.#pending.indexOf(pending); + if (index !== -1) this.#pending.splice(index, 1); + reject(request.signal.reason); + }; + const pending: PendingToken = { + request, + resolve: (value) => { + request.signal.removeEventListener('abort', abort); + resolve(value); + }, + reject, + abort, + }; + if (request.signal.aborted) abort(); + else { + this.#pending.push(pending); + request.signal.addEventListener('abort', abort, { once: true }); + } + }); + } + + async releaseNext(): Promise { + for (let turn = 0; turn < 20 && this.#pending.length === 0; turn += 1) { + await flushMicrotasks(1); + } + const pending = this.#pending.shift(); + if (pending === undefined) throw new Error('SDK did not request an access token'); + pending.resolve({ + relayUrl: 'wss://relay.contract.test/miakapp/ws', + token: `contract-${pending.request.reason}`, + expiresAtMs: TOKEN_EXPIRY_MS, + }); + } +} + +class CoordinatorSdkContractSubject { + #setup: ContractReplaySetup | undefined; + #recorder = new ContractRecorder(); + #relay: FakeRelay | undefined; + #runtime: FakeRuntime | undefined; + #tokenGate: TokenGate | undefined; + #coordinator: Coordinator | undefined; + #connection: FakeRelayConnection | undefined; + #connectionIndex = 0; + #generation = 0; + #stimulusIndex = -1; + #desiredRevisions: DeclarationRevisions = { + state: 0, + state_access: 0, + events: 0, + event_access: 0, + functions: 0, + }; + #activeRevisions: DeclarationRevisions | undefined; + readonly #transactions = new Map(); + readonly #functionFrames = new Map(); + readonly #operations = new Map(); + readonly #declarationPromiseOrder = new Map(); + readonly #background: Promise[] = []; + #primaryStartCount = 0; + #stopSettlementIndex: number | undefined; + #presenceInitial = true; + #currentTransaction: number | undefined; + #queuedTransaction: number | undefined; + #transactionHandedOff = false; + + async reset(setup: ContractReplaySetup, signal: AbortSignal): Promise { + if (signal.aborted) throw signal.reason; + await this.#cleanupPrevious(); + this.#setup = setup; + this.#recorder = new ContractRecorder(); + this.#relay = undefined; + this.#runtime = undefined; + this.#tokenGate = undefined; + this.#coordinator = undefined; + this.#connection = undefined; + this.#connectionIndex = 0; + this.#generation = 0; + this.#stimulusIndex = -1; + this.#desiredRevisions = { + state: 0, + state_access: 0, + events: 0, + event_access: 0, + functions: 0, + }; + this.#activeRevisions = undefined; + this.#transactions.clear(); + this.#functionFrames.clear(); + this.#operations.clear(); + this.#declarationPromiseOrder.clear(); + this.#background.length = 0; + this.#primaryStartCount = 0; + this.#stopSettlementIndex = undefined; + this.#presenceInitial = true; + this.#currentTransaction = undefined; + this.#queuedTransaction = undefined; + this.#transactionHandedOff = false; + } + + async dispatch(stimulus: ContractStimulus, signal: AbortSignal): Promise { + if (signal.aborted) throw signal.reason; + this.#stimulusIndex += 1; + this.#recorder.stimulusIndex = this.#stimulusIndex; + switch (stimulus.kind) { + case 'construct': + this.#ensureCoordinator(true); + break; + case 'start': + this.#start(stimulus.promise_id); + break; + case 'welcome': + await this.#welcome(stimulus.session_id, stimulus.generation); + break; + case 'declaration_update': + this.#declarationUpdate(stimulus); + break; + case 'declaration_handoff': + await this.#declarationHandoff(stimulus.transaction); + break; + case 'declaration_ack': + await this.#declarationAck(stimulus.domain, stimulus.transaction); + break; + case 'declaration_error': + await this.#declarationError(stimulus.domain, stimulus.transaction, stimulus.code); + break; + case 'declaration_probe': + this.#declarationProbe(); + break; + case 'operation': + await this.#operation(stimulus); + break; + case 'operation_terminal': + await this.#operationTerminal(stimulus.operation_id, stimulus.outcome); + break; + case 'operation_error': + await this.#operationError(stimulus.operation_id, stimulus.code); + break; + case 'call_progress': + await this.#callProgress(stimulus.operation_id, stimulus.value); + break; + case 'call_result': + await this.#callResult(stimulus.operation_id, stimulus.value); + break; + case 'call_cancel': + await this.#callCancel(stimulus.operation_id); + break; + case 'disconnect': + this.#connectionOrThrow().close(); + this.#currentTransaction = undefined; + this.#queuedTransaction = undefined; + this.#transactionHandedOff = false; + break; + case 'presence': + this.#presence(stimulus.entries); + break; + case 'stop': + await this.#stop(stimulus.promise_id); + break; + case 'state_publish': + case 'effect': + throw new Error(`SDK contract subject cannot dispatch migration stimulus ${stimulus.kind}`); + default: + throw new Error('Unsupported coordinator contract stimulus'); + } + await flushMicrotasks(20); + } + + async observe(signal: AbortSignal): Promise { + if (signal.aborted) throw signal.reason; + await flushMicrotasks(30); + await Promise.all(this.#background); + if (signal.aborted) throw signal.reason; + this.#recorder.lifecyclePromises.sort((left, right) => ( + left.invocation_stimulus_index - right.invocation_stimulus_index + )); + this.#recorder.declarationPromises.sort((left, right) => ( + (this.#declarationPromiseOrder.get(left.promise_id) ?? Number.MAX_SAFE_INTEGER) + - (this.#declarationPromiseOrder.get(right.promise_id) ?? Number.MAX_SAFE_INTEGER) + )); + const coordinator = this.#coordinator; + const relay = this.#relay; + const runtime = this.#runtime; + const terminal = coordinator === undefined + || coordinator.status === 'idle' + || coordinator.status === 'stopped'; + return this.#recorder.observe({ + sockets: relay?.openConnectionCount ?? 0, + socketHighWater: relay?.socketHighWater ?? 0, + timers: runtime?.pendingTimerCount ?? 0, + terminal, + }); + } + + async #cleanupPrevious(): Promise { + const coordinator = this.#coordinator; + if (coordinator === undefined || coordinator.status === 'stopped') return; + this.#recorder.recording = false; + const stopped = coordinator.stop({ deadlineMs: 0 }); + await this.#runtime?.advanceBy(0); + await stopped; + } + + #ensureCoordinator(recordIdle = false): Coordinator { + if (this.#coordinator !== undefined) return this.#coordinator; + if (this.#setup === undefined || this.#setup.mode !== 'sdk') { + throw new Error('SDK subject requires an sdk setup'); + } + const relay = new FakeRelay({ autoWelcome: false, coordinatorName: COORDINATOR_NAME }); + const runtime = new FakeRuntime(relay); + const tokenGate = new TokenGate(this.#recorder); + const coordinator = createCoordinatorWithRuntime({ + name: COORDINATOR_NAME, + accessTokenProvider: { + getAccessToken: (request) => tokenGate.request(request), + }, + }, runtime); + coordinator.configure(this.#configurationFor(this.#desiredRevisions)); + coordinator.subscribe((event) => this.#recorder.recordStatus(event.current)); + coordinator.errors.subscribe((failure) => this.#recorder.recordFailure(failure)); + coordinator.presence.subscribe((entries) => { + if (this.#presenceInitial) { + this.#presenceInitial = false; + return; + } + if (!this.#recorder.recording) return; + this.#recorder.presence.push(entries.map((entry) => ({ + session_id: entry.sessionId, + user_id: entry.userId, + }))); + }); + this.#relay = relay; + this.#runtime = runtime; + this.#tokenGate = tokenGate; + this.#coordinator = coordinator; + if (recordIdle) this.#recorder.recordInitialIdle(); + return coordinator; + } + + #configurationFor(revisions: DeclarationRevisions) { + return { + state: this.#stateFor(revisions.state), + stateAccess: this.#stateAccessFor(revisions.state_access), + events: this.#eventsFor(revisions.events), + eventAccess: this.#eventAccessFor(revisions.event_access), + functions: this.#functionsFor(revisions.functions), + }; + } + + #stateFor(revision: number): Readonly> { + return { 'contract.state': revision }; + } + + #stateAccessFor(revision: number) { + return [{ userId: `contract-user-${revision}`, patterns: ['contract.*'] }]; + } + + #eventsFor(revision: number) { + const entries = [{ topic: 'contract.event', directions: EventDirection.publishToUsers }]; + if (revision > 0) { + entries.push({ + topic: `contract.event.revision${revision}`, + directions: EventDirection.publishToUsers, + }); + } + return entries; + } + + #eventAccessFor(revision: number) { + return [{ + userId: `contract-user-${revision}`, + publish: ['contract.*'], + subscribe: ['contract.*'], + }]; + } + + #functionsFor(revision: number) { + const functions: Record ProtocolValue> = { + 'contract.call': () => revision, + }; + if (revision > 0) functions[`contract.call.revision${revision}`] = () => revision; + return functions; + } + + #start(promiseId: string): void { + const coordinator = this.#ensureCoordinator(); + const invocationIndex = this.#stimulusIndex; + const duplicate = this.#primaryStartCount > 0; + this.#primaryStartCount += 1; + const started = coordinator.start(); + const tracked = started.then( + (ready) => { + this.#recorder.lifecyclePromises.push({ + operation: 'start', + promise_id: promiseId, + invocation_stimulus_index: invocationIndex, + settlement_stimulus_index: this.#recorder.stimulusIndex, + outcome: 'resolved', + session_id: ready.sessionId, + generation: ready.generation, + }); + }, + (error: unknown) => { + if (!isFailure(error)) throw error; + this.#recorder.lifecyclePromises.push({ + operation: 'start', + promise_id: promiseId, + invocation_stimulus_index: invocationIndex, + settlement_stimulus_index: this.#recorder.stimulusIndex, + outcome: 'rejected', + code: error.kind, + }); + if (duplicate) this.#recorder.recordFailure(error); + }, + ); + this.#background.push(tracked); + } + + async #welcome(sessionId: number, generation: number): Promise { + const coordinator = this.#ensureCoordinator(); + const runtime = this.#runtimeOrThrow(); + if (coordinator.status === 'reconnecting') await runtime.advanceBy(0); + await this.#tokenGateOrThrow().releaseNext(); + await flushMicrotasks(20); + const connection = await this.#relayOrThrow().connectionAt(this.#connectionIndex); + this.#connectionIndex += 1; + await connection.nextClientFrame(Opcode.Hello); + connection.send({ + opcode: Opcode.Welcome, + payload: [ + 1, + 0, + sessionId, + connection.epoch, + true, + [[COORDINATOR_NAME, generation, 1]], + [262_144, 128, 256, 1_048_576], + TOKEN_EXPIRY_MS, + ], + }); + this.#connection = connection; + this.#generation = generation; + const snapshot: TransactionSnapshot = { + generation, + transaction: 1, + revisions: cloneRevisions(this.#desiredRevisions), + }; + this.#transactions.set(this.#transactionKey(generation, 1), snapshot); + this.#currentTransaction = 1; + this.#queuedTransaction = undefined; + this.#transactionHandedOff = false; + this.#recordTransactionStart(snapshot); + await flushMicrotasks(20); + } + + #declarationUpdate( + stimulus: Extract, + ): void { + const coordinator = this.#ensureCoordinator(); + if (stimulus.changed_domains.length !== stimulus.promise_ids.length) { + throw new Error('Declaration promise IDs do not match changed domains'); + } + this.#desiredRevisions = cloneRevisions(stimulus.revisions); + const snapshot: TransactionSnapshot = { + generation: this.#generation, + transaction: stimulus.transaction, + revisions: cloneRevisions(stimulus.revisions), + }; + this.#transactions.set(this.#transactionKey(this.#generation, stimulus.transaction), snapshot); + if (this.#currentTransaction === undefined) { + this.#currentTransaction = stimulus.transaction; + this.#transactionHandedOff = false; + this.#recordTransactionStart(snapshot); + } else if (this.#transactionHandedOff) { + this.#queuedTransaction = stimulus.transaction; + } else { + this.#currentTransaction = stimulus.transaction; + this.#recordTransactionStart(snapshot); + } + + stimulus.changed_domains.forEach((domain, index) => { + const promiseId = stimulus.promise_ids[index]; + if (promiseId === undefined) throw new Error('Declaration promise ID is missing'); + this.#declarationPromiseOrder.set(promiseId, this.#declarationPromiseOrder.size); + let declaration: Promise; + if (domain === 'state') { + declaration = coordinator.state.declare(this.#stateFor(stimulus.revisions.state)); + } else if (domain === 'state_access') { + declaration = coordinator.access.declareState( + this.#stateAccessFor(stimulus.revisions.state_access), + ); + } else if (domain === 'events') { + declaration = coordinator.events.declare(this.#eventsFor(stimulus.revisions.events)); + } else if (domain === 'event_access') { + declaration = coordinator.access.declareEvents( + this.#eventAccessFor(stimulus.revisions.event_access), + ); + } else { + declaration = coordinator.functions.declare( + this.#functionsFor(stimulus.revisions.functions), + ); + } + const tracked = declaration.then( + () => { + this.#recorder.declarationPromises.push({ + promise_id: promiseId, + transaction: stimulus.transaction, + stimulus_index: this.#recorder.stimulusIndex, + outcome: 'activated', + }); + }, + (error: unknown) => { + if (!isFailure(error)) throw error; + this.#recorder.declarationPromises.push({ + promise_id: promiseId, + transaction: stimulus.transaction, + stimulus_index: this.#recorder.stimulusIndex, + outcome: 'rejected', + code: this.#recorder.injectedErrorCode ?? error.kind, + }); + }, + ); + this.#background.push(tracked); + }); + } + + async #declarationHandoff(transaction: number): Promise { + if (this.#currentTransaction !== transaction) { + throw new Error(`Declaration transaction ${transaction} is not current at handoff`); + } + const frame = await this.#takeLatestOpcode(Opcode.FunctionSync); + this.#verifyDeclarationFrame('functions', transaction, frame); + this.#functionFrames.set(this.#transactionKey(this.#generation, transaction), frame); + this.#transactionHandedOff = true; + } + + async #declarationAck(domain: DeclarationDomain, transaction: number): Promise { + const key = this.#transactionKey(this.#generation, transaction); + const frame = domain === 'functions' + ? this.#functionFrames.get(key) + : await this.#takeLatestOpcode(DOMAIN_OPCODE[domain]); + if (frame === undefined) throw new Error(`Missing ${domain} declaration handoff`); + this.#verifyDeclarationFrame(domain, transaction, frame); + const requestId = integer(frame.payload[0], `${domain} request ID`); + const connection = this.#connectionOrThrow(); + if (domain === 'state') { + connection.send({ + opcode: Opcode.StateSyncOk, + payload: [ + requestId, + connection.epoch, + transaction + 1, + dictionaryFromNames(namesFromTuples(frame.payload[1], 'STATE_SYNC.entries'), 101), + ], + }); + } else if (domain === 'state_access') { + connection.send({ opcode: Opcode.StateAclOk, payload: [requestId, transaction + 1] }); + } else if (domain === 'events') { + connection.send({ + opcode: Opcode.EventSyncOk, + payload: [ + requestId, + dictionaryFromNames(namesFromTuples(frame.payload[1], 'EVENT_SYNC.entries'), 201), + ], + }); + } else if (domain === 'event_access') { + connection.send({ opcode: Opcode.EventAclOk, payload: [requestId, transaction + 1] }); + } else { + connection.send({ + opcode: Opcode.FunctionSyncOk, + payload: [requestId, dictionaryFromNames(names(frame.payload[1], 'FUNCTION_SYNC.names'), 301)], + }); + this.#functionFrames.delete(key); + const snapshot = this.#transaction(transaction); + this.#activeRevisions = cloneRevisions(snapshot.revisions); + this.#activateQueuedTransaction(); + } + this.#recorder.declarations.push({ domain, generation: this.#generation, transaction }); + await flushMicrotasks(20); + } + + async #declarationError( + domain: DeclarationDomain, + transaction: number, + code: string, + ): Promise { + const key = this.#transactionKey(this.#generation, transaction); + const frame = domain === 'functions' + ? this.#functionFrames.get(key) + : await this.#takeLatestOpcode(DOMAIN_OPCODE[domain]); + if (frame === undefined) throw new Error(`Missing ${domain} declaration frame`); + this.#verifyDeclarationFrame(domain, transaction, frame); + this.#recorder.injectedErrorCode = code; + this.#connectionOrThrow().send({ + opcode: Opcode.Error, + payload: [ + integer(frame.payload[0], `${domain} request ID`), + sourceOpcode(domain), + relayCode(code), + false, + 'Synthetic declaration rejection', + ], + }); + await flushMicrotasks(20); + this.#recorder.injectedErrorCode = undefined; + if (this.#activeRevisions !== undefined) { + this.#desiredRevisions = cloneRevisions(this.#activeRevisions); + } + this.#activateQueuedTransaction(); + } + + #activateQueuedTransaction(): void { + if (this.#queuedTransaction === undefined) { + this.#currentTransaction = undefined; + this.#transactionHandedOff = false; + return; + } + this.#currentTransaction = this.#queuedTransaction; + this.#queuedTransaction = undefined; + this.#transactionHandedOff = false; + this.#recordTransactionStart(this.#transaction(this.#currentTransaction)); + } + + #recordTransactionStart(snapshot: TransactionSnapshot): void { + if (this.#recorder.declarationSnapshots.some((entry) => ( + entry.generation === snapshot.generation && entry.transaction === snapshot.transaction + ))) return; + this.#recorder.declarationSnapshots.push({ + generation: snapshot.generation, + transaction: snapshot.transaction, + stimulus_index: this.#stimulusIndex, + revisions: cloneRevisions(snapshot.revisions), + }); + } + + #declarationProbe(): void { + if (this.#activeRevisions === undefined) { + this.#recorder.declarationVisibility.push('none'); + } else if (revisionsEqual(this.#activeRevisions, this.#desiredRevisions)) { + this.#recorder.declarationVisibility.push('desired'); + } else { + this.#recorder.declarationVisibility.push('previous'); + } + } + + async #operation( + stimulus: Extract, + ): Promise { + const coordinator = this.#ensureCoordinator(); + const trace: ContractObservation['operations'][number] = stimulus.operation === 'call' + ? { + operation_id: stimulus.operation_id, + operation: stimulus.operation, + attempts: 0, + outcome: 'not_dispatched', + idempotency_key: stimulus.idempotency_key ?? null, + } + : { + operation_id: stimulus.operation_id, + operation: stimulus.operation, + attempts: 0, + outcome: 'not_dispatched', + }; + const record: OperationRecord = { + operationId: stimulus.operation_id, + operation: stimulus.operation, + trace, + }; + this.#operations.set(stimulus.operation_id, record); + this.#recorder.operations.push(trace); + + if (stimulus.operation === 'state_set') { + const operation = coordinator.state.set([ + { path: 'contract.state', value: this.#stimulusIndex }, + ]); + record.statePromise = operation; + this.#trackStateOperation(record, operation); + if (stimulus.phase !== 'before_send') { + const frame = await this.#takeLatestOpcode(Opcode.StateSet); + record.wireId = integer(frame.payload[0], 'STATE_SET.requestId'); + trace.attempts += 1; + } + return; + } + if (stimulus.operation === 'event') { + const handle = coordinator.events.publish('contract.event', stimulus.operation_id); + record.eventHandle = handle; + this.#recorder.correlate(handle.localId, stimulus.operation_id); + const tracked = handle.sent.then( + () => { trace.outcome = 'sent'; }, + (error: unknown) => { + if (!isFailure(error)) throw error; + trace.outcome = operationOutcome(error); + }, + ); + this.#background.push(tracked); + if (stimulus.phase !== 'before_send') { + const frame = await this.#takeLatestOpcode(Opcode.Event); + record.wireId = integer(frame.payload[0], 'EVENT.eventId'); + trace.attempts += 1; + } + return; + } + + const callBase = { + function: 'contract.call', + arguments: stimulus.operation_id, + timeoutMs: 300_000, + }; + const options = stimulus.idempotency_key === undefined || stimulus.idempotency_key === null + ? callBase + : { ...callBase, idempotencyKey: stimulus.idempotency_key }; + const handle = coordinator.calls.start(options); + record.callHandle = handle; + this.#recorder.correlate(handle.localId, stimulus.operation_id); + const callStream: CallStreamTrace = { + operation_id: stimulus.operation_id, + acceptance: 'rejected', + progress: [], + terminal: { kind: 'error', code: 'not_dispatched' }, + }; + record.callStream = callStream; + this.#recorder.callStreams.push(callStream); + this.#trackCallOperation(record, handle, callStream); + if (stimulus.phase !== 'before_send') { + const frame = await this.#takeLatestOpcode(Opcode.Call); + record.wireId = integer(frame.payload[0], 'CALL.callId'); + trace.attempts += 1; + if (stimulus.phase === 'accepted') { + this.#connectionOrThrow().send({ opcode: Opcode.CallAccepted, payload: [record.wireId] }); + } + } + } + + #trackStateOperation(record: OperationRecord, operation: Promise): void { + const tracked = operation.then( + () => { record.trace.outcome = 'applied'; }, + (error: unknown) => { + if (!isFailure(error)) throw error; + record.trace.outcome = operationOutcome(error); + }, + ); + this.#background.push(tracked); + } + + #trackCallOperation( + record: OperationRecord, + handle: CallHandle, + stream: CallStreamTrace, + ): void { + const acceptance = handle.accepted.then( + () => { stream.acceptance = 'resolved'; }, + (error: unknown) => { + if (!isFailure(error)) throw error; + stream.acceptance = 'rejected'; + }, + ); + const result = handle.result.then( + (value) => { + const json = this.#jsonValue(value); + record.trace.outcome = 'succeeded'; + stream.terminal = { kind: 'result', value: json }; + }, + (error: unknown) => { + if (!isFailure(error)) throw error; + const outcome = operationOutcome(error); + record.trace.outcome = outcome; + stream.terminal = { kind: 'error', code: outcome }; + }, + ); + const progress = (async () => { + try { + for await (const value of handle.stream) stream.progress.push(this.#jsonValue(value)); + } catch (error) { + if (!isFailure(error)) throw error; + } + })(); + this.#background.push(acceptance, result, progress); + } + + async #operationTerminal( + operationId: string, + outcome: 'not_dispatched' | 'applied' | 'failed' | 'outcome_unknown', + ): Promise { + const operation = this.#operationOrThrow(operationId); + if (operation.operation === 'state_set' && operation.wireId !== undefined) { + if (outcome === 'applied') { + this.#connectionOrThrow().send({ + opcode: Opcode.StateSetOk, + payload: [operation.wireId, this.#connectionOrThrow().epoch, this.#stimulusIndex + 1], + }); + } else if (outcome === 'failed' || outcome === 'not_dispatched') { + this.#connectionOrThrow().send({ + opcode: Opcode.Error, + payload: [ + operation.wireId, + Opcode.StateSet, + outcome === 'failed' ? 1500 : 1304, + false, + 'Synthetic state terminal', + ], + }); + } + } + await flushMicrotasks(20); + if (operation.trace.outcome !== outcome) { + throw new Error( + `SDK operation ${operationId} settled ${operation.trace.outcome}, expected ${outcome}`, + ); + } + } + + async #operationError(operationId: string, code: string): Promise { + const operation = this.#operationOrThrow(operationId); + if (operation.wireId === undefined) throw new Error(`Operation ${operationId} has no wire ID`); + this.#recorder.injectedErrorCode = code; + if (operation.operation === 'event') { + this.#connectionOrThrow().send({ + opcode: Opcode.Error, + payload: [operation.wireId, Opcode.Event, relayCode(code), false, 'Synthetic event error'], + }); + } else if (operation.operation === 'call') { + this.#connectionOrThrow().send({ + opcode: Opcode.CallError, + payload: [operation.wireId, relayCode(code), false, 'Synthetic call error', null], + }); + } else { + throw new Error('State errors use operation_terminal stimuli'); + } + await flushMicrotasks(20); + this.#recorder.injectedErrorCode = undefined; + } + + async #callProgress(operationId: string, value: JsonValue): Promise { + const operation = this.#operationOrThrow(operationId); + if (operation.operation !== 'call' || operation.wireId === undefined) { + throw new Error(`Operation ${operationId} is not an active call`); + } + this.#connectionOrThrow().send({ + opcode: Opcode.CallResult, + payload: [operation.wireId, false, value], + }); + await flushMicrotasks(20); + await this.#takeLatestOpcode(Opcode.CallCredit); + } + + async #callResult(operationId: string, value: JsonValue): Promise { + const operation = this.#operationOrThrow(operationId); + if (operation.operation !== 'call' || operation.wireId === undefined) { + throw new Error(`Operation ${operationId} is not an active call`); + } + this.#connectionOrThrow().send({ + opcode: Opcode.CallResult, + payload: [operation.wireId, true, value], + }); + } + + async #callCancel(operationId: string): Promise { + const operation = this.#operationOrThrow(operationId); + if (operation.callHandle === undefined || operation.wireId === undefined) { + throw new Error(`Operation ${operationId} is not an active call`); + } + operation.callHandle.cancel(); + const frame = await this.#takeLatestOpcode(Opcode.CallCancel); + if (integer(frame.payload[0], 'CALL_CANCEL.callId') !== operation.wireId) { + throw new Error('SDK cancelled the wrong call'); + } + } + + #presence(entries: Array<{ session_id: number; user_id: string }>): void { + this.#connectionOrThrow().send({ + opcode: Opcode.PresenceSnapshot, + payload: [entries.map((entry) => [entry.session_id, entry.user_id])], + }); + } + + async #stop(promiseId: string): Promise { + const coordinator = this.#ensureCoordinator(); + const invocationIndex = this.#stimulusIndex; + const stopped = coordinator.stop(); + await stopped; + if (this.#stopSettlementIndex === undefined) this.#stopSettlementIndex = this.#stimulusIndex; + this.#recorder.lifecyclePromises.push({ + operation: 'stop', + promise_id: promiseId, + invocation_stimulus_index: invocationIndex, + settlement_stimulus_index: this.#stopSettlementIndex, + outcome: 'resolved', + }); + } + + async #takeLatestOpcode(opcode: number): Promise { + const connection = this.#connectionOrThrow(); + await flushMicrotasks(20); + const frames: Frame[] = []; + if (connection.queuedClientFrameCount === 0) { + frames.push(await connection.nextClientFrame()); + } + while (connection.queuedClientFrameCount > 0) { + frames.push(await connection.nextClientFrame()); + } + const matching = frames.filter((frame) => frame.opcode === opcode); + const selected = matching[matching.length - 1]; + if (selected === undefined) { + throw new Error( + `SDK did not emit opcode 0x${opcode.toString(16)}; received ${frames.map((frame) => frame.opcode).join(',')}`, + ); + } + return selected; + } + + #verifyDeclarationFrame(domain: DeclarationDomain, transaction: number, frame: Frame): void { + const snapshot = this.#transaction(transaction); + if (frame.opcode !== DOMAIN_OPCODE[domain]) throw new Error(`Wrong frame for ${domain}`); + if (domain === 'state') { + const entries = array(frame.payload[1], 'STATE_SYNC.entries'); + const base = entries.find((entry) => Array.isArray(entry) && entry[0] === 'contract.state'); + if (!Array.isArray(base) || base[1] !== snapshot.revisions.state) { + throw new Error('STATE_SYNC does not contain the expected desired revision'); + } + } + if (domain === 'state_access') { + const entries = array(frame.payload[1], 'STATE_ACL_SYNC.entries'); + const first = entries[0]; + if (!Array.isArray(first) || first[0] !== `contract-user-${snapshot.revisions.state_access}`) { + throw new Error('STATE_ACL_SYNC does not contain the expected desired revision'); + } + } + if (domain === 'events') { + const eventNames = namesFromTuples(frame.payload[1], 'EVENT_SYNC.entries'); + const marker = snapshot.revisions.events === 0 + ? 'contract.event' + : `contract.event.revision${snapshot.revisions.events}`; + if (!eventNames.includes(marker)) { + throw new Error('EVENT_SYNC does not contain the expected desired revision'); + } + } + if (domain === 'event_access') { + const entries = array(frame.payload[1], 'EVENT_ACL_SYNC.entries'); + const first = entries[0]; + if (!Array.isArray(first) || first[0] !== `contract-user-${snapshot.revisions.event_access}`) { + throw new Error('EVENT_ACL_SYNC does not contain the expected desired revision'); + } + } + if (domain === 'functions') { + const functionNames = names(frame.payload[1], 'FUNCTION_SYNC.names'); + const marker = snapshot.revisions.functions === 0 + ? 'contract.call' + : `contract.call.revision${snapshot.revisions.functions}`; + if (!functionNames.includes(marker)) { + throw new Error('FUNCTION_SYNC does not contain the expected desired revision'); + } + } + } + + #transaction(transaction: number): TransactionSnapshot { + const snapshot = this.#transactions.get(this.#transactionKey(this.#generation, transaction)); + if (snapshot === undefined) throw new Error(`Unknown declaration transaction ${transaction}`); + return snapshot; + } + + #transactionKey(generation: number, transaction: number): string { + return `${generation}:${transaction}`; + } + + #operationOrThrow(operationId: string): OperationRecord { + const operation = this.#operations.get(operationId); + if (operation === undefined) throw new Error(`Unknown operation ${operationId}`); + return operation; + } + + #connectionOrThrow(): FakeRelayConnection { + if (this.#connection === undefined) throw new Error('No active contract relay connection'); + return this.#connection; + } + + #runtimeOrThrow(): FakeRuntime { + if (this.#runtime === undefined) throw new Error('Contract runtime is not constructed'); + return this.#runtime; + } + + #relayOrThrow(): FakeRelay { + if (this.#relay === undefined) throw new Error('Contract relay is not constructed'); + return this.#relay; + } + + #tokenGateOrThrow(): TokenGate { + if (this.#tokenGate === undefined) throw new Error('Contract token gate is not constructed'); + return this.#tokenGate; + } + + #jsonValue(value: ProtocolValue): JsonValue { + if (value instanceof Uint8Array) throw new TypeError('Contract JSON trace cannot contain binary'); + if (Array.isArray(value)) return value.map((entry) => this.#jsonValue(entry)); + if (value !== null && typeof value === 'object') { + const output: { [key: string]: JsonValue } = {}; + for (const [key, entry] of Object.entries(value)) output[key] = this.#jsonValue(entry); + return output; + } + return value; + } +} + +export function createCoordinatorContractSubject(): CoordinatorSdkContractSubject { + return new CoordinatorSdkContractSubject(); +} diff --git a/test/declarations.test.ts b/test/declarations.test.ts new file mode 100644 index 0000000..25f06e0 --- /dev/null +++ b/test/declarations.test.ts @@ -0,0 +1,261 @@ +import { describe, expect, test } from 'bun:test'; +import { EventDirection, type CoordinatorFailure } from '../src/api.js'; +import { Opcode, type Frame, type ProtocolValue } from '../src/protocol/codec.js'; +import type { FakeRelayConnection } from './fakes/relay.js'; +import { flushMicrotasks } from './fakes/runtime.js'; +import { + configuration, + createTestHarness, + isCoordinatorFailure, + startReady, +} from './helpers.js'; + +function integer(value: ProtocolValue | undefined, label: string): number { + if (typeof value !== 'number' || !Number.isSafeInteger(value)) { + throw new TypeError(`${label} is invalid`); + } + return value; +} + +function stringEntries(value: ProtocolValue | undefined, label: string): string[] { + if (!Array.isArray(value)) throw new TypeError(`${label} is not an array`); + return value.map((raw, index) => { + if (!Array.isArray(raw) || typeof raw[0] !== 'string') { + throw new TypeError(`${label}[${index}] is invalid`); + } + return raw[0]; + }); +} + +function names(value: ProtocolValue | undefined, label: string): string[] { + if (!Array.isArray(value)) throw new TypeError(`${label} is not an array`); + return value.map((raw, index) => { + if (typeof raw !== 'string') throw new TypeError(`${label}[${index}] is invalid`); + return raw; + }); +} + +function dictionary(values: readonly string[], firstId: number): ProtocolValue[] { + return values.map((value, index) => [firstId + index, value]); +} + +async function advanceThroughFunctionHandoff( + connection: FakeRelayConnection, + initialState?: Frame, +): Promise { + const state = initialState ?? await connection.nextClientFrame(Opcode.StateSync); + connection.send({ + opcode: Opcode.StateSyncOk, + payload: [ + integer(state.payload[0], 'STATE_SYNC.requestId'), + connection.epoch, + 2, + dictionary(stringEntries(state.payload[1], 'STATE_SYNC.entries'), 101), + ], + }); + const stateAccess = await connection.nextClientFrame(Opcode.StateAclSync); + connection.send({ + opcode: Opcode.StateAclOk, + payload: [integer(stateAccess.payload[0], 'STATE_ACL_SYNC.requestId'), 2], + }); + const events = await connection.nextClientFrame(Opcode.EventSync); + connection.send({ + opcode: Opcode.EventSyncOk, + payload: [ + integer(events.payload[0], 'EVENT_SYNC.requestId'), + dictionary(stringEntries(events.payload[1], 'EVENT_SYNC.entries'), 201), + ], + }); + const eventAccess = await connection.nextClientFrame(Opcode.EventAclSync); + connection.send({ + opcode: Opcode.EventAclOk, + payload: [integer(eventAccess.payload[0], 'EVENT_ACL_SYNC.requestId'), 2], + }); + const functions = await connection.nextClientFrame(Opcode.FunctionSync); + await flushMicrotasks(); + return functions; +} + +function acknowledgeFunctions(connection: FakeRelayConnection, frame: Frame): void { + connection.send({ + opcode: Opcode.FunctionSyncOk, + payload: [ + integer(frame.payload[0], 'FUNCTION_SYNC.requestId'), + dictionary(names(frame.payload[1], 'FUNCTION_SYNC.names'), 301), + ], + }); +} + +describe('declaration transactions', () => { + test('defensively snapshots configuration before transport handoff', async () => { + const harness = createTestHarness(); + const state = { 'home.temperature': 20 }; + const events = [{ topic: 'home.alert', directions: EventDirection.publishToUsers }]; + const configured = { + state, + stateAccess: [{ userId: 'user-1', patterns: ['home.*'] }], + events, + eventAccess: [], + functions: { 'home.echo': () => 'original' }, + }; + harness.coordinator.configure(configured); + state['home.temperature'] = 99; + events[0]!.topic = 'home.mutated'; + + const started = harness.coordinator.start(); + const connection = await harness.relay.connectionAt(0); + await connection.nextClientFrame(Opcode.Hello); + const stateFrame = await connection.nextClientFrame(Opcode.StateSync); + expect(stateFrame.payload[1]).toEqual([['home.temperature', 20]]); + const exchange = await connection.acknowledgeDeclarations(stateFrame); + expect(exchange.events.payload[1]).toEqual([ + ['home.alert', EventDirection.publishToUsers], + ]); + await started; + await harness.coordinator.stop(); + }); + + test('activates a live declaration atomically and resolves its receipt at the final ACK', async () => { + const harness = createTestHarness(); + const { connection } = await startReady(harness); + let settled = false; + const declaration = harness.coordinator.state.declare({ + 'home.temperature': 21, + 'home.humidity': 45, + }).finally(() => { + settled = true; + }); + const state = await connection.nextClientFrame(Opcode.StateSync); + const functions = await advanceThroughFunctionHandoff(connection, state); + expect(settled).toBe(false); + expect(harness.coordinator.status).toBe('synchronizing'); + + acknowledgeFunctions(connection, functions); + expect(await declaration).toEqual({ sessionId: 41, generation: 4 }); + expect(harness.coordinator.status).toBe('ready'); + await harness.coordinator.stop(); + }); + + test('supersedes a pre-handoff desired snapshot and ignores its stale ACK', async () => { + const harness = createTestHarness(); + const { connection } = await startReady(harness); + const first = harness.coordinator.state.declare({ 'home.temperature': 21 }); + const firstState = await connection.nextClientFrame(Opcode.StateSync); + const second = harness.coordinator.state.declare({ 'home.temperature': 22 }); + const firstFailure = await first.catch((error: unknown) => error); + const secondState = await connection.nextClientFrame(Opcode.StateSync); + + expect(isCoordinatorFailure(firstFailure) && firstFailure.kind).toBe('superseded'); + connection.send({ + opcode: Opcode.StateSyncOk, + payload: [integer(firstState.payload[0], 'first request'), connection.epoch, 2, [[101, 'home.temperature']]], + }); + await flushMicrotasks(); + expect(harness.coordinator.status).toBe('synchronizing'); + + await connection.acknowledgeDeclarations(secondState); + await expect(second).resolves.toEqual({ sessionId: 41, generation: 4 }); + await harness.coordinator.stop(); + }); + + test('queues a newer desired snapshot after final-frame handoff', async () => { + const harness = createTestHarness(); + const { connection } = await startReady(harness); + const stateDeclaration = harness.coordinator.state.declare({ 'home.temperature': 23 }); + const functions = await advanceThroughFunctionHandoff(connection); + + const eventDeclaration = harness.coordinator.events.declare([ + { topic: 'home.alert', directions: EventDirection.publishToUsers }, + { topic: 'home.changed', directions: EventDirection.publishToUsers }, + ]); + acknowledgeFunctions(connection, functions); + await expect(stateDeclaration).resolves.toBeDefined(); + + const queuedState = await connection.nextClientFrame(Opcode.StateSync); + expect(queuedState.payload[1]).toEqual([['home.temperature', 23]]); + await connection.acknowledgeDeclarations(queuedState); + await expect(eventDeclaration).resolves.toBeDefined(); + expect(harness.coordinator.status).toBe('ready'); + await harness.coordinator.stop(); + }); + + test('rolls a rejected desired slice back before the next transaction', async () => { + const harness = createTestHarness(); + const failures: CoordinatorFailure[] = []; + harness.coordinator.errors.subscribe((failure) => failures.push(failure)); + const { connection } = await startReady(harness); + const rejected = harness.coordinator.state.declare({ 'home.temperature': 99 }); + const state = await connection.nextClientFrame(Opcode.StateSync); + connection.send({ + opcode: Opcode.StateSyncOk, + payload: [integer(state.payload[0], 'STATE_SYNC.requestId'), connection.epoch, 2, [[101, 'home.temperature']]], + }); + const stateAccess = await connection.nextClientFrame(Opcode.StateAclSync); + connection.send({ + opcode: Opcode.Error, + payload: [ + integer(stateAccess.payload[0], 'STATE_ACL_SYNC.requestId'), + Opcode.StateAclSync, + 1301, + false, + 'Synthetic collision', + ], + }); + const rejection = await rejected.catch((error: unknown) => error); + expect(isCoordinatorFailure(rejection) && rejection.code).toBe(1301); + expect(harness.coordinator.status).toBe('ready'); + + const later = harness.coordinator.events.declare([ + { topic: 'home.alert', directions: EventDirection.publishToUsers }, + { topic: 'home.changed', directions: EventDirection.publishToUsers }, + ]); + const rolledBackState = await connection.nextClientFrame(Opcode.StateSync); + expect(rolledBackState.payload[1]).toEqual([['home.temperature', 20]]); + await connection.acknowledgeDeclarations(rolledBackState); + await later; + expect(failures.some((failure) => failure.code === 1301)).toBe(true); + await harness.coordinator.stop(); + }); + + test('treats an out-of-order declaration acknowledgement as a protocol failure', async () => { + const harness = createTestHarness(); + const failures: CoordinatorFailure[] = []; + harness.coordinator.errors.subscribe((failure) => failures.push(failure)); + harness.coordinator.configure(configuration()); + const started = harness.coordinator.start(); + void started.catch(() => undefined); + const connection = await harness.relay.connectionAt(0); + await connection.nextClientFrame(Opcode.Hello); + const state = await connection.nextClientFrame(Opcode.StateSync); + connection.send({ + opcode: Opcode.StateAclOk, + payload: [integer(state.payload[0], 'STATE_SYNC.requestId'), 1], + }); + await flushMicrotasks(); + + expect(failures.some((failure) => failure.kind === 'protocol')).toBe(true); + expect(harness.coordinator.status).toBe('reconnecting'); + await harness.coordinator.stop(); + }); + + test('rejects an activation dictionary that does not match the declared snapshot', async () => { + const harness = createTestHarness(); + const failures: CoordinatorFailure[] = []; + harness.coordinator.errors.subscribe((failure) => failures.push(failure)); + harness.coordinator.configure(configuration()); + const started = harness.coordinator.start(); + void started.catch(() => undefined); + const connection = await harness.relay.connectionAt(0); + await connection.nextClientFrame(Opcode.Hello); + const state = await connection.nextClientFrame(Opcode.StateSync); + connection.send({ + opcode: Opcode.StateSyncOk, + payload: [integer(state.payload[0], 'STATE_SYNC.requestId'), connection.epoch, 1, []], + }); + await flushMicrotasks(); + + expect(failures.filter((failure) => failure.kind === 'protocol')).toHaveLength(1); + expect(harness.coordinator.status).toBe('reconnecting'); + await harness.coordinator.stop(); + }); +}); diff --git a/test/fakes/relay.ts b/test/fakes/relay.ts new file mode 100644 index 0000000..df8b5c4 --- /dev/null +++ b/test/fakes/relay.ts @@ -0,0 +1,351 @@ +import { + decodeFrame, + encodeFrame, + Opcode, + type Frame, + type ProtocolValue, +} from '../../src/protocol/codec.js'; +import type { + ManagedSocket, + SocketFactory, + SocketHandlers, +} from '../../src/internal/runtime.js'; +import { createDeferred } from '../../src/internal/resources.js'; +import { flushMicrotasks } from './runtime.js'; + +interface FrameWaiter { + resolve(frame: Frame): void; +} + +export interface FakeRelayOptions { + autoWelcome?: boolean; + coordinatorName?: string; + epoch?: Uint8Array; + expiresAtMs?: number; + generation?: number; + sessionId?: number; +} + +export interface DeclarationExchange { + state: Frame; + stateAccess: Frame; + events: Frame; + eventAccess: Frame; + functions: Frame; +} + +function integer(value: ProtocolValue | undefined, label: string): number { + if (typeof value !== 'number' || !Number.isSafeInteger(value)) { + throw new TypeError(`${label} is not an integer`); + } + return value; +} + +function entries(value: ProtocolValue | undefined, label: string): ProtocolValue[] { + if (!Array.isArray(value)) throw new TypeError(`${label} is not an array`); + return value; +} + +function namesFromEntries(frame: Frame, label: string): string[] { + return entries(frame.payload[1], label).map((raw, index) => { + const tuple = entries(raw, `${label}[${index}]`); + const name = tuple[0]; + if (typeof name !== 'string') throw new TypeError(`${label}[${index}] has no name`); + return name; + }); +} + +function functionNames(frame: Frame): string[] { + return entries(frame.payload[1], 'FUNCTION_SYNC.names').map((value, index) => { + if (typeof value !== 'string') throw new TypeError(`FUNCTION_SYNC.names[${index}] is invalid`); + return value; + }); +} + +function dictionary(names: readonly string[], firstId: number): ProtocolValue[] { + return names.map((name, index) => [firstId + index, name]); +} + +class FakeManagedSocket implements ManagedSocket { + readonly #connection: FakeRelayConnection; + #closed = false; + #detached = false; + #bufferedBytes = 0; + #nextWriteError: Error | undefined; + #nextWriteCompletion: Promise | undefined; + + constructor(connection: FakeRelayConnection) { + this.#connection = connection; + } + + get bufferedBytes(): number { + return this.#bufferedBytes; + } + + setBufferedBytes(value: number): void { + this.#bufferedBytes = value; + } + + failNextWrite(error = new Error('Synthetic write failure')): void { + this.#nextWriteError = error; + } + + deferNextWrite(completion: Promise): void { + if (this.#nextWriteCompletion !== undefined) { + throw new Error('A synthetic write is already deferred'); + } + this.#nextWriteCompletion = completion; + } + + async write(bytes: Uint8Array): Promise { + if (this.#closed) throw new Error('Synthetic socket is closed'); + const failure = this.#nextWriteError; + this.#nextWriteError = undefined; + if (failure !== undefined) throw failure; + this.#connection.receiveClientBytes(bytes); + const completion = this.#nextWriteCompletion; + this.#nextWriteCompletion = undefined; + await completion; + } + + close(code = 1000, reason = ''): void { + if (this.#closed) return; + this.#closed = true; + if (!this.#detached) this.#connection.notifyClientClose(code, reason); + } + + terminate(): void { + this.close(1006, 'terminated'); + } + + detach(): void { + this.#detached = true; + } +} + +export class FakeRelayConnection { + readonly #handlers: SocketHandlers; + readonly #options: Required; + readonly #onClosed: () => void; + readonly #frames: Frame[] = []; + readonly #waiters: FrameWaiter[] = []; + readonly socket: FakeManagedSocket; + #serverClosed = false; + + constructor( + handlers: SocketHandlers, + options: Required, + onClosed: () => void, + ) { + this.#handlers = handlers; + this.#options = options; + this.#onClosed = onClosed; + this.socket = new FakeManagedSocket(this); + } + + get epoch(): Uint8Array { + return this.#options.epoch.slice(); + } + + get queuedClientFrameCount(): number { + return this.#frames.length; + } + + deferNextClientWrite(): { resolve(): void; reject(error?: Error): void } { + const completion = createDeferred(); + this.socket.deferNextWrite(completion.promise); + return { + resolve: () => completion.resolve(undefined), + reject: (error = new Error('Synthetic deferred write failure')) => completion.reject(error), + }; + } + + receiveClientBytes(bytes: Uint8Array): void { + const frame = decodeFrame(bytes); + const waiter = this.#waiters.shift(); + if (waiter === undefined) this.#frames.push(frame); + else waiter.resolve(frame); + if (frame.opcode === Opcode.Hello && this.#options.autoWelcome) { + queueMicrotask(() => this.sendWelcome()); + } + } + + notifyClientClose(_code: number, _reason: string): void { + this.#markClosed(); + } + + async nextClientFrame(expectedOpcode?: number): Promise { + const frame = this.#frames.shift() ?? await new Promise((resolve) => { + this.#waiters.push({ resolve }); + }); + if (expectedOpcode !== undefined && frame.opcode !== expectedOpcode) { + throw new Error( + `Expected client opcode 0x${expectedOpcode.toString(16)}, received 0x${frame.opcode.toString(16)}`, + ); + } + return frame; + } + + send(frame: Frame): void { + if (this.#serverClosed) throw new Error('Synthetic relay connection is closed'); + this.#handlers.message(encodeFrame(frame)); + } + + sendWelcome(): void { + this.send({ + opcode: Opcode.Welcome, + payload: [ + 1, + 0, + this.#options.sessionId, + this.#options.epoch, + true, + [[this.#options.coordinatorName, this.#options.generation, 1]], + [262_144, 128, 256, 1_048_576], + this.#options.expiresAtMs, + ], + }); + } + + close(code = 1001, reason = 'synthetic disconnect'): void { + if (this.#serverClosed) return; + this.#markClosed(); + this.#handlers.close(code, reason); + } + + fail(error = new Error('Synthetic relay failure')): void { + if (this.#serverClosed) return; + this.#markClosed(); + this.#handlers.error(error); + } + + #markClosed(): void { + if (this.#serverClosed) return; + this.#serverClosed = true; + this.#onClosed(); + } + + async acknowledgeDeclarations(stateFrame?: Frame): Promise { + const state = stateFrame ?? await this.nextClientFrame(Opcode.StateSync); + if (state.opcode !== Opcode.StateSync) { + throw new Error(`Expected a STATE_SYNC frame, received opcode 0x${state.opcode.toString(16)}`); + } + const stateNames = namesFromEntries(state, 'STATE_SYNC.entries'); + this.send({ + opcode: Opcode.StateSyncOk, + payload: [integer(state.payload[0], 'STATE_SYNC.requestId'), this.epoch, 1, dictionary(stateNames, 101)], + }); + + const stateAccess = await this.nextClientFrame(Opcode.StateAclSync); + this.send({ + opcode: Opcode.StateAclOk, + payload: [integer(stateAccess.payload[0], 'STATE_ACL_SYNC.requestId'), 1], + }); + + const events = await this.nextClientFrame(Opcode.EventSync); + const eventNames = namesFromEntries(events, 'EVENT_SYNC.entries'); + this.send({ + opcode: Opcode.EventSyncOk, + payload: [integer(events.payload[0], 'EVENT_SYNC.requestId'), dictionary(eventNames, 201)], + }); + + const eventAccess = await this.nextClientFrame(Opcode.EventAclSync); + this.send({ + opcode: Opcode.EventAclOk, + payload: [integer(eventAccess.payload[0], 'EVENT_ACL_SYNC.requestId'), 1], + }); + + const functions = await this.nextClientFrame(Opcode.FunctionSync); + this.send({ + opcode: Opcode.FunctionSyncOk, + payload: [ + integer(functions.payload[0], 'FUNCTION_SYNC.requestId'), + dictionary(functionNames(functions), 301), + ], + }); + await flushMicrotasks(); + return { state, stateAccess, events, eventAccess, functions }; + } +} + +export class FakeRelay implements SocketFactory { + readonly #options: Required; + readonly #connections: FakeRelayConnection[] = []; + readonly #connectionWaiters: Array<(connection: FakeRelayConnection) => void> = []; + readonly #connectErrors: Error[] = []; + #openConnections = 0; + #socketHighWater = 0; + + constructor(options: FakeRelayOptions = {}) { + this.#options = { + autoWelcome: options.autoWelcome ?? true, + coordinatorName: options.coordinatorName ?? 'test-coordinator', + epoch: options.epoch?.slice() ?? new Uint8Array(16).fill(7), + expiresAtMs: options.expiresAtMs ?? 2_000_000, + generation: options.generation ?? 4, + sessionId: options.sessionId ?? 41, + }; + } + + get connections(): readonly FakeRelayConnection[] { + return this.#connections; + } + + get connectCount(): number { + return this.#connections.length + this.#connectErrors.length; + } + + get socketHighWater(): number { + return this.#socketHighWater; + } + + get openConnectionCount(): number { + return this.#openConnections; + } + + queueConnectError(error = new Error('Synthetic connection failure')): void { + this.#connectErrors.push(error); + } + + async connect( + _url: string, + handlers: SocketHandlers, + signal: AbortSignal, + ): Promise { + if (signal.aborted) throw signal.reason; + const failure = this.#connectErrors.shift(); + if (failure !== undefined) throw failure; + const connectionIndex = this.#connections.length; + const epoch = this.#options.epoch.slice(); + epoch[0] = ((epoch[0] ?? 0) + connectionIndex) % 256; + const connectionOptions: Required = { + ...this.#options, + epoch, + sessionId: this.#options.sessionId + connectionIndex, + }; + const connection = new FakeRelayConnection(handlers, connectionOptions, () => { + this.#openConnections -= 1; + }); + this.#connections.push(connection); + this.#openConnections += 1; + this.#socketHighWater = Math.max(this.#socketHighWater, this.#openConnections); + signal.addEventListener('abort', () => connection.close(1006, 'aborted'), { once: true }); + const waiter = this.#connectionWaiters.shift(); + waiter?.(connection); + return connection.socket; + } + + latestConnection(): FakeRelayConnection { + const connection = this.#connections[this.#connections.length - 1]; + if (connection === undefined) throw new Error('No synthetic relay connection exists'); + return connection; + } + + async connectionAt(index: number): Promise { + const existing = this.#connections[index]; + if (existing !== undefined) return existing; + return new Promise((resolve) => { + this.#connectionWaiters.push(resolve); + }); + } +} diff --git a/test/fakes/runtime.ts b/test/fakes/runtime.ts new file mode 100644 index 0000000..cec479d --- /dev/null +++ b/test/fakes/runtime.ts @@ -0,0 +1,104 @@ +import type { + CoordinatorRuntime, + RuntimeTimer, + SocketFactory, +} from '../../src/internal/runtime.js'; + +interface ScheduledTimer { + id: number; + dueAtMs: number; + callback: () => void; + cancelled: boolean; +} + +class FakeTimer implements RuntimeTimer { + readonly #timer: ScheduledTimer; + + constructor(timer: ScheduledTimer) { + this.#timer = timer; + } + + cancel(): void { + this.#timer.cancelled = true; + } +} + +export class FakeRuntime implements CoordinatorRuntime { + readonly socketFactory: SocketFactory; + readonly #timers: ScheduledTimer[] = []; + readonly #randomValues: number[] = []; + #currentTimeMs: number; + #nextTimerId = 1; + + constructor(socketFactory: SocketFactory, currentTimeMs = 1_000_000) { + this.socketFactory = socketFactory; + this.#currentTimeMs = currentTimeMs; + } + + now(): number { + return this.#currentTimeMs; + } + + random(): number { + return this.#randomValues.shift() ?? 0; + } + + queueRandom(...values: number[]): void { + for (const value of values) { + if (!Number.isFinite(value) || value < 0 || value >= 1) { + throw new RangeError('Fake random values must be in [0, 1)'); + } + this.#randomValues.push(value); + } + } + + setTimer(callback: () => void, delayMs: number): RuntimeTimer { + const timer: ScheduledTimer = { + id: this.#nextTimerId, + dueAtMs: this.#currentTimeMs + Math.max(0, delayMs), + callback, + cancelled: false, + }; + this.#nextTimerId += 1; + this.#timers.push(timer); + return new FakeTimer(timer); + } + + get pendingTimerCount(): number { + return this.#timers.filter((timer) => !timer.cancelled).length; + } + + async advanceBy(delayMs: number): Promise { + if (!Number.isSafeInteger(delayMs) || delayMs < 0) { + throw new RangeError('Fake time advance must be a non-negative safe integer'); + } + const targetTimeMs = this.#currentTimeMs + delayMs; + while (true) { + const timer = this.#nextDueTimer(targetTimeMs); + if (timer === undefined) break; + timer.cancelled = true; + this.#currentTimeMs = timer.dueAtMs; + timer.callback(); + await flushMicrotasks(); + } + this.#currentTimeMs = targetTimeMs; + await flushMicrotasks(); + } + + #nextDueTimer(targetTimeMs: number): ScheduledTimer | undefined { + let next: ScheduledTimer | undefined; + for (const timer of this.#timers) { + if (timer.cancelled || timer.dueAtMs > targetTimeMs) continue; + if (next === undefined + || timer.dueAtMs < next.dueAtMs + || (timer.dueAtMs === next.dueAtMs && timer.id < next.id)) { + next = timer; + } + } + return next; + } +} + +export async function flushMicrotasks(turns = 12): Promise { + for (let turn = 0; turn < turns; turn += 1) await Promise.resolve(); +} diff --git a/test/helpers.ts b/test/helpers.ts new file mode 100644 index 0000000..0000fff --- /dev/null +++ b/test/helpers.ts @@ -0,0 +1,88 @@ +import type { + AccessTokenRequest, + Coordinator, + CoordinatorConfiguration, + CoordinatorFailure, + CoordinatorLogger, + FunctionHandler, + ReadySession, +} from '../src/api.js'; +import { EventDirection } from '../src/api.js'; +import { createCoordinatorWithRuntime } from '../src/coordinator.js'; +import { Opcode } from '../src/protocol/codec.js'; +import { FakeRelay, type FakeRelayConnection, type FakeRelayOptions } from './fakes/relay.js'; +import { FakeRuntime } from './fakes/runtime.js'; + +export interface TestHarness { + coordinator: Coordinator; + relay: FakeRelay; + runtime: FakeRuntime; + tokenRequests: AccessTokenRequest[]; +} + +export function configuration( + handler: FunctionHandler = (call) => call.arguments, +): CoordinatorConfiguration { + return { + state: { 'home.temperature': 20 }, + stateAccess: [{ userId: 'user-1', patterns: ['home.*'] }], + events: [{ + topic: 'home.alert', + directions: EventDirection.acceptFromUsers | EventDirection.publishToUsers, + }], + eventAccess: [{ + userId: 'user-1', + publish: ['home.alert'], + subscribe: ['home.alert'], + }], + functions: { 'home.echo': handler }, + }; +} + +export function createTestHarness( + relayOptions: FakeRelayOptions = {}, + logger?: CoordinatorLogger, +): TestHarness { + const relay = new FakeRelay(relayOptions); + const runtime = new FakeRuntime(relay); + const tokenRequests: AccessTokenRequest[] = []; + const baseOptions = { + name: relayOptions.coordinatorName ?? 'test-coordinator', + accessTokenProvider: { + async getAccessToken(request: AccessTokenRequest) { + tokenRequests.push(request); + return { + relayUrl: 'wss://relay.test/miakapp/ws', + token: `token-${request.reason}`, + expiresAtMs: runtime.now() + 1_000_000, + }; + }, + }, + }; + const coordinator = createCoordinatorWithRuntime( + logger === undefined ? baseOptions : { ...baseOptions, logger }, + runtime, + ); + return { coordinator, relay, runtime, tokenRequests }; +} + +export async function startReady( + harness: TestHarness, + declarations: CoordinatorConfiguration = configuration(), + connectionIndex = 0, +): Promise<{ connection: FakeRelayConnection; ready: ReadySession }> { + harness.coordinator.configure(declarations); + const started = harness.coordinator.start(); + const connection = await harness.relay.connectionAt(connectionIndex); + await connection.nextClientFrame(Opcode.Hello); + await connection.acknowledgeDeclarations(); + const ready = await started; + return { connection, ready }; +} + +export function isCoordinatorFailure(value: unknown): value is CoordinatorFailure { + return value instanceof Error + && 'kind' in value + && 'outcome' in value + && 'retryable' in value; +} diff --git a/test/lifecycle.test.ts b/test/lifecycle.test.ts new file mode 100644 index 0000000..ff851c3 --- /dev/null +++ b/test/lifecycle.test.ts @@ -0,0 +1,289 @@ +import { describe, expect, test } from 'bun:test'; +import type { CoordinatorFailure, CoordinatorStatus } from '../src/api.js'; +import { Opcode, type Frame, type ProtocolValue } from '../src/protocol/codec.js'; +import { flushMicrotasks } from './fakes/runtime.js'; +import { + configuration, + createTestHarness, + isCoordinatorFailure, + startReady, +} from './helpers.js'; + +function integer(value: ProtocolValue | undefined, label: string): number { + if (typeof value !== 'number' || !Number.isSafeInteger(value)) { + throw new TypeError(`${label} is invalid`); + } + return value; +} + +function requestId(frame: Frame): number { + return integer(frame.payload[0], 'request ID'); +} + +describe('coordinator lifecycle', () => { + test('resolves start only after the ordered five-domain synchronization barrier', async () => { + const harness = createTestHarness(); + harness.coordinator.configure(configuration()); + const statuses: CoordinatorStatus[] = []; + harness.coordinator.subscribe((event) => statuses.push(event.current)); + let startSettled = false; + const started = harness.coordinator.start().finally(() => { + startSettled = true; + }); + const connection = await harness.relay.connectionAt(0); + expect((await connection.nextClientFrame()).opcode).toBe(Opcode.Hello); + + const state = await connection.nextClientFrame(Opcode.StateSync); + expect(startSettled).toBe(false); + connection.send({ + opcode: Opcode.StateSyncOk, + payload: [requestId(state), connection.epoch, 1, [[101, 'home.temperature']]], + }); + const stateAccess = await connection.nextClientFrame(Opcode.StateAclSync); + connection.send({ opcode: Opcode.StateAclOk, payload: [requestId(stateAccess), 1] }); + const events = await connection.nextClientFrame(Opcode.EventSync); + connection.send({ + opcode: Opcode.EventSyncOk, + payload: [requestId(events), [[201, 'home.alert']]], + }); + const eventAccess = await connection.nextClientFrame(Opcode.EventAclSync); + connection.send({ opcode: Opcode.EventAclOk, payload: [requestId(eventAccess), 1] }); + const functions = await connection.nextClientFrame(Opcode.FunctionSync); + + await flushMicrotasks(); + expect(startSettled).toBe(false); + expect(harness.coordinator.status).toBe('synchronizing'); + + connection.send({ + opcode: Opcode.FunctionSyncOk, + payload: [requestId(functions), [[301, 'home.echo']]], + }); + const ready = await started; + expect(ready).toEqual({ sessionId: 41, generation: 4, connectedAtMs: 1_000_000 }); + expect(statuses).toEqual(['connecting', 'authenticating', 'synchronizing', 'ready']); + await harness.coordinator.stop(); + }); + + test('rejects duplicate starts and returns one shared terminal stop promise', async () => { + const harness = createTestHarness(); + harness.coordinator.configure(configuration()); + const started = harness.coordinator.start(); + const duplicateFailure = await harness.coordinator.start().catch((error: unknown) => error); + expect(isCoordinatorFailure(duplicateFailure) && duplicateFailure.kind).toBe('invalid_lifecycle'); + + const connection = await harness.relay.connectionAt(0); + await connection.nextClientFrame(Opcode.Hello); + await connection.acknowledgeDeclarations(); + await started; + + const firstStop = harness.coordinator.stop({ deadlineMs: 10 }); + const secondStop = harness.coordinator.stop({ deadlineMs: 99 }); + expect(firstStop).toBe(secondStop); + await firstStop; + expect(harness.coordinator.status).toBe('stopped'); + expect(harness.runtime.pendingTimerCount).toBe(0); + }); + + test('rejects start after an inert coordinator has already stopped', async () => { + const harness = createTestHarness(); + await harness.coordinator.stop(); + const failure = await harness.coordinator.start().catch((error: unknown) => error); + + expect(isCoordinatorFailure(failure) && failure.kind).toBe('invalid_lifecycle'); + expect(harness.relay.connections).toHaveLength(0); + }); + + test('reconnects with full jitter, reacquires a token, and gates operations until ready', async () => { + const harness = createTestHarness(); + const { connection } = await startReady(harness); + connection.close(); + await flushMicrotasks(); + expect(harness.coordinator.status).toBe('reconnecting'); + + const offlineFailure = await harness.coordinator.state.set([ + { path: 'home.temperature', value: 22 }, + ]).catch((error: unknown) => error); + expect(isCoordinatorFailure(offlineFailure) && offlineFailure.outcome).toBe('not_dispatched'); + + await harness.runtime.advanceBy(0); + const reconnected = await harness.relay.connectionAt(1); + await reconnected.nextClientFrame(Opcode.Hello); + const stateFrame = await reconnected.nextClientFrame(Opcode.StateSync); + expect(harness.coordinator.status).toBe('synchronizing'); + expect(harness.tokenRequests.map((request) => request.reason)).toEqual(['initial', 'reconnect']); + + reconnected.send({ + opcode: Opcode.StateSyncOk, + payload: [requestId(stateFrame), reconnected.epoch, 2, [[101, 'home.temperature']]], + }); + const stateAccess = await reconnected.nextClientFrame(Opcode.StateAclSync); + reconnected.send({ opcode: Opcode.StateAclOk, payload: [requestId(stateAccess), 2] }); + const events = await reconnected.nextClientFrame(Opcode.EventSync); + reconnected.send({ opcode: Opcode.EventSyncOk, payload: [requestId(events), [[201, 'home.alert']]] }); + const eventAccess = await reconnected.nextClientFrame(Opcode.EventAclSync); + reconnected.send({ opcode: Opcode.EventAclOk, payload: [requestId(eventAccess), 2] }); + const functions = await reconnected.nextClientFrame(Opcode.FunctionSync); + reconnected.send({ opcode: Opcode.FunctionSyncOk, payload: [requestId(functions), [[301, 'home.echo']]] }); + await flushMicrotasks(); + + expect(harness.coordinator.status).toBe('ready'); + expect(harness.relay.socketHighWater).toBe(1); + await harness.coordinator.stop(); + }); + + test('does not restore ready when a reconnect synchronization fails permanently', async () => { + const harness = createTestHarness(); + const failures: CoordinatorFailure[] = []; + harness.coordinator.errors.subscribe((failure) => failures.push(failure)); + const { connection } = await startReady(harness); + connection.close(); + await flushMicrotasks(); + await harness.runtime.advanceBy(0); + + const reconnected = await harness.relay.connectionAt(1); + await reconnected.nextClientFrame(Opcode.Hello); + const state = await reconnected.nextClientFrame(Opcode.StateSync); + reconnected.send({ + opcode: Opcode.Error, + payload: [requestId(state), Opcode.StateSync, 1301, false, 'Synthetic collision'], + }); + await flushMicrotasks(); + + expect(harness.coordinator.status).toBe('synchronizing'); + expect(failures.some((failure) => failure.code === 1301)).toBe(true); + await harness.coordinator.stop(); + }); + + test('an aborted start drives bounded shutdown and rejects before readiness', async () => { + const harness = createTestHarness({ autoWelcome: false }); + harness.coordinator.configure(configuration()); + const controller = new AbortController(); + const started = harness.coordinator.start({ signal: controller.signal }); + await harness.relay.connectionAt(0); + controller.abort('test abort'); + const failure = await started.catch((error: unknown) => error); + await harness.coordinator.stop(); + + expect(isCoordinatorFailure(failure) && failure.kind).toBe('cancelled'); + expect(harness.coordinator.status).toBe('stopped'); + expect(harness.runtime.pendingTimerCount).toBe(0); + }); + + test('stops reentrantly from a lifecycle listener without opening a socket', async () => { + const harness = createTestHarness(); + harness.coordinator.configure(configuration()); + harness.coordinator.subscribe((event) => { + if (event.current === 'connecting') void harness.coordinator.stop(); + }); + + const failure = await harness.coordinator.start().catch((error: unknown) => error); + await harness.coordinator.stop(); + expect(isCoordinatorFailure(failure) && failure.kind).toBe('cancelled'); + expect(harness.relay.connections).toHaveLength(0); + expect(harness.coordinator.status).toBe('stopped'); + }); + + test('rejects a WELCOME whose authentication expiry is not in the future', async () => { + const harness = createTestHarness({ autoWelcome: false, expiresAtMs: 1_000_050 }); + const failures: CoordinatorFailure[] = []; + harness.coordinator.errors.subscribe((failure) => failures.push(failure)); + harness.coordinator.configure(configuration()); + const started = harness.coordinator.start(); + void started.catch(() => undefined); + const connection = await harness.relay.connectionAt(0); + await connection.nextClientFrame(Opcode.Hello); + await harness.runtime.advanceBy(100); + connection.sendWelcome(); + await flushMicrotasks(); + + expect(failures.some((failure) => failure.kind === 'protocol')).toBe(true); + expect(harness.coordinator.status).toBe('reconnecting'); + await harness.coordinator.stop(); + }); + + test('rejects a stale session epoch in STATE_SYNC_OK', async () => { + const harness = createTestHarness(); + const failures: CoordinatorFailure[] = []; + harness.coordinator.errors.subscribe((failure) => failures.push(failure)); + harness.coordinator.configure(configuration()); + const started = harness.coordinator.start(); + void started.catch(() => undefined); + const connection = await harness.relay.connectionAt(0); + await connection.nextClientFrame(Opcode.Hello); + const state = await connection.nextClientFrame(Opcode.StateSync); + connection.send({ + opcode: Opcode.StateSyncOk, + payload: [requestId(state), new Uint8Array(16).fill(99), 1, [[101, 'home.temperature']]], + }); + await flushMicrotasks(); + + expect(failures.some((failure) => failure.kind === 'protocol')).toBe(true); + expect(harness.coordinator.status).toBe('reconnecting'); + await harness.coordinator.stop(); + }); + + test('rejects a REAUTH_OK whose expiry has already elapsed', async () => { + const harness = createTestHarness(); + const failures: CoordinatorFailure[] = []; + harness.coordinator.errors.subscribe((failure) => failures.push(failure)); + const { connection } = await startReady(harness); + await harness.runtime.advanceBy(970_000); + const reauth = await connection.nextClientFrame(Opcode.Reauth); + connection.send({ + opcode: Opcode.ReauthOk, + payload: [requestId(reauth), 1_970_000], + }); + await flushMicrotasks(); + + expect(failures.some((failure) => failure.kind === 'protocol')).toBe(true); + expect(harness.coordinator.status).toBe('reconnecting'); + await harness.coordinator.stop(); + }); + + test('settles a non-retryable FATAL received before WELCOME', async () => { + const harness = createTestHarness({ autoWelcome: false }); + const failures: CoordinatorFailure[] = []; + harness.coordinator.errors.subscribe((failure) => failures.push(failure)); + harness.coordinator.configure(configuration()); + const started = harness.coordinator.start(); + const connection = await harness.relay.connectionAt(0); + await connection.nextClientFrame(Opcode.Hello); + connection.send({ + opcode: Opcode.Fatal, + payload: [Opcode.Hello, 1100, false, 'Authentication rejected'], + }); + const failure = await started.catch((error: unknown) => error); + await harness.coordinator.stop(); + + expect(isCoordinatorFailure(failure) && failure.code).toBe(1100); + expect(failures.filter((entry) => entry.code === 1100)).toHaveLength(1); + expect(harness.coordinator.status).toBe('stopped'); + }); + + test('drains after GOAWAY and applies retryAfter only after relay close', async () => { + const harness = createTestHarness(); + const { connection } = await startReady(harness); + connection.send({ opcode: Opcode.Goaway, payload: [50, 0] }); + await flushMicrotasks(); + expect(harness.coordinator.status).toBe('draining'); + expect(harness.relay.openConnectionCount).toBe(1); + + const unavailableFailure = await harness.coordinator.state.set([ + { path: 'home.temperature', value: 22 }, + ]).catch((error: unknown) => error); + expect(isCoordinatorFailure(unavailableFailure) && unavailableFailure.outcome) + .toBe('not_dispatched'); + await harness.runtime.advanceBy(970_000); + expect(connection.queuedClientFrameCount).toBe(0); + connection.close(); + await flushMicrotasks(); + expect(harness.coordinator.status).toBe('reconnecting'); + + await harness.runtime.advanceBy(49); + expect(harness.relay.connections).toHaveLength(1); + await harness.runtime.advanceBy(1); + const reconnected = await harness.relay.connectionAt(1); + expect((await reconnected.nextClientFrame()).opcode).toBe(Opcode.Hello); + await harness.coordinator.stop(); + }); +}); diff --git a/test/node-smoke.mjs b/test/node-smoke.mjs new file mode 100644 index 0000000..60a1ade --- /dev/null +++ b/test/node-smoke.mjs @@ -0,0 +1,40 @@ +import assert from 'node:assert/strict'; +import { + ApplicationCallError, + EventDirection, + createCoordinator, +} from '../dist/index.js'; + +assert.equal(typeof createCoordinator, 'function'); +assert.equal(EventDirection.publishToUsers, 0x02); +assert.equal(new ApplicationCallError(2000, 'Expected').code, 2000); + +const coordinator = createCoordinator({ + name: 'node-smoke', + accessTokenProvider: { + async getAccessToken() { + throw new Error('The inert smoke test must not request a token'); + }, + }, +}); + +assert.equal(coordinator.status, 'idle'); +coordinator.configure({ + state: {}, + stateAccess: [], + events: [], + eventAccess: [], + functions: {}, +}); +assert.equal(coordinator.status, 'idle'); +const offlineCall = coordinator.calls.start({ + function: 'smoke.missing', + arguments: null, + timeoutMs: 1_000, +}); +await offlineCall.result.catch(() => undefined); +await new Promise((resolve) => setImmediate(resolve)); +await coordinator.stop(); +assert.equal(coordinator.status, 'stopped'); + +console.log(JSON.stringify({ package: 'miakapi', status: 'ok' })); diff --git a/test/protocol.test.ts b/test/protocol.test.ts new file mode 100644 index 0000000..c87d901 --- /dev/null +++ b/test/protocol.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, test } from 'bun:test'; +import { + decodeFrame, + encodeFrame, + Opcode, + ProtocolError, +} from '../src/protocol/codec.js'; +import { CoordinatorProtocolSession, ProtocolSessionError } from '../src/protocol/session.js'; + +describe('protocol integration', () => { + test('encodes maps canonically and round-trips binary values', () => { + const first = encodeFrame({ + opcode: 0x80, + payload: [{ zebra: 1, alpha: new Uint8Array([4, 5]) }], + }); + const second = encodeFrame({ + opcode: 0x80, + payload: [{ alpha: new Uint8Array([4, 5]), zebra: 1 }], + }); + expect(first).toEqual(second); + expect(decodeFrame(first)).toEqual({ + opcode: 0x80, + payload: [{ alpha: new Uint8Array([4, 5]), zebra: 1 }], + }); + }); + + test('rejects a non-canonical map before decoding it', () => { + const nonCanonical = new Uint8Array([ + 0x80, + 0x91, + 0x82, + 0xa1, 0x62, 0x01, + 0xa1, 0x61, 0x02, + ]); + expect(() => decodeFrame(nonCanonical)).toThrow(ProtocolError); + try { + decodeFrame(nonCanonical); + } catch (error) { + expect(error instanceof ProtocolError && error.kind).toBe('non_canonical'); + } + }); + + test('enforces the coordinator session handshake and traffic directions', () => { + const session = new CoordinatorProtocolSession(); + expect(() => session.encode({ opcode: Opcode.StateSync, payload: [1, []] })) + .toThrow(ProtocolSessionError); + + session.encode({ opcode: Opcode.Hello, payload: [1, 0, 0, 2, 'token', ['coordinator']] }); + expect(session.phase).toBe('awaiting_welcome'); + expect(() => session.decode(encodeFrame({ opcode: Opcode.StateSyncOk, payload: [1, new Uint8Array(16), 1, []] }))) + .toThrow(ProtocolSessionError); + }); + + test('distinguishes the two EVENT wire shapes by direction', () => { + const outgoing = new CoordinatorProtocolSession(); + outgoing.encode({ opcode: Opcode.Hello, payload: [1, 0, 0, 2, 'token', ['coordinator']] }); + outgoing.decode(encodeFrame({ + opcode: Opcode.Welcome, + payload: [1, 0, 1, new Uint8Array(16), true, [['coordinator', 1, 1]], [1, 1, 1, 1], 1], + })); + expect(() => outgoing.encode({ + opcode: Opcode.Event, + payload: [1, 1, 0, null, [1, 'user', 1, null, null], true], + })).toThrow(ProtocolSessionError); + expect(() => outgoing.decode(encodeFrame({ + opcode: Opcode.Event, + payload: [1, 1, 0, null, true], + }))).toThrow(ProtocolSessionError); + }); +}); diff --git a/test/public-api.test.ts b/test/public-api.test.ts new file mode 100644 index 0000000..3513900 --- /dev/null +++ b/test/public-api.test.ts @@ -0,0 +1,179 @@ +import { describe, expect, test } from 'bun:test'; +import { + ApplicationCallError, + EventDirection, +} from '../src/api.js'; +import * as entrypoint from '../src/index.js'; +import { + validateCoordinatorOptions, + validateFunctions, + validateProtocolValue, + validateStateAccess, + validateStateEntries, + validateStateMutations, +} from '../src/internal/validation.js'; +import { configuration, createTestHarness, isCoordinatorFailure } from './helpers.js'; + +describe('public API', () => { + test('exports the canonical surface and the coordinator factory', () => { + expect(typeof entrypoint.createCoordinator).toBe('function'); + expect(entrypoint.ApplicationCallError).toBe(ApplicationCallError); + expect(entrypoint.EventDirection).toBe(EventDirection); + }); + + test('construction and configuration are inert', () => { + const harness = createTestHarness(); + expect(harness.coordinator.status).toBe('idle'); + expect(harness.relay.connections).toHaveLength(0); + expect(harness.runtime.pendingTimerCount).toBe(0); + + harness.coordinator.configure(configuration()); + expect(harness.coordinator.status).toBe('idle'); + expect(harness.relay.connections).toHaveLength(0); + expect(harness.runtime.pendingTimerCount).toBe(0); + }); + + test('rejects invalid and open coordinator option shapes', () => { + expect(() => validateCoordinatorOptions({ + name: 'valid', + accessTokenProvider: { async getAccessToken() {} }, + secret: 'must-not-be-accepted', + })).toThrow(/invalid shape/); + expect(() => validateCoordinatorOptions({ + name: '../invalid', + accessTokenProvider: { async getAccessToken() {} }, + })).toThrow(/invalid/); + }); + + test('accepts class-based token providers and loggers', () => { + class TokenProvider { + async getAccessToken() { + return { + relayUrl: 'wss://relay.test/miakapp/ws', + token: 'token', + expiresAtMs: 2_000_000, + }; + } + } + class Logger { + write(): void {} + } + + const provider = new TokenProvider(); + const logger = new Logger(); + const options = validateCoordinatorOptions({ + name: 'class-based', + accessTokenProvider: provider, + logger, + }); + expect(options.accessTokenProvider).toBe(provider); + expect(options.logger).toBe(logger); + }); + + test('ApplicationCallError enforces the application code and safe UTF-8 message ranges', () => { + const error = new ApplicationCallError(2_000, 'Device rejected the command', true); + expect(error.code).toBe(2_000); + expect(error.retryable).toBe(true); + expect(() => new ApplicationCallError(1_999)).toThrow(RangeError); + expect(() => new ApplicationCallError(3_000)).toThrow(RangeError); + expect(() => new ApplicationCallError(2_000, '\ud800')).toThrow(TypeError); + expect(() => new ApplicationCallError(2_000, 'line\nbreak')).toThrow(TypeError); + }); + + test('protocol values are defensively cloned, frozen, and cycle checked', () => { + const binary = new Uint8Array([1, 2, 3]); + const sourceNested: unknown[] = [binary, { ok: true }]; + const source: Record = { nested: sourceNested }; + const validated = validateProtocolValue(source); + binary[0] = 9; + sourceNested.push('late mutation'); + + expect(Object.isFrozen(validated)).toBe(true); + if (validated === null + || validated instanceof Uint8Array + || Array.isArray(validated) + || typeof validated !== 'object') { + throw new Error('Expected a validated protocol object'); + } + const nested = validated.nested; + if (!Array.isArray(nested) || !(nested[0] instanceof Uint8Array)) { + throw new Error('Expected the cloned nested binary value'); + } + expect([...nested[0]]).toEqual([1, 2, 3]); + expect(nested).toHaveLength(2); + + const cyclic: Record = {}; + cyclic.self = cyclic; + expect(() => validateProtocolValue(cyclic)).toThrow(/cycle/); + }); + + test('preserves own prototype-named declarations without prototype mutation', () => { + const state: Record = Object.create(null); + Object.defineProperty(state, '__proto__', { + configurable: true, + enumerable: true, + value: 42, + writable: true, + }); + const functions: Record = Object.create(null); + const handler = () => 42; + Object.defineProperty(functions, '__proto__', { + configurable: true, + enumerable: true, + value: handler, + writable: true, + }); + + const validatedState = validateStateEntries(state); + const validatedFunctions = validateFunctions(functions); + expect(Object.getPrototypeOf(validatedState)).toBeNull(); + expect(Object.getPrototypeOf(validatedFunctions)).toBeNull(); + expect(Object.hasOwn(validatedState, '__proto__')).toBe(true); + expect(validatedState.__proto__).toBe(42); + expect(validatedFunctions.__proto__).toBe(handler); + }); + + test('bounds aggregate state value bytes before transport encoding', () => { + const first = new Uint8Array(131_072); + const second = new Uint8Array(131_072); + expect(() => validateStateEntries({ first, second })).toThrow(/aggregate value byte limit/); + expect(() => validateStateMutations([ + { path: 'first', value: first }, + { path: 'second', value: second }, + ])).toThrow(/aggregate value byte limit/); + }); + + test('bounds aggregate ACL bytes before constructing a declaration frame', () => { + const patterns = Array.from({ length: 1_024 }, (_, index) => { + const prefix = `root.${index}.`; + return `${prefix}${'x'.repeat(256 - prefix.length)}`; + }); + expect(() => validateStateAccess([ + { userId: 'user-1', patterns }, + { userId: 'user-2', patterns }, + ])).toThrow(/aggregate value byte limit/); + }); + + test('offline operations fail closed without creating transport resources', async () => { + const harness = createTestHarness(); + const stateFailure = await harness.coordinator.state.set([ + { path: 'home.temperature', value: 21 }, + ]).catch((error: unknown) => error); + const eventFailure = await harness.coordinator.events.publish('home.alert', true).sent + .catch((error: unknown) => error); + const call = harness.coordinator.calls.start({ + function: 'home.echo', + arguments: null, + timeoutMs: 1_000, + }); + const acceptedFailure = await call.accepted.catch((error: unknown) => error); + const resultFailure = await call.result.catch((error: unknown) => error); + + expect(isCoordinatorFailure(stateFailure) && stateFailure.outcome).toBe('not_dispatched'); + expect(isCoordinatorFailure(eventFailure) && eventFailure.outcome).toBe('not_dispatched'); + expect(isCoordinatorFailure(acceptedFailure) && acceptedFailure.outcome).toBe('not_dispatched'); + expect(isCoordinatorFailure(resultFailure) && resultFailure.outcome).toBe('not_dispatched'); + expect(harness.relay.connections).toHaveLength(0); + expect(harness.runtime.pendingTimerCount).toBe(0); + }); +}); diff --git a/test/resources-security.test.ts b/test/resources-security.test.ts new file mode 100644 index 0000000..0682d27 --- /dev/null +++ b/test/resources-security.test.ts @@ -0,0 +1,292 @@ +import { describe, expect, test } from 'bun:test'; +import type { + AccessToken, + CoordinatorFailure, + CoordinatorLogRecord, +} from '../src/api.js'; +import { createCoordinatorWithRuntime } from '../src/coordinator.js'; +import { + validateDeclarationOptions, + validateEventPublishOptions, + validateOperationOptions, + validateProtocolValue, + validateStartOptions, + validateStopOptions, +} from '../src/internal/validation.js'; +import { LIMITS, Opcode, type ProtocolValue } from '../src/protocol/codec.js'; +import { FakeRelay } from './fakes/relay.js'; +import { FakeRuntime, flushMicrotasks } from './fakes/runtime.js'; +import { + configuration, + createTestHarness, + isCoordinatorFailure, + startReady, +} from './helpers.js'; + +function integer(value: ProtocolValue | undefined, label: string): number { + if (typeof value !== 'number' || !Number.isSafeInteger(value)) { + throw new TypeError(`${label} is invalid`); + } + return value; +} + +describe('resource ownership', () => { + test('bounds stop even when an access-token provider ignores cancellation', async () => { + const relay = new FakeRelay(); + const runtime = new FakeRuntime(relay); + let providerSignal: AbortSignal | undefined; + const never = new Promise(() => undefined); + const coordinator = createCoordinatorWithRuntime({ + name: 'test-coordinator', + accessTokenProvider: { + getAccessToken(request) { + providerSignal = request.signal; + return never; + }, + }, + }, runtime); + const started = coordinator.start(); + void started.catch(() => undefined); + await flushMicrotasks(); + expect(providerSignal?.aborted).toBe(false); + + let stopSettled = false; + const stopped = coordinator.stop({ deadlineMs: 50 }).finally(() => { + stopSettled = true; + }); + expect(providerSignal?.aborted).toBe(true); + await runtime.advanceBy(49); + expect(stopSettled).toBe(false); + await runtime.advanceBy(1); + await stopped; + expect(stopSettled).toBe(true); + expect(coordinator.status).toBe('stopped'); + expect(relay.connections).toHaveLength(0); + }); + + test('uses deterministic full-jitter backoff without parallel sockets', async () => { + const harness = createTestHarness(); + harness.relay.queueConnectError(); + harness.runtime.queueRandom(0.5); + harness.coordinator.configure(configuration()); + const started = harness.coordinator.start(); + await flushMicrotasks(); + expect(harness.coordinator.status).toBe('reconnecting'); + + await harness.runtime.advanceBy(499); + expect(harness.relay.connections).toHaveLength(0); + await harness.runtime.advanceBy(1); + const connection = await harness.relay.connectionAt(0); + await connection.nextClientFrame(Opcode.Hello); + await connection.acknowledgeDeclarations(); + await started; + + expect(harness.tokenRequests.map((request) => request.reason)).toEqual(['initial', 'reconnect']); + expect(harness.relay.socketHighWater).toBe(1); + await harness.coordinator.stop(); + }); + + test('coalesces scheduled reauthentication onto the active socket', async () => { + const harness = createTestHarness(); + const { connection } = await startReady(harness); + await harness.runtime.advanceBy(969_999); + expect(connection.queuedClientFrameCount).toBe(0); + await harness.runtime.advanceBy(1); + const reauth = await connection.nextClientFrame(Opcode.Reauth); + expect(harness.tokenRequests.map((request) => request.reason)).toEqual(['initial', 'reauth']); + connection.send({ + opcode: Opcode.ReauthOk, + payload: [integer(reauth.payload[0], 'REAUTH.requestId'), harness.runtime.now() + 1_000_000], + }); + await flushMicrotasks(); + + expect(harness.relay.connections).toHaveLength(1); + expect(harness.relay.socketHighWater).toBe(1); + await harness.coordinator.stop(); + }); + + test('does not let a stale reauthentication tear down a replacement session', async () => { + const relay = new FakeRelay(); + const runtime = new FakeRuntime(relay); + let releaseStale: ((token: AccessToken) => void) | undefined; + const coordinator = createCoordinatorWithRuntime({ + name: 'test-coordinator', + accessTokenProvider: { + getAccessToken(request) { + if (request.reason === 'reauth') { + return new Promise((resolve) => { + releaseStale = resolve; + }); + } + return Promise.resolve({ + relayUrl: 'wss://relay.test/miakapp/ws', + token: `token-${request.reason}`, + expiresAtMs: runtime.now() + 1_000_000, + }); + }, + }, + }, runtime); + coordinator.configure(configuration()); + const started = coordinator.start(); + const first = await relay.connectionAt(0); + await first.nextClientFrame(Opcode.Hello); + await first.acknowledgeDeclarations(); + await started; + + await runtime.advanceBy(970_000); + expect(releaseStale).toBeDefined(); + first.close(); + await flushMicrotasks(); + await runtime.advanceBy(0); + const replacement = await relay.connectionAt(1); + await replacement.nextClientFrame(Opcode.Hello); + await replacement.acknowledgeDeclarations(); + expect(coordinator.status).toBe('ready'); + + releaseStale?.({ + relayUrl: 'wss://relay.test/miakapp/ws', + token: 'stale-reauth-token', + expiresAtMs: runtime.now() + 1_000_000, + }); + await flushMicrotasks(30); + expect(coordinator.status).toBe('ready'); + expect(relay.openConnectionCount).toBe(1); + expect(replacement.queuedClientFrameCount).toBe(0); + await coordinator.stop(); + }); + + test('caps frames buffered between WELCOME and SDK activation', async () => { + const harness = createTestHarness({ autoWelcome: false }); + const failures: CoordinatorFailure[] = []; + harness.coordinator.errors.subscribe((failure) => failures.push(failure)); + harness.coordinator.configure(configuration()); + const started = harness.coordinator.start(); + void started.catch(() => undefined); + const connection = await harness.relay.connectionAt(0); + await connection.nextClientFrame(Opcode.Hello); + connection.sendWelcome(); + for (let index = 0; index <= 256; index += 1) { + connection.send({ opcode: Opcode.PresenceSnapshot, payload: [[]] }); + } + await flushMicrotasks(); + + expect(failures.some((failure) => failure.kind === 'protocol')).toBe(true); + await harness.coordinator.stop(); + }); + + test('caps aggregate bytes buffered between WELCOME and SDK activation', async () => { + const harness = createTestHarness({ autoWelcome: false }); + const failures: CoordinatorFailure[] = []; + harness.coordinator.errors.subscribe((failure) => failures.push(failure)); + harness.coordinator.configure(configuration()); + const started = harness.coordinator.start(); + void started.catch(() => undefined); + const connection = await harness.relay.connectionAt(0); + await connection.nextClientFrame(Opcode.Hello); + connection.sendWelcome(); + for (let index = 1; index <= 8; index += 1) { + connection.send({ + opcode: Opcode.CallDispatch, + payload: [ + index, + [1, 'user-1', 71, null, null], + 0, + null, + 301, + 5_000, + null, + 0, + new Uint8Array(131_072), + ], + }); + } + await flushMicrotasks(); + + expect(failures.filter((failure) => failure.kind === 'protocol')).toHaveLength(1); + await harness.coordinator.stop(); + }); + + test('reconstructs late event correlation without retaining every sent event', async () => { + const harness = createTestHarness(); + const failures: CoordinatorFailure[] = []; + harness.coordinator.errors.subscribe((failure) => failures.push(failure)); + const { connection } = await startReady(harness); + let firstLocalId = ''; + let firstWireId = 0; + for (let index = 0; index < 1_100; index += 1) { + const handle = harness.coordinator.events.publish('home.alert', index); + const frame = await connection.nextClientFrame(Opcode.Event); + if (index === 0) { + firstLocalId = handle.localId; + firstWireId = integer(frame.payload[0], 'EVENT.eventId'); + } + await handle.sent; + } + connection.send({ + opcode: Opcode.Error, + payload: [firstWireId, Opcode.Event, 1201, false, 'Delayed rejection'], + }); + await flushMicrotasks(); + + expect(failures.at(-1)?.correlation).toEqual({ kind: 'event', localId: firstLocalId }); + await harness.coordinator.stop(); + }); +}); + +describe('dynamic-boundary validation and redaction', () => { + test('rejects open option objects at every operation boundary', () => { + expect(() => validateStartOptions({ signal: undefined, extra: true })).toThrow(/invalid shape/); + expect(() => validateStopOptions({ deadlineMs: 1, extra: true })).toThrow(/invalid shape/); + expect(() => validateDeclarationOptions({ extra: true }, 'declaration')).toThrow(/invalid shape/); + expect(() => validateOperationOptions({ extra: true }, 'operation')).toThrow(/invalid shape/); + expect(() => validateEventPublishOptions({ target: { kind: 'default' }, extra: true })) + .toThrow(/invalid shape/); + }); + + test('rejects sparse, over-deep, symbolic, and reserved protocol structures', () => { + const sparse: unknown[] = []; + sparse.length = 1; + expect(() => validateProtocolValue(sparse)).toThrow(/sparse/); + + let deep: unknown = null; + for (let depth = 0; depth <= LIMITS.depth; depth += 1) deep = [deep]; + expect(() => validateProtocolValue(deep)).toThrow(/depth/); + + const symbolic: Record = { safe: true }; + symbolic[Symbol('hidden')] = 'hidden'; + expect(() => validateProtocolValue(symbolic)).toThrow(/symbolic|non-enumerable/); + expect(() => validateProtocolValue({ constructor: 'forbidden' })).toThrow(/reserved/); + }); + + test('never exposes access material through logs or public failures', async () => { + const logs: CoordinatorLogRecord[] = []; + const relay = new FakeRelay(); + const runtime = new FakeRuntime(relay); + const coordinator = createCoordinatorWithRuntime({ + name: 'test-coordinator', + logger: { write: (record) => logs.push(record) }, + accessTokenProvider: { + async getAccessToken() { + return { + relayUrl: 'wss://relay.test/miakapp/ws', + token: 'super-secret-access-token', + expiresAtMs: runtime.now(), + }; + }, + }, + }, runtime); + let failure: unknown; + coordinator.errors.subscribe((observed) => { + failure = observed; + }); + const started = coordinator.start(); + void started.catch(() => undefined); + await flushMicrotasks(); + + const serialized = JSON.stringify({ logs, failure }); + expect(serialized).not.toContain('super-secret-access-token'); + expect(isCoordinatorFailure(failure) && failure.kind).toBe('unavailable'); + await coordinator.stop({ deadlineMs: 0 }); + await runtime.advanceBy(0); + }); +}); diff --git a/test/socket.test.ts b/test/socket.test.ts new file mode 100644 index 0000000..6f07fc5 --- /dev/null +++ b/test/socket.test.ts @@ -0,0 +1,188 @@ +import { describe, expect, test } from 'bun:test'; +import WebSocket, { WebSocketServer, type RawData } from 'ws'; +import type { SocketHandlers } from '../src/internal/runtime.js'; +import { createProductionRuntime, WsSocketFactory } from '../src/internal/socket.js'; +import { LIMITS } from '../src/protocol/codec.js'; + +function bytes(data: RawData): Uint8Array { + if (data instanceof ArrayBuffer) return new Uint8Array(data); + if (Array.isArray(data)) return new Uint8Array(Buffer.concat(data)); + return new Uint8Array(data.buffer, data.byteOffset, data.byteLength); +} + +async function listeningPort(server: WebSocketServer): Promise { + await new Promise((resolve, reject) => { + server.once('listening', resolve); + server.once('error', reject); + }); + const address = server.address(); + if (address === null || typeof address === 'string') { + throw new Error('WebSocket test server has no TCP address'); + } + return address.port; +} + +async function closeServer(server: WebSocketServer): Promise { + for (const client of server.clients) client.terminate(); + server.close(); + await new Promise((resolve) => setTimeout(resolve, 0)); +} + +describe('production WebSocket adapter', () => { + test('rejects an already aborted connection attempt without opening a socket', async () => { + const controller = new AbortController(); + const reason = new Error('cancelled before connect'); + controller.abort(reason); + const failure = await new WsSocketFactory().connect( + 'ws://127.0.0.1:1', + { message() {}, close() {}, error() {} }, + controller.signal, + ).catch((error: unknown) => error); + + expect(failure).toBe(reason); + }); + + test('rejects a failed WebSocket handshake', async () => { + const server = new WebSocketServer({ + host: '127.0.0.1', + port: 0, + verifyClient: (_info, complete) => complete(false, 401, 'Denied'), + }); + const port = await listeningPort(server); + const failure = await new WsSocketFactory().connect( + `ws://127.0.0.1:${port}`, + { message() {}, close() {}, error() {} }, + new AbortController().signal, + ).catch((error: unknown) => error); + + expect(failure).toBeInstanceOf(Error); + await closeServer(server); + }); + + test('carries binary frames in both directions', async () => { + const server = new WebSocketServer({ host: '127.0.0.1', port: 0, perMessageDeflate: false }); + const port = await listeningPort(server); + const receivedByServer = new Promise((resolve, reject) => { + server.once('connection', (socket) => { + socket.once('message', (data, isBinary) => { + if (!isBinary) reject(new Error('Client sent a text frame')); + else resolve(bytes(data)); + }); + socket.send(new Uint8Array([4, 5, 6]), { binary: true }); + }); + }); + let resolveInbound: ((value: Uint8Array) => void) | undefined; + const inbound = new Promise((resolve) => { + resolveInbound = resolve; + }); + const failures: Error[] = []; + const handlers: SocketHandlers = { + message: (value) => resolveInbound?.(value), + close() {}, + error: (error) => failures.push(error), + }; + const controller = new AbortController(); + const socket = await new WsSocketFactory().connect( + `ws://127.0.0.1:${port}`, + handlers, + controller.signal, + ); + + expect([...await inbound]).toEqual([4, 5, 6]); + await socket.write(new Uint8Array([1, 2, 3])); + expect([...await receivedByServer]).toEqual([1, 2, 3]); + expect(failures).toEqual([]); + + socket.terminate(); + socket.detach(); + await closeServer(server); + }); + + test('rejects text relay messages and terminates the connection', async () => { + const server = new WebSocketServer({ host: '127.0.0.1', port: 0, perMessageDeflate: false }); + const port = await listeningPort(server); + server.once('connection', (socket) => socket.send('not binary')); + let resolveFailure: ((error: Error) => void) | undefined; + const failure = new Promise((resolve) => { + resolveFailure = resolve; + }); + const socket = await new WsSocketFactory().connect( + `ws://127.0.0.1:${port}`, + { + message() {}, + close() {}, + error: (error) => resolveFailure?.(error), + }, + new AbortController().signal, + ); + + expect((await failure).message).toMatch(/non-binary/); + socket.detach(); + await closeServer(server); + }); + + test('detaches callbacks while retaining safe late socket handling', async () => { + const server = new WebSocketServer({ host: '127.0.0.1', port: 0, perMessageDeflate: false }); + const port = await listeningPort(server); + let peer: WebSocket | undefined; + server.once('connection', (socket) => { + peer = socket; + }); + const callbacks: string[] = []; + const socket = await new WsSocketFactory().connect( + `ws://127.0.0.1:${port}`, + { + message: () => callbacks.push('message'), + close: () => callbacks.push('close'), + error: () => callbacks.push('error'), + }, + new AbortController().signal, + ); + socket.detach(); + peer?.terminate(); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(callbacks).toEqual([]); + await closeServer(server); + }); + + test('bounds writes and terminates an active socket on abort', async () => { + const server = new WebSocketServer({ host: '127.0.0.1', port: 0, perMessageDeflate: false }); + const port = await listeningPort(server); + let resolveClose: (() => void) | undefined; + const closed = new Promise((resolve) => { + resolveClose = resolve; + }); + const controller = new AbortController(); + const socket = await new WsSocketFactory().connect( + `ws://127.0.0.1:${port}`, + { + message() {}, + close: () => resolveClose?.(), + error() {}, + }, + controller.signal, + ); + + await expect(socket.write(new Uint8Array(LIMITS.frameBytes + 1))).rejects.toThrow(/queue limit/); + controller.abort(new Error('test abort')); + await closed; + socket.detach(); + await closeServer(server); + }); + + test('provides cancellable production timers', async () => { + const runtime = createProductionRuntime(); + let cancelledTimerRan = false; + const cancelled = runtime.setTimer(() => { + cancelledTimerRan = true; + }, 0); + cancelled.cancel(); + await new Promise((resolve) => runtime.setTimer(resolve, 0)); + + expect(cancelledTimerRan).toBe(false); + expect(Number.isFinite(runtime.now())).toBe(true); + expect(runtime.random()).toBeGreaterThanOrEqual(0); + expect(runtime.random()).toBeLessThan(1); + }); +}); diff --git a/test/state-events.test.ts b/test/state-events.test.ts new file mode 100644 index 0000000..1c036ad --- /dev/null +++ b/test/state-events.test.ts @@ -0,0 +1,250 @@ +import { describe, expect, test } from 'bun:test'; +import type { + CoordinatorFailure, + CoordinatorLogRecord, + IncomingEvent, +} from '../src/api.js'; +import { Opcode, type ProtocolValue } from '../src/protocol/codec.js'; +import { flushMicrotasks } from './fakes/runtime.js'; +import { createTestHarness, isCoordinatorFailure, startReady } from './helpers.js'; + +function integer(value: ProtocolValue | undefined, label: string): number { + if (typeof value !== 'number' || !Number.isSafeInteger(value)) { + throw new TypeError(`${label} is invalid`); + } + return value; +} + +describe('state operations', () => { + test('maps active paths, binds the session epoch, and settles only on STATE_SET_OK', async () => { + const harness = createTestHarness(); + const { connection } = await startReady(harness); + let settled = false; + const operation = harness.coordinator.state.set([ + { path: 'home.temperature', value: 22 }, + ]).finally(() => { + settled = true; + }); + const frame = await connection.nextClientFrame(Opcode.StateSet); + + expect(frame.payload[1]).toEqual(connection.epoch); + expect(frame.payload[2]).toEqual([[101, 0, 22]]); + expect(settled).toBe(false); + connection.send({ + opcode: Opcode.StateSetOk, + payload: [integer(frame.payload[0], 'STATE_SET.requestId'), connection.epoch, 2], + }); + await expect(operation).resolves.toEqual({ outcome: 'applied' }); + await harness.coordinator.stop(); + }); + + test('uses an explicit relay rejection as proof that a mutation was not dispatched', async () => { + const harness = createTestHarness(); + const { connection } = await startReady(harness); + const operation = harness.coordinator.state.set([ + { path: 'home.temperature', value: 23 }, + ]); + const frame = await connection.nextClientFrame(Opcode.StateSet); + connection.send({ + opcode: Opcode.Error, + payload: [integer(frame.payload[0], 'STATE_SET.requestId'), Opcode.StateSet, 1201, false, 'Denied'], + }); + const failure = await operation.catch((error: unknown) => error); + + expect(isCoordinatorFailure(failure) && failure.code).toBe(1201); + expect(isCoordinatorFailure(failure) && failure.outcome).toBe('not_dispatched'); + await harness.coordinator.stop(); + }); + + test('uses outcome_unknown after a handed-off mutation loses its connection and never retries it', async () => { + const harness = createTestHarness(); + const { connection } = await startReady(harness); + const operation = harness.coordinator.state.set([ + { path: 'home.temperature', value: 24 }, + ]); + await connection.nextClientFrame(Opcode.StateSet); + connection.close(); + const failure = await operation.catch((error: unknown) => error); + await flushMicrotasks(); + + expect(isCoordinatorFailure(failure) && failure.outcome).toBe('outcome_unknown'); + expect(connection.queuedClientFrameCount).toBe(0); + await harness.coordinator.stop(); + }); + + test('keeps post-handoff cancellation correlation until the relay terminal arrives', async () => { + const harness = createTestHarness(); + const observed: CoordinatorFailure[] = []; + harness.coordinator.errors.subscribe((failure) => observed.push(failure)); + const { connection } = await startReady(harness); + const controller = new AbortController(); + const operation = harness.coordinator.state.set([ + { path: 'home.temperature', value: 24 }, + ], { signal: controller.signal }); + const frame = await connection.nextClientFrame(Opcode.StateSet); + controller.abort(); + const failure = await operation.catch((error: unknown) => error); + expect(isCoordinatorFailure(failure) && failure.outcome).toBe('outcome_unknown'); + + connection.send({ + opcode: Opcode.StateSetOk, + payload: [integer(frame.payload[0], 'STATE_SET.requestId'), connection.epoch, 3], + }); + await flushMicrotasks(); + expect(observed.some((entry) => entry.kind === 'protocol')).toBe(false); + expect(harness.coordinator.status).toBe('ready'); + await harness.coordinator.stop(); + }); + + test('ignores an old write callback after the request ID is reused on reconnect', async () => { + const harness = createTestHarness(); + const { connection } = await startReady(harness); + const staleWrite = connection.deferNextClientWrite(); + const staleOperation = harness.coordinator.state.set([ + { path: 'home.temperature', value: 24 }, + ]); + const staleFrame = await connection.nextClientFrame(Opcode.StateSet); + connection.close(); + const staleFailure = await staleOperation.catch((error: unknown) => error); + expect(isCoordinatorFailure(staleFailure) && staleFailure.outcome).toBe('outcome_unknown'); + + await harness.runtime.advanceBy(0); + const replacement = await harness.relay.connectionAt(1); + await replacement.nextClientFrame(Opcode.Hello); + await replacement.acknowledgeDeclarations(); + const replacementOperation = harness.coordinator.state.set([ + { path: 'home.temperature', value: 25 }, + ]); + const replacementFrame = await replacement.nextClientFrame(Opcode.StateSet); + expect(integer(replacementFrame.payload[0], 'replacement request ID')) + .toBe(integer(staleFrame.payload[0], 'stale request ID')); + + staleWrite.reject(); + await flushMicrotasks(); + replacement.send({ + opcode: Opcode.StateSetOk, + payload: [ + integer(replacementFrame.payload[0], 'replacement request ID'), + replacement.epoch, + 3, + ], + }); + await expect(replacementOperation).resolves.toEqual({ outcome: 'applied' }); + expect(harness.coordinator.status).toBe('ready'); + await harness.coordinator.stop(); + }); + + test('rejects a stale-epoch acknowledgement as a protocol failure', async () => { + const harness = createTestHarness(); + const observed: CoordinatorFailure[] = []; + harness.coordinator.errors.subscribe((failure) => observed.push(failure)); + const { connection } = await startReady(harness); + const operation = harness.coordinator.state.set([ + { path: 'home.temperature', value: 25 }, + ]); + const frame = await connection.nextClientFrame(Opcode.StateSet); + connection.send({ + opcode: Opcode.StateSetOk, + payload: [integer(frame.payload[0], 'STATE_SET.requestId'), new Uint8Array(16).fill(99), 3], + }); + const failure = await operation.catch((error: unknown) => error); + await flushMicrotasks(); + + expect(observed.some((entry) => entry.kind === 'protocol')).toBe(true); + expect(isCoordinatorFailure(failure) && failure.outcome).toBe('outcome_unknown'); + await harness.coordinator.stop(); + }); +}); + +describe('events', () => { + test('publishes at most once and orders sent before a correlated late error', async () => { + const harness = createTestHarness(); + const observed: CoordinatorFailure[] = []; + harness.coordinator.errors.subscribe((failure) => observed.push(failure)); + const { connection } = await startReady(harness); + const handle = harness.coordinator.events.publish('home.alert', { active: true }, { + target: { kind: 'user_session', id: 71 }, + }); + const frame = await connection.nextClientFrame(Opcode.Event); + expect(frame.payload.slice(1)).toEqual([201, 1, 71, { active: true }]); + await expect(handle.sent).resolves.toEqual({ outcome: 'sent' }); + + connection.send({ + opcode: Opcode.Error, + payload: [integer(frame.payload[0], 'EVENT.eventId'), Opcode.Event, 1201, false, 'Denied'], + }); + await flushMicrotasks(); + expect(observed).toHaveLength(1); + expect(observed[0]?.outcome).toBe('sent'); + expect(observed[0]?.correlation).toEqual({ kind: 'event', localId: handle.localId }); + await harness.coordinator.stop(); + }); + + test('isolates local listeners and exposes immutable incoming source and value data', async () => { + const logs: CoordinatorLogRecord[] = []; + const harness = createTestHarness({}, { write: (record) => logs.push(record) }); + const { connection } = await startReady(harness); + const received: IncomingEvent[] = []; + const firstUnsubscribe = harness.coordinator.events.subscribe('home.alert', () => { + throw new Error('listener failure'); + }); + harness.coordinator.events.subscribe('home.alert', (event) => received.push(event)); + connection.send({ + opcode: Opcode.Event, + payload: [ + 81, + 201, + 0, + null, + [1, 'user-1', 71, null, 'user@example.test'], + { temperature: 22 }, + ], + }); + + expect(received).toHaveLength(1); + expect(received[0]?.source).toEqual({ + kind: 'user', + id: 'user-1', + sessionId: 71, + coordinatorName: null, + verifiedEmail: 'user@example.test', + }); + expect(Object.isFrozen(received[0]?.source)).toBe(true); + expect(Object.isFrozen(received[0]?.value)).toBe(true); + expect(logs.some((record) => record.event === 'event_listener_failed')).toBe(true); + firstUnsubscribe(); + firstUnsubscribe(); + await harness.coordinator.stop(); + }); + + test('uses outcome_unknown when cancellation races after transport handoff', async () => { + const harness = createTestHarness(); + const { connection } = await startReady(harness); + const controller = new AbortController(); + const handle = harness.coordinator.events.publish('home.alert', true, { + signal: controller.signal, + }); + controller.abort(); + await connection.nextClientFrame(Opcode.Event); + const failure = await handle.sent.catch((error: unknown) => error); + + expect(isCoordinatorFailure(failure) && failure.outcome).toBe('outcome_unknown'); + await harness.coordinator.stop(); + }); + + test('fails the session on an event with an unknown active topic ID', async () => { + const harness = createTestHarness(); + const observed: CoordinatorFailure[] = []; + harness.coordinator.errors.subscribe((failure) => observed.push(failure)); + const { connection } = await startReady(harness); + connection.send({ + opcode: Opcode.Event, + payload: [82, 999, 0, null, [1, 'user-1', 71, null, null], true], + }); + await flushMicrotasks(); + + expect(observed.some((failure) => failure.kind === 'protocol')).toBe(true); + expect(harness.coordinator.status).toBe('reconnecting'); + await harness.coordinator.stop(); + }); +}); diff --git a/test/type-contract.ts b/test/type-contract.ts new file mode 100644 index 0000000..b71c54f --- /dev/null +++ b/test/type-contract.ts @@ -0,0 +1,34 @@ +import type { + Coordinator, + CoordinatorModule, + CoordinatorOptions, + ProtocolValue, +} from '../src/index.js'; +import { createCoordinator } from '../src/index.js'; + +const options: CoordinatorOptions = { + name: 'type-contract', + accessTokenProvider: { + async getAccessToken() { + return { + relayUrl: 'wss://relay.example.test/miakapp/ws', + token: 'token', + expiresAtMs: Date.now() + 60_000, + }; + }, + }, +}; + +const moduleSurface: CoordinatorModule = { createCoordinator }; + +export function compilePublicSurface(value: ProtocolValue): Coordinator { + const coordinator = moduleSurface.createCoordinator(options); + coordinator.configure({ + state: { 'contract.value': value }, + stateAccess: [], + events: [], + eventAccess: [], + functions: {}, + }); + return coordinator; +} diff --git a/tsconfig.build.json b/tsconfig.build.json new file mode 100644 index 0000000..6103c93 --- /dev/null +++ b/tsconfig.build.json @@ -0,0 +1,18 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "declaration": true, + "declarationMap": true, + "emitDeclarationOnly": false, + "outDir": "./dist", + "rootDir": "./src", + "sourceMap": true, + "stripInternal": true, + "types": [ + "node" + ] + }, + "include": [ + "src/**/*.ts" + ] +} diff --git a/tsconfig.contract.json b/tsconfig.contract.json new file mode 100644 index 0000000..6f5e474 --- /dev/null +++ b/tsconfig.contract.json @@ -0,0 +1,16 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "declaration": false, + "outDir": "./.contract-dist", + "rootDir": ".", + "sourceMap": true, + "types": [ + "node" + ] + }, + "include": [ + "src/**/*.ts", + "test/contract/**/*.ts" + ] +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..5a4e4e7 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,32 @@ +{ + "compilerOptions": { + "allowUnreachableCode": false, + "allowUnusedLabels": false, + "exactOptionalPropertyTypes": true, + "forceConsistentCasingInFileNames": true, + "lib": [ + "ES2022" + ], + "module": "NodeNext", + "moduleResolution": "NodeNext", + "noFallthroughCasesInSwitch": true, + "noImplicitOverride": true, + "noImplicitReturns": true, + "noUncheckedIndexedAccess": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "skipLibCheck": true, + "strict": true, + "target": "ES2022", + "types": [ + "bun", + "node" + ], + "useUnknownInCatchVariables": true, + "verbatimModuleSyntax": true + }, + "include": [ + "src/**/*.ts", + "test/**/*.ts" + ] +} From 9463294af3353c128dced85309c468abddf87ad4 Mon Sep 17 00:00:00 2001 From: Mathieu Colmon Date: Mon, 31 Aug 2026 15:59:45 +0200 Subject: [PATCH 2/2] fix: negotiate the Miakapp WebSocket subprotocol --- src/internal/socket.ts | 7 ++++++- test/socket.test.ts | 3 +++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/internal/socket.ts b/src/internal/socket.ts index 467bc5e..ea40763 100644 --- a/src/internal/socket.ts +++ b/src/internal/socket.ts @@ -10,6 +10,7 @@ import type { const MAX_QUEUED_BYTES = 1_048_576; const HANDSHAKE_TIMEOUT_MS = 10_000; +const WEBSOCKET_SUBPROTOCOL = 'miakapp'; interface BoundedClientOptions extends ClientOptions { maxBufferedChunks: number; @@ -159,7 +160,11 @@ export class WsSocketFactory implements SocketFactory { maxPayload: LIMITS.frameBytes, perMessageDeflate: false, }; - const managed = new WsManagedSocket(new WebSocket(url, options), handlers, signal); + const managed = new WsManagedSocket( + new WebSocket(url, WEBSOCKET_SUBPROTOCOL, options), + handlers, + signal, + ); await managed.ready(); return managed; } diff --git a/test/socket.test.ts b/test/socket.test.ts index 6f07fc5..da2cc9c 100644 --- a/test/socket.test.ts +++ b/test/socket.test.ts @@ -62,8 +62,10 @@ describe('production WebSocket adapter', () => { test('carries binary frames in both directions', async () => { const server = new WebSocketServer({ host: '127.0.0.1', port: 0, perMessageDeflate: false }); const port = await listeningPort(server); + let negotiatedProtocol: string | undefined; const receivedByServer = new Promise((resolve, reject) => { server.once('connection', (socket) => { + negotiatedProtocol = socket.protocol; socket.once('message', (data, isBinary) => { if (!isBinary) reject(new Error('Client sent a text frame')); else resolve(bytes(data)); @@ -91,6 +93,7 @@ describe('production WebSocket adapter', () => { expect([...await inbound]).toEqual([4, 5, 6]); await socket.write(new Uint8Array([1, 2, 3])); expect([...await receivedByServer]).toEqual([1, 2, 3]); + expect(negotiatedProtocol).toBe('miakapp'); expect(failures).toEqual([]); socket.terminate();