From 3ab2d4a6cf1cbf7cf5314b9eea1c81b9a6ced67c Mon Sep 17 00:00:00 2001 From: rudder Date: Wed, 12 Aug 2026 14:02:25 +0300 Subject: [PATCH] Initial commit --- .gitea/workflows/ci.yaml | 31 + .gitignore | 10 + CHANGELOG.md | 65 + README.md | 184 ++ package-lock.json | 3007 +++++++++++++++++++++ package.json | 51 + src/auth/AuthService.ts | 107 + src/battlepass/BattlePassService.ts | 46 + src/client/RudderClient.ts | 262 ++ src/client/RudderClientOptions.ts | 56 + src/client/RudderError.ts | 57 + src/core/context.ts | 16 + src/device/DeviceId.ts | 29 + src/domains/CatalogDomain.ts | 24 + src/domains/ConfigDomain.ts | 33 + src/domains/InventoryDomain.ts | 31 + src/domains/PlayerDomain.ts | 16 + src/domains/StorageDomain.ts | 29 + src/domains/StoresDomain.ts | 62 + src/effects/EffectsCenter.ts | 277 ++ src/generated/api.ts | 228 ++ src/generated/auth.ts | 24 + src/generated/battlepass.ts | 71 + src/generated/catalog.ts | 13 + src/generated/common.ts | 44 + src/generated/errors.ts | 15 + src/generated/index.ts | 17 + src/generated/inventory.ts | 14 + src/generated/leaderboards.ts | 19 + src/generated/player.ts | 21 + src/generated/quests.ts | 42 + src/generated/remote-config.ts | 19 + src/generated/scenarios.ts | 46 + src/generated/storage.ts | 18 + src/generated/stores.ts | 54 + src/generated/ugc.ts | 47 + src/index.ts | 103 + src/leaderboards/LeaderboardsService.ts | 52 + src/quests/QuestsService.ts | 26 + src/scenario/ScenarioService.ts | 593 ++++ src/scenario/engine/DagWalker.ts | 74 + src/scenario/engine/IndexedDbPlanStore.ts | 94 + src/scenario/engine/sessions.ts | 342 +++ src/scenario/engine/types.ts | 63 + src/state/RemoteConfigState.ts | 70 + src/state/SyncEngine.ts | 150 + src/state/SyncedState.ts | 152 ++ src/state/inventory.ts | 29 + src/state/shops.ts | 72 + src/token/TokenStore.ts | 101 + src/transport/request.ts | 188 ++ src/transport/url.ts | 23 + test/AuthService.test.ts | 264 ++ test/PublicApi.test.ts | 90 + test/RudderClient.test.ts | 217 ++ test/ScenarioService.test.ts | 528 ++++ test/e2e-prod/_setup/adminClient.ts | 77 + test/e2e-prod/_setup/artifact.ts | 19 + test/e2e-prod/_setup/globalSetup.ts | 48 + test/e2e-prod/_setup/harness.ts | 99 + test/e2e-prod/_setup/seed.ts | 311 +++ test/e2e-prod/_setup/types.ts | 46 + test/e2e-prod/health.e2e.test.ts | 26 + test/e2e-prod/leaderboards.e2e.test.ts | 30 + test/e2e-prod/player.e2e.test.ts | 11 + test/e2e-prod/purchase.e2e.test.ts | 98 + test/e2e-prod/remoteConfig.e2e.test.ts | 33 + test/e2e-prod/scenarios.e2e.test.ts | 52 + test/e2e-prod/storage.e2e.test.ts | 22 + test/e2e-prod/stores.e2e.test.ts | 37 + test/e2e/boundary.e2e.test.ts | 159 ++ test/e2e/offerJourney.e2e.test.ts | 157 ++ test/e2e/persistence.e2e.test.ts | 75 + test/e2e/remoteConfig.e2e.test.ts | 101 + test/e2e/storage.e2e.test.ts | 80 + test/helpers/FakeTokenStore.ts | 20 + test/helpers/createClient.ts | 13 + test/helpers/fakeGateway.ts | 241 ++ test/helpers/memoryPlanStore.ts | 41 + test/helpers/plan.ts | 72 + tsconfig.json | 20 + tsconfig.test.json | 10 + tsup.config.ts | 14 + vitest.config.ts | 12 + vitest.e2e-prod.config.ts | 17 + 85 files changed, 10257 insertions(+) create mode 100644 .gitea/workflows/ci.yaml create mode 100644 .gitignore create mode 100644 CHANGELOG.md create mode 100644 README.md create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 src/auth/AuthService.ts create mode 100644 src/battlepass/BattlePassService.ts create mode 100644 src/client/RudderClient.ts create mode 100644 src/client/RudderClientOptions.ts create mode 100644 src/client/RudderError.ts create mode 100644 src/core/context.ts create mode 100644 src/device/DeviceId.ts create mode 100644 src/domains/CatalogDomain.ts create mode 100644 src/domains/ConfigDomain.ts create mode 100644 src/domains/InventoryDomain.ts create mode 100644 src/domains/PlayerDomain.ts create mode 100644 src/domains/StorageDomain.ts create mode 100644 src/domains/StoresDomain.ts create mode 100644 src/effects/EffectsCenter.ts create mode 100644 src/generated/api.ts create mode 100644 src/generated/auth.ts create mode 100644 src/generated/battlepass.ts create mode 100644 src/generated/catalog.ts create mode 100644 src/generated/common.ts create mode 100644 src/generated/errors.ts create mode 100644 src/generated/index.ts create mode 100644 src/generated/inventory.ts create mode 100644 src/generated/leaderboards.ts create mode 100644 src/generated/player.ts create mode 100644 src/generated/quests.ts create mode 100644 src/generated/remote-config.ts create mode 100644 src/generated/scenarios.ts create mode 100644 src/generated/storage.ts create mode 100644 src/generated/stores.ts create mode 100644 src/generated/ugc.ts create mode 100644 src/index.ts create mode 100644 src/leaderboards/LeaderboardsService.ts create mode 100644 src/quests/QuestsService.ts create mode 100644 src/scenario/ScenarioService.ts create mode 100644 src/scenario/engine/DagWalker.ts create mode 100644 src/scenario/engine/IndexedDbPlanStore.ts create mode 100644 src/scenario/engine/sessions.ts create mode 100644 src/scenario/engine/types.ts create mode 100644 src/state/RemoteConfigState.ts create mode 100644 src/state/SyncEngine.ts create mode 100644 src/state/SyncedState.ts create mode 100644 src/state/inventory.ts create mode 100644 src/state/shops.ts create mode 100644 src/token/TokenStore.ts create mode 100644 src/transport/request.ts create mode 100644 src/transport/url.ts create mode 100644 test/AuthService.test.ts create mode 100644 test/PublicApi.test.ts create mode 100644 test/RudderClient.test.ts create mode 100644 test/ScenarioService.test.ts create mode 100644 test/e2e-prod/_setup/adminClient.ts create mode 100644 test/e2e-prod/_setup/artifact.ts create mode 100644 test/e2e-prod/_setup/globalSetup.ts create mode 100644 test/e2e-prod/_setup/harness.ts create mode 100644 test/e2e-prod/_setup/seed.ts create mode 100644 test/e2e-prod/_setup/types.ts create mode 100644 test/e2e-prod/health.e2e.test.ts create mode 100644 test/e2e-prod/leaderboards.e2e.test.ts create mode 100644 test/e2e-prod/player.e2e.test.ts create mode 100644 test/e2e-prod/purchase.e2e.test.ts create mode 100644 test/e2e-prod/remoteConfig.e2e.test.ts create mode 100644 test/e2e-prod/scenarios.e2e.test.ts create mode 100644 test/e2e-prod/storage.e2e.test.ts create mode 100644 test/e2e-prod/stores.e2e.test.ts create mode 100644 test/e2e/boundary.e2e.test.ts create mode 100644 test/e2e/offerJourney.e2e.test.ts create mode 100644 test/e2e/persistence.e2e.test.ts create mode 100644 test/e2e/remoteConfig.e2e.test.ts create mode 100644 test/e2e/storage.e2e.test.ts create mode 100644 test/helpers/FakeTokenStore.ts create mode 100644 test/helpers/createClient.ts create mode 100644 test/helpers/fakeGateway.ts create mode 100644 test/helpers/memoryPlanStore.ts create mode 100644 test/helpers/plan.ts create mode 100644 tsconfig.json create mode 100644 tsconfig.test.json create mode 100644 tsup.config.ts create mode 100644 vitest.config.ts create mode 100644 vitest.e2e-prod.config.ts diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml new file mode 100644 index 0000000..4c1c999 --- /dev/null +++ b/.gitea/workflows/ci.yaml @@ -0,0 +1,31 @@ +name: CI + +on: + push: + branches: [main, master] + pull_request: + +jobs: + check: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Node + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + + - name: Install + run: npm ci + + - name: Typecheck (src + tests) + run: npm run typecheck + + - name: Unit + local e2e tests + run: npm test + + - name: Build + run: npm run build diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9817341 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +node_modules/ +dist/ +# Local seed artifact produced by the prod e2e suite (contains a throwaway operator token). +.e2e-prod.local.json + +# IDE / OS +.idea/ +.vscode/ +.DS_Store +*.log diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..b220c64 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,65 @@ +# Changelog + +## 0.3.0 + +Breaking changes — the package was aligned with the Rudder SDK glossary and +the regenerated wire types (`src/generated`, apigen). No compatibility shims +are provided. + +### Auth + +- `client.auth.loginViaDevice(region, language, nickname?)` replaced by + `client.auth.loginWithDevice(options?: { region?, language?, nickname? })`. + Default region is now `'global'` (was `'en'`); default language stays `'en'`. +- New `client.auth.onAuthStateChange(cb)` — fires immediately with the current + state, then on every `'signed-in' | 'signed-out'` transition (login, logout, + and unrecoverable session expiry in the transport). +- New `client.auth.isAuthenticated`. + +### Client configuration + +- `tokenStore` in `RudderClientOptions` is now optional. Default: + localStorage-backed store with a silent in-memory fallback where + localStorage is unavailable (SSR, private mode). New `createDefaultTokenStore()` export. +- Invalid client options (`baseUrl` / `projectKey` missing) now throw + `RudderError` with `code: 'sdk/invalid-options'` instead of a plain `Error`. +- New `onEffectError` option — called when an effect handler throws + (default: `console.error`); previously such errors were swallowed silently. + +### Renames + +- `client.config` → `client.remoteConfig` (canonical glossary name). +- `client.battlepass` → `client.battlePass`; `BattlepassService` → `BattlePassService`. +- Effects: `onBattlepass` / `BattlepassEffect` → `onBattlePass` / `BattlePassEffect`; + `onBattlepassLevel` / `BattlepassLevelEffect` → `onBattlePassLevel` / `BattlePassLevelEffect`. +- Generated types: `SdkQuest` → `Quest`; `*Battlepass*` request/response types → `*BattlePass*`. +- Internal transport `sendAsync` → `request` (not part of the public surface). + +### Leaderboards + +- Removed `LeaderboardsService.getRanking(slug, limit)` and + `LeaderboardsService.submitScore(slug, score)`. Single path: + `client.leaderboards.findBySlug(slug)` → `handle.list(limit?)` / `handle.submit(score)`. + +### Errors + +- `RudderNetworkError.inner` → standard `Error.cause`. +- `RudderHttpError.code` is typed as `RudderErrorCode | (string & {})` + (generated union of server error codes, plus room for unknown strings). +- New exports: `RudderErrorCode` (type), `RudderErrorCodes` (constants), + `SDK_ERROR_INVALID_OPTIONS`, `RudderErrorCodeLike` (type). + +### Public surface + +- The barrel no longer exports `getOrCreateDeviceId`, domain classes + (`PlayerDomain`, …) or service constructors (`AuthService`, …) as values — + they remain available as types. Instances live on the client. +- `SubmittedBy` is no longer re-exported (UGC is unsupported on web). + +### Bundle + +- The scenario engine and the IndexedDB plan store are loaded lazily (dynamic + import) on first login / first runtime access — a thin client never pulls + the scenario machinery into the initial chunk. +- The package now ships both ESM (`dist/index.js`) and CJS (`dist/index.cjs`) + builds with matching `exports` entries. diff --git a/README.md b/README.md new file mode 100644 index 0000000..f7c87da --- /dev/null +++ b/README.md @@ -0,0 +1,184 @@ +# @rudder/js-sdk + +LiveOps Web SDK for browser games — device auth, observable player state, +stores, remote config, leaderboards, quests, battle pass, and a scenario runtime +surfaced as typed effects. + +## Install + +The package is published to a private registry, not npmjs. Point the +`@rudder` scope at it in your project's `.npmrc`: + +``` +@rudder:registry=https://hub.rudder.build/api/packages/rudder/npm/ +``` + +Then install as usual (anonymous read access, no token needed): + +```bash +npm install @rudder/js-sdk +``` + +## Quickstart + +```ts +import { RudderClient } from '@rudder/js-sdk'; + +const client = new RudderClient({ + baseUrl: 'https://api.rudder.build', + projectKey: 'your-project-key', + // tokenStore is optional: defaults to localStorage with an in-memory + // fallback where localStorage is unavailable (SSR, private mode). +}); + +await client.auth.loginWithDevice(); // region 'global', language 'en' +// or: await client.auth.loginWithDevice({ region: 'eu', language: 'de', nickname: 'Bob' }); + +// Observable domains — read `.data`, subscribe with `onChange`, force `reload()`. +const profile = client.player.data; +const speed = client.remoteConfig.get('player_speed', 200); + +client.stores.onChange(({ data: stores }) => renderStores(stores ?? [])); + +// Scenario-driven UI arrives through effects. +client.effects.onStoreOffer((offer) => showOffer(offer)); +client.effects.onScenarioFailed(({ error }) => console.error('scenario failed', error)); + +// Tear down background work (sync poll, wait timers) on unmount / HMR. +client.dispose(); +``` + +## Surface + +| Area | Access | +|---|---| +| Auth | `client.auth.loginWithDevice()`, `client.auth.logout()`, `client.auth.isAuthenticated`, `client.auth.onAuthStateChange(cb)` | +| Player / wallets | `client.player` (observable) | +| Inventory | `client.inventory` (observable, catalog-merged) | +| Catalog | `client.catalog` (observable) | +| Stores | `client.stores` (observable) + `client.stores.purchase(slug, offerId)` | +| Remote config | `client.remoteConfig.get(key, default)` (observable, typed) | +| Storage | `client.storage` (observable) + `.save(items)` / `.delete(type)` | +| Leaderboards | `client.leaderboards.findBySlug(slug)` → `handle.submit(score)` / `handle.list(limit?)` | +| Battle pass | `client.battlePass` (getProgress / addXp / claimReward / purchasePremium) | +| Quests | `client.quests.list()` / `client.quests.claim(id)` | +| Scenario effects | `client.effects.on*` | + +Type the remote config for `client.remoteConfig`: + +```ts +interface GameConfig extends Record { + player_speed: number; + feature_x: boolean; +} +const client = new RudderClient({ /* … */ }); +client.remoteConfig.get('player_speed', 200); // number +``` + +## Scenario effects + +The scenario runtime is not exposed directly; scenario nodes surface through +`client.effects`: + +- `onNotification`, `onStoreOffer`, `onLeaderboard`, `onConfigChanged` +- `onWait`, `onQuest`, `onBattlePass`, `onBattlePassLevel` +- `onScenarioCompleted`, `onScenarioFailed` + +The scenario engine (and its IndexedDB persistence) loads lazily on first +login — a client that only reads domains never pulls it into the page. + +Errors thrown inside effect handlers are reported via the `onEffectError` +client option (default: `console.error`). + +## Error handling + +All SDK errors extend `RudderError` and carry an optional machine-readable +`code`: + +```ts +import { + RudderError, + RudderNetworkError, + RudderHttpError, + RudderAuthError, + RudderErrorCodes, +} from '@rudder/js-sdk'; + +try { + await client.player.reload(); +} catch (error) { + if (error instanceof RudderAuthError) { + // 401 — session expired and the refresh failed; the client already + // cleared tokens and emitted 'signed-out' via onAuthStateChange. + showLogin(); + } else if (error instanceof RudderHttpError) { + if (error.code === RudderErrorCodes.runExpired) { + // typed server error code + } + console.error(error.status, error.code, error.body); + } else if (error instanceof RudderNetworkError) { + // fetch failed / timeout — the underlying error is in error.cause + console.error('offline?', error.cause); + } +} +``` + +Invalid client options (missing `baseUrl` / `projectKey`) throw `RudderError` +with `code: 'sdk/invalid-options'` from the constructor. + +Track the session lifecycle without polling the token store: + +```ts +client.auth.onAuthStateChange((state) => { + // fires immediately with the current state, then on every change + setSignedIn(state === 'signed-in'); +}); +``` + +## Data liveness model + +Observable domains (`player`, `inventory`, `catalog`, `stores`, `remoteConfig`, +`storage`) are warmed once at login and then kept fresh by a revision poll: +the client asks the gateway for entity revisions every **30 s (±20% jitter)** +and reloads only the entities whose revision grew. Polling **pauses while the +tab is hidden** and resumes on `visibilitychange`. Mutations (purchases, +storage writes, scenario callbacks) invalidate the affected domains +immediately, so subscribers see fresh data without waiting for the next poll. +Expect data to be eventually consistent within one poll interval; call +`reload()` when you need a guarantee. + +## React recipe + +`SyncedState.onChange` fires immediately with the current snapshot, which maps +straight onto `useSyncExternalStore`: + +```tsx +import { useSyncExternalStore } from 'react'; + +function useSynced(state: { onChange(cb: () => void): () => void; data: T | undefined }) { + return useSyncExternalStore( + (onStoreChange) => state.onChange(() => onStoreChange()), + () => state.data, + ); +} + +function Wallet() { + const profile = useSynced(client.player); + return
{profile?.wallets?.map((w) => `${w.currency}: ${w.balance}`).join(', ')}
; +} +``` + +The same pattern works for `client.remoteConfig`, `client.stores`, and +`client.auth.onAuthStateChange`. + +## Development + +```bash +npm run typecheck # tsc over src and tests +npm test # unit + local e2e (jsdom) +npm run build # tsup → dist (ESM + CJS + d.ts) +npm run test:e2e-prod # drives a seeded project on the live gateway +``` + +Generated wire types (`src/generated`) come from the gateway's `apigen`; run +`npm run generate` to refresh them. Do not edit them by hand. diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..ee48b4f --- /dev/null +++ b/package-lock.json @@ -0,0 +1,3007 @@ +{ + "name": "@rudder/js-sdk", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@rudder/js-sdk", + "version": "0.1.0", + "devDependencies": { + "@types/node": "^26.1.1", + "jsdom": "^29.1.1", + "tsup": "^8.3.5", + "typescript": "^5.7.0", + "vitest": "^2.1.0" + } + }, + "node_modules/@asamuzakjp/css-color": { + "version": "5.1.11", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", + "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@csstools/css-calc": "^3.2.0", + "@csstools/css-color-parser": "^4.1.0", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz", + "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/generational-cache": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz", + "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz", + "integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.1.tgz", + "integrity": "sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.1.tgz", + "integrity": "sha512-eZ5XOtyhK+mggRafYUWzA0tvaYOFgdY8AkgQiCJF9qNAePnUo/zmsqqYubBBb3sQ8uNUaSKTY9s9klfRaAXL0g==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.0.2", + "@csstools/css-calc": "^3.2.1" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.5.tgz", + "integrity": "sha512-oNjBvzLq2GPZtJphCjLqXow/cHySHSgtxvKZb7OqSZ/xHgw6NWNhfad+6AB9cLeVm6eA9d/qMll3JdEHjy6M+A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", + "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", + "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", + "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", + "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", + "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", + "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", + "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", + "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", + "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", + "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", + "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", + "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", + "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", + "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", + "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", + "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", + "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", + "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", + "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", + "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", + "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@exodus/bytes": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", + "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.61.1.tgz", + "integrity": "sha512-JnBB8MdXj45cajvTuO5FmPlvFVJRQgvrz1uSEl3NwqFnReAPGwb8EanbGi4z2nRaqLzjJSv5/JmycoTKlRZxHA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.61.1.tgz", + "integrity": "sha512-Jx2g7iSjw4AOT0HDPHM9RV3GNjRXwybWtSFZiZAYUTjUwjVrYIwq3kBf+LnhqJlzXFAqTAh2F7IGI+O568exPw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.61.1.tgz", + "integrity": "sha512-0F1L/Z3Eqv8mT2n3dCpeO8GcTvHvVqkP5/t6DMsn0KzhYVcg+s7Ncl5DS8qjKYEeio6Az0Gt6nyBORay5qIlCA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.61.1.tgz", + "integrity": "sha512-qLttcH871ujY4YcVfUSShhOw+CsoTatYz8gRbHO7Bb92QH059/P0y5do1KMs41fY0BpD2x4AJH/gID0zFiqVKQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.61.1.tgz", + "integrity": "sha512-fUI4RapGE0Oh3mb8mgfvC1O2nU1RpDZUKnDQm3xB1Ipg7C2wTs5Kstz7G2uWK99a8S2yTMq8/P4uycwNa0nJyw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.61.1.tgz", + "integrity": "sha512-H5YrdvJaDtI/U9/emrD4b++xkvp3y/JvOe4rizHbxvkyMfRS/CiRYdji+Pl8D0brEaNFWUh1drQxgAGIl6Xudw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.61.1.tgz", + "integrity": "sha512-Q8CBCCQtDFrYtXoeUXSrnFXKOnyUhx6bz+SkL6A0E7V8kAiCJ5pamq1WtbfpVGhR5TSpXY6ak3avmDc5fHTyJA==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.61.1.tgz", + "integrity": "sha512-nwnhk1581l0FBVellGcVCAT0Oi06onEA3WB53sf01VO3I0UPBkMH9sXONYME2K0ovXcNayJfNtHfm6mpJElatQ==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.61.1.tgz", + "integrity": "sha512-x5Xr49hwt3hdW75UOZm3395YwwzPyauktslv29KpWL/T+vVAzoT3azLcTWv0eMciBNrx+DYjH4paehHoLpPvpg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.61.1.tgz", + "integrity": "sha512-unMS3H73DpaoPyyEVPjGKleM/s0mkmsauTENpw4INQY8y4+IuLNjkueQ5QCtC0D3N38Y38yhAU8OoZ20S2Tm6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.61.1.tgz", + "integrity": "sha512-zNZzGRnAhwjFEYmvphJRV5XaQGjs62cCmeYYHUT//NbvEnHauw+I85nGG+SiVg5ld4GX8D1IbKIX+ozITQnhMQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.61.1.tgz", + "integrity": "sha512-LdpWGL8X209B2SIvWjqlc8VZgM6PKfontSerGepuldQmHYrAOtnMCXeJkxXGbC+PPZVOuu5czJo7fNV6aeW8rQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.61.1.tgz", + "integrity": "sha512-EC5kTtNaNGOmbMGqar8dvJy6y/hg99GAwjfBz++pxZhQATXGcRjd6c5en5wcbru0vkRmiMGsQKdMJOOf6sza4g==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.61.1.tgz", + "integrity": "sha512-8hiwp6D4acEcNK78I4rP0/XtS1sknWIAMJBPdR4l6zUtyTm5KiTDr5bXmWt4foY7nAN7AThDHgkLIEZOWKbzWw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.61.1.tgz", + "integrity": "sha512-10dh/h/BqA7DuMPWSxkR8uks18FRwnwOEqr5zOTEl+NOwP/OMzKX8OFR/Of9xxDA7D5qef1Nzar5WDD2kCCr1g==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.61.1.tgz", + "integrity": "sha512-YKJ5lg35DP17gcAOggnihe+APw9HLyj1Xn7gsmGumBJAUDa6NGXNixJzmkWLhcK9TOuuyQjdamzvJefkO7qHZQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.61.1.tgz", + "integrity": "sha512-Mlil5G2Jj6a7B3LWGctg+XPL9vdXYuzCtNXfxOQ0nPjc2m6ueUktocPGH9bnAM0bNRKb/bAWTujUU7IJQdQA+g==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.61.1.tgz", + "integrity": "sha512-bVWIOIk6pV01p4CdUbPP7CJ/434z+OooYjDuFcR+44N35YvKUC66G8MGnvcWx5mWKW3g61J+t74l3Kj15Kwn2Q==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.61.1.tgz", + "integrity": "sha512-qy5pBvZbqNFheBz61R1rzsezjm0J7O2oNGoWtGoY89SZYLUfxAJTBAqDChqAIdB4rCiIbi9nF7yZ83GnNiLwSw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.61.1.tgz", + "integrity": "sha512-E83TXjI4zm0+5f2qO+UOudaCYIhYwpJ5jq6YCZNIZ+6CbfhKrkAGezeiASBL9ElxAxFsRS9ZhESv8mfnj6TKeg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.61.1.tgz", + "integrity": "sha512-fbWnKqVkjrJN38vNe3ahkbk6iejS/3b0Nt7EEtPpE6RBacZcGXNKbzfHN3GUUlXOPghUg0j6XUGrtjX9z1sIvA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.61.1.tgz", + "integrity": "sha512-ArMl38iVAbk0New1ogihQNY6iphLi4ZaRsa037gUzv5yeKPY8TD3Dmy4x2RNC1VztU/uqm+G+/RwFrSka3Oy2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.61.1.tgz", + "integrity": "sha512-0mYtjHS9ucAbcATycCNK9IGBk/cCe/ma7EmSLGZdsxnOA8cjRIyU04wDpVAD9NiOfLUR9KTxdiO53uOkherqjQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.61.1.tgz", + "integrity": "sha512-gK1iCEPfpoSG9wfBihXxvBMi8ZfcWffYkEsC/Eih+iFENTaewvNcrEQ69lIOWYO5pePHKLHHO7nq5AILGO/HQQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.61.1.tgz", + "integrity": "sha512-X+zaP2x+j4RXGfbp/seSoRHWnPxzApilDszisZxbYH5C/jTxFhCtDNdPGZb9lJyYPs24wGxruPF7Y+sIXt9Gzw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "26.1.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.1.tgz", + "integrity": "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~8.3.0" + } + }, + "node_modules/@vitest/expect": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", + "integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.9.tgz", + "integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.12" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz", + "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.9.tgz", + "integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "2.1.9", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner/node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitest/snapshot": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz", + "integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "magic-string": "^0.30.12", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot/node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitest/spy": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz", + "integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^3.0.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz", + "integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "loupe": "^3.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, + "node_modules/bundle-require": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/bundle-require/-/bundle-require-5.1.0.tgz", + "integrity": "sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "load-tsconfig": "^0.2.3" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "peerDependencies": { + "esbuild": ">=0.18" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/confbox": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz", + "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/consola": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz", + "integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.18.0 || >=16.10.0" + } + }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "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/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fix-dts-default-cjs-exports": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/fix-dts-default-cjs-exports/-/fix-dts-default-cjs-exports-1.0.1.tgz", + "integrity": "sha512-pVIECanWFC61Hzl2+oOCtoJ3F17kglZC/6N94eRWycFgBH35hHx0Li604ZIzhseh97mf2p0cv7vVrOZGoqhlEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "magic-string": "^0.30.17", + "mlly": "^1.7.4", + "rollup": "^4.34.8" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/joycon": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz", + "integrity": "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/jsdom": { + "version": "29.1.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz", + "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^5.1.11", + "@asamuzakjp/dom-selector": "^7.1.1", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.3", + "@exodus/bytes": "^1.15.0", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.3.5", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.1", + "undici": "^7.25.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.1", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/load-tsconfig": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/load-tsconfig/-/load-tsconfig-0.2.5.tgz", + "integrity": "sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "11.5.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz", + "integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/mlly": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.2.tgz", + "integrity": "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.16.0", + "pathe": "^2.0.3", + "pkg-types": "^1.3.1", + "ufo": "^1.6.3" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/pkg-types": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz", + "integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "confbox": "^0.1.8", + "mlly": "^1.7.4", + "pathe": "^2.0.1" + } + }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/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/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/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-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/rollup": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.61.1.tgz", + "integrity": "sha512-I4KW6iuRpuu2uHBLraZ1wNZe0DP7lnRha+VJ9tNaYVaVgKhW0aI3h4RYnoRPeql0flHm/Co55b7snEDcOfOJrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.61.1", + "@rollup/rollup-android-arm64": "4.61.1", + "@rollup/rollup-darwin-arm64": "4.61.1", + "@rollup/rollup-darwin-x64": "4.61.1", + "@rollup/rollup-freebsd-arm64": "4.61.1", + "@rollup/rollup-freebsd-x64": "4.61.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.61.1", + "@rollup/rollup-linux-arm-musleabihf": "4.61.1", + "@rollup/rollup-linux-arm64-gnu": "4.61.1", + "@rollup/rollup-linux-arm64-musl": "4.61.1", + "@rollup/rollup-linux-loong64-gnu": "4.61.1", + "@rollup/rollup-linux-loong64-musl": "4.61.1", + "@rollup/rollup-linux-ppc64-gnu": "4.61.1", + "@rollup/rollup-linux-ppc64-musl": "4.61.1", + "@rollup/rollup-linux-riscv64-gnu": "4.61.1", + "@rollup/rollup-linux-riscv64-musl": "4.61.1", + "@rollup/rollup-linux-s390x-gnu": "4.61.1", + "@rollup/rollup-linux-x64-gnu": "4.61.1", + "@rollup/rollup-linux-x64-musl": "4.61.1", + "@rollup/rollup-openbsd-x64": "4.61.1", + "@rollup/rollup-openharmony-arm64": "4.61.1", + "@rollup/rollup-win32-arm64-msvc": "4.61.1", + "@rollup/rollup-win32-ia32-msvc": "4.61.1", + "@rollup/rollup-win32-x64-gnu": "4.61.1", + "@rollup/rollup-win32-x64-msvc": "4.61.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map": { + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", + "integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">= 12" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz", + "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", + "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "7.4.2", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.2.tgz", + "integrity": "sha512-kCwffuaH8ntKtygnWe1b4BJKWiCUH30n5KfoTr6IchcXOwR7chAOFJxFrH3vjANafUYrIA4a7SDL+nn7SiR4Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.2" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.2", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.2.tgz", + "integrity": "sha512-nwEyF4vl4RSJjwSjBUmOSxc3BFPoIFdlRthJ6e+5v9P3bHNsoD06UjuqMUspqp7vsEZ1beaHi1km+optiE17yA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz", + "integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/tree-kill": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", + "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", + "dev": true, + "license": "MIT", + "bin": { + "tree-kill": "cli.js" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/tsup": { + "version": "8.5.1", + "resolved": "https://registry.npmjs.org/tsup/-/tsup-8.5.1.tgz", + "integrity": "sha512-xtgkqwdhpKWr3tKPmCkvYmS9xnQK3m3XgxZHwSUjvfTjp7YfXe5tT3GgWi0F2N+ZSMsOeWeZFh7ZZFg5iPhing==", + "dev": true, + "license": "MIT", + "dependencies": { + "bundle-require": "^5.1.0", + "cac": "^6.7.14", + "chokidar": "^4.0.3", + "consola": "^3.4.0", + "debug": "^4.4.0", + "esbuild": "^0.27.0", + "fix-dts-default-cjs-exports": "^1.0.0", + "joycon": "^3.1.1", + "picocolors": "^1.1.1", + "postcss-load-config": "^6.0.1", + "resolve-from": "^5.0.0", + "rollup": "^4.34.8", + "source-map": "^0.7.6", + "sucrase": "^3.35.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.11", + "tree-kill": "^1.2.2" + }, + "bin": { + "tsup": "dist/cli-default.js", + "tsup-node": "dist/cli-node.js" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@microsoft/api-extractor": "^7.36.0", + "@swc/core": "^1", + "postcss": "^8.4.12", + "typescript": ">=4.5.0" + }, + "peerDependenciesMeta": { + "@microsoft/api-extractor": { + "optional": true + }, + "@swc/core": { + "optional": true + }, + "postcss": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/ufo": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.4.tgz", + "integrity": "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==", + "dev": true, + "license": "MIT" + }, + "node_modules/undici": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.27.2.tgz", + "integrity": "sha512-uZsKNuzQxDMUY6M3pIMvy5tvlGmtq8XJ2oLAkfRKGNu+1VQAIvLy2xIVG5ATZl5wDXl/tddByAWCizRbOme+TA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/undici-types": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.9.tgz", + "integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.7", + "es-module-lexer": "^1.5.4", + "pathe": "^1.1.2", + "vite": "^5.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vite-node/node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite/node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/vitest": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz", + "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "2.1.9", + "@vitest/mocker": "2.1.9", + "@vitest/pretty-format": "^2.1.9", + "@vitest/runner": "2.1.9", + "@vitest/snapshot": "2.1.9", + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "debug": "^4.3.7", + "expect-type": "^1.1.0", + "magic-string": "^0.30.12", + "pathe": "^1.1.2", + "std-env": "^3.8.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.1", + "tinypool": "^1.0.1", + "tinyrainbow": "^1.2.0", + "vite": "^5.0.0", + "vite-node": "2.1.9", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "2.1.9", + "@vitest/ui": "2.1.9", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + } + } +} \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..a785598 --- /dev/null +++ b/package.json @@ -0,0 +1,51 @@ +{ + "name": "@rudder/js-sdk", + "version": "0.1.0", + "publishConfig": { + "registry": "https://hub.rudder.build/api/packages/rudder/npm/" + }, + "description": "LiveOps Web SDK for browser games — player authentication, leaderboards, stores, remote config, and typed runtime effects", + "type": "module", + "main": "./dist/index.cjs", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "sideEffects": false, + "exports": { + ".": { + "import": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } + } + }, + "files": [ + "dist" + ], + "repository": { + "type": "git", + "url": "https://hub.rudder.build/rudder/rudder-js-sdk" + }, + "scripts": { + "build": "tsup", + "dev": "tsup --watch", + "test": "vitest run", + "test:watch": "vitest", + "test:e2e": "vitest run test/e2e", + "test:e2e-prod": "vitest run --config vitest.e2e-prod.config.ts", + "e2e:prod": "vitest run --config vitest.e2e-prod.config.ts", + "typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.test.json", + "check:package": "npm run build && npm pack --dry-run --json", + "generate": "make -C ../liveops-gateway generate-openapi" + }, + "devDependencies": { + "@types/node": "^26.1.1", + "jsdom": "^29.1.1", + "tsup": "^8.3.5", + "typescript": "^5.7.0", + "vitest": "^2.1.0" + } +} diff --git a/src/auth/AuthService.ts b/src/auth/AuthService.ts new file mode 100644 index 0000000..7e76cc5 --- /dev/null +++ b/src/auth/AuthService.ts @@ -0,0 +1,107 @@ +/** + * AuthService — device-based player authentication. + * + * Handles login via device ID (the primary auth flow for game clients), + * logout (token clearing), and auth state observation. + */ + +import type { RudderContext } from '../core/context.js'; +import type { RemoteConfigShape } from '../state/RemoteConfigState.js'; +import { getOrCreateDeviceId } from '../device/DeviceId.js'; +import { api } from '../generated/api.js'; +import type { LoginViaDeviceResponse } from '../generated/auth.js'; + +export type AuthState = 'signed-in' | 'signed-out'; + +export type AuthStateListener = (state: AuthState) => void; + +export interface LoginWithDeviceOptions { + /** Player's region code (default: "global"). */ + region?: string; + /** Player's language code (default: "en"). */ + language?: string; + /** Optional player nickname; omitted from the request when not set. */ + nickname?: string; +} + +export class AuthService { + private readonly listeners = new Set(); + private state: AuthState; + + constructor( + private readonly ctx: RudderContext, + private readonly startRuntime: () => Promise, + private readonly stopRuntime: () => void, + ) { + this.state = this.isAuthenticated ? 'signed-in' : 'signed-out'; + } + + /** True while an access token is present in the token store. */ + get isAuthenticated(): boolean { + return this.ctx.options.tokenStore.getAccessToken() !== null; + } + + /** + * Subscribes to auth state changes. The listener fires immediately with the + * current state (like `SyncedState.onChange`). Returns an unsubscribe function. + */ + onAuthStateChange(listener: AuthStateListener): () => void { + this.listeners.add(listener); + // Resync with the token store — tokens may have been written externally + // since construction. + this.state = this.isAuthenticated ? 'signed-in' : 'signed-out'; + listener(this.state); + return () => { + this.listeners.delete(listener); + }; + } + + /** + * Authenticates the current device and returns access + refresh tokens. + * + * The device ID is auto-generated on first call and persisted in localStorage. + * On success, tokens are saved to the client's TokenStore. + */ + async loginWithDevice(options: LoginWithDeviceOptions = {}): Promise { + const { region = 'global', language = 'en', nickname } = options; + const response = await api.loginViaDevice(this.ctx, { + key: this.ctx.options.projectKey, + deviceId: getOrCreateDeviceId(), + region, + language, + nickname, + }); + + this.ctx.options.tokenStore.saveTokens( + response.accessToken ?? '', + response.refreshToken ?? '', + ); + await this.startRuntime(); + this.setState('signed-in'); + return response; + } + + /** Clears all stored tokens (logout). */ + logout(): void { + this.ctx.options.tokenStore.clear(); + this.stopRuntime(); + this.setState('signed-out'); + } + + /** + * Called by the transport when the session is unrecoverable (refresh failed). + * + * @internal + */ + notifySessionExpired(): void { + this.setState('signed-out'); + } + + private setState(state: AuthState): void { + if (state === this.state) return; + this.state = state; + for (const listener of this.listeners) { + listener(state); + } + } +} diff --git a/src/battlepass/BattlePassService.ts b/src/battlepass/BattlePassService.ts new file mode 100644 index 0000000..3d99a18 --- /dev/null +++ b/src/battlepass/BattlePassService.ts @@ -0,0 +1,46 @@ +/** + * BattlePassService — call-and-response access to the battle pass endpoints. + * + * Battle pass state is tied to a scenario battle pass node, so every call + * carries a `scenarioId` + `nodeId` (and a `runId` for the mutating calls). + * Battle pass has no sync-revision key, so this is a plain service, not an + * observable domain; re-fetch progress explicitly after a mutation. + */ + +import type { RudderContext } from '../core/context.js'; +import { api } from '../generated/api.js'; +import type { + AddBattlePassXpRequest, + AddBattlePassXpResponse, + ClaimBattlePassRewardRequest, + ClaimBattlePassRewardResponse, + PurchaseBattlePassPremiumRequest, + PurchaseBattlePassPremiumResponse, + GetBattlePassProgressResponse, +} from '../generated/battlepass.js'; + +export class BattlePassService { + constructor(private readonly ctx: RudderContext) {} + + /** Reads current progress (xp, level, premium, claimed tiers) for a node. */ + getProgress(scenarioId: string, nodeId: string): Promise { + return api.getBattlePassProgress(this.ctx, { scenarioId, nodeId }); + } + + /** Credits XP from a configured source. Returns the new xp/level. */ + addXp(request: AddBattlePassXpRequest): Promise { + return api.addBattlePassXp(this.ctx, request); + } + + /** Claims a tier reward at a reached level (idempotent server-side). */ + claimReward(request: ClaimBattlePassRewardRequest): Promise { + return api.claimBattlePassReward(this.ctx, request); + } + + /** Purchases the premium track (charges the wallet, idempotent). */ + purchasePremium( + request: PurchaseBattlePassPremiumRequest, + ): Promise { + return api.purchaseBattlePassPremium(this.ctx, request); + } +} diff --git a/src/client/RudderClient.ts b/src/client/RudderClient.ts new file mode 100644 index 0000000..56c4144 --- /dev/null +++ b/src/client/RudderClient.ts @@ -0,0 +1,262 @@ +/** + * RudderClient — the main entry point for the LiveOps Web SDK. + * + * Composes the observable domains, the scenario runtime, and the core HTTP + * transport that every service uses to reach the LiveOps API gateway. + * + * The client builds a {@link RudderContext} first and injects it into every + * domain and service, so they depend on that narrow surface rather than on the + * client (which keeps the module graph acyclic). Cross-domain wiring (the + * inventory↔catalog link, purchase invalidation) is done here at construction. + * + * The scenario runtime (engine + plan persistence) is loaded lazily on first + * use — a client that never logs in never pays for the scenario engine. + */ + +import type { + ResolvedRudderClientOptions, + RudderClientOptions, +} from './RudderClientOptions.js'; +import { RudderError, SDK_ERROR_INVALID_OPTIONS } from './RudderError.js'; +import type { RudderContext } from '../core/context.js'; +import { request, type RequestContext } from '../transport/request.js'; +import { createDefaultTokenStore } from '../token/TokenStore.js'; +import { AuthService } from '../auth/AuthService.js'; +import { LeaderboardsService } from '../leaderboards/LeaderboardsService.js'; +import { BattlePassService } from '../battlepass/BattlePassService.js'; +import { QuestsService } from '../quests/QuestsService.js'; +import type { ScenarioService } from '../scenario/ScenarioService.js'; +import type { PlanStateStore } from '../scenario/engine/IndexedDbPlanStore.js'; +import { EffectsCenter, type Effects } from '../effects/EffectsCenter.js'; +import { SyncEngine } from '../state/SyncEngine.js'; +import type { RemoteConfigShape } from '../state/RemoteConfigState.js'; +import { PlayerDomain } from '../domains/PlayerDomain.js'; +import { CatalogDomain } from '../domains/CatalogDomain.js'; +import { InventoryDomain } from '../domains/InventoryDomain.js'; +import { ConfigDomain } from '../domains/ConfigDomain.js'; +import { StorageDomain } from '../domains/StorageDomain.js'; +import { StoresDomain } from '../domains/StoresDomain.js'; + +/** A domain the client can bulk-invalidate (re-login) or reset (logout). */ +type ManagedDomain = { invalidate(): void; reset(): void }; + +export class RudderClient { + public readonly options: ResolvedRudderClientOptions; + + public readonly auth: AuthService; + public readonly leaderboards: LeaderboardsService; + public readonly battlePass: BattlePassService; + public readonly quests: QuestsService; + public readonly effects: Effects; + + // Observable domains: subscribe via `onChange`, read `.data`, or `reload()`. + public readonly player: PlayerDomain; + public readonly catalog: CatalogDomain; + public readonly inventory: InventoryDomain; + public readonly remoteConfig: ConfigDomain; + public readonly storage: StorageDomain; + public readonly stores: StoresDomain; + + private readonly effectsCenter: EffectsCenter; + private scenarioRuntime: ScenarioService | null = null; + private scenarioRuntimePromise: Promise> | null = null; + private readonly syncEngine: SyncEngine; + private readonly loginEvent: string | null; + private readonly ctx: RudderContext; + private readonly transportContext: RequestContext; + private runtimeStarted = false; + + constructor(options: RudderClientOptions) { + if (!options.baseUrl) { + throw new RudderError('RudderClient: baseUrl is required', { + code: SDK_ERROR_INVALID_OPTIONS, + }); + } + if (!options.projectKey) { + throw new RudderError('RudderClient: projectKey is required', { + code: SDK_ERROR_INVALID_OPTIONS, + }); + } + + this.options = { ...options, tokenStore: options.tokenStore ?? createDefaultTokenStore() }; + this.loginEvent = + options.runtime?.loginEvent === undefined ? 'player_login' : options.runtime.loginEvent; + + this.transportContext = { + baseUrl: this.options.baseUrl, + tokenStore: this.options.tokenStore, + requestTimeoutMs: this.options.requestTimeoutMs, + refreshState: {}, + onAuthFailure: () => { + this.auth.notifySessionExpired(); + this.stopRuntimeAfterLogout(); + }, + }; + + this.effectsCenter = new EffectsCenter(this.options.onEffectError); + this.effects = this.effectsCenter; + + this.ctx = { + options: this.options, + effects: this.effectsCenter, + request: (method: string, path: string, body?: unknown) => + request(method, path, body, this.transportContext), + }; + const ctx = this.ctx; + + this.player = new PlayerDomain(ctx); + this.catalog = new CatalogDomain(ctx); + this.inventory = new InventoryDomain(ctx, this.catalog); + this.remoteConfig = new ConfigDomain(ctx); + this.storage = new StorageDomain(ctx); + this.stores = new StoresDomain(ctx, () => { + this.player.invalidate(); + this.inventory.invalidate(); + this.stores.invalidate(); + }); + + this.leaderboards = new LeaderboardsService(ctx); + this.battlePass = new BattlePassService(ctx); + this.quests = new QuestsService(ctx); + this.syncEngine = new SyncEngine(ctx, [ + this.player, + this.catalog, + this.inventory, + this.remoteConfig, + this.storage, + this.stores, + ]); + this.auth = new AuthService( + ctx, + () => this.startRuntimeAfterLogin(), + () => this.stopRuntimeAfterLogout(), + ); + } + + /** + * Tears down all background work: stops the sync poll (and its + * visibilitychange listener), cancels scenario wait timers, and drops cached + * state. Call when disposing the client (e.g. on unmount / HMR) to avoid leaks. + */ + dispose(): void { + this.syncEngine.stop(); + this.scenarioRuntime?.clear(); + this.resetAll(); + this.runtimeStarted = false; + } + + /** @internal */ + async startRuntimeAfterLogin(): Promise { + const runtimePromise = this.ensureScenarioRuntime(); + if (this.runtimeStarted) { + this.scenarioRuntime?.clear(); + this.invalidateAll(); + } + this.runtimeStarted = true; + // These four warms are independent — fetch them concurrently so login + // doesn't pay a serial round-trip per entity. + await Promise.all([ + this.remoteConfig.load(), + this.player.load(), + this.stores.load(), + this.catalog.load(), + ]); + const runtime = await runtimePromise; + await runtime.restore(); + if (this.loginEvent) { + await this.sendRuntimeEvent(runtime, this.loginEvent); + } + this.syncEngine.start(); + } + + /** + * Loads the scenario engine and plan persistence on first use and caches the + * runtime. Dynamic imports keep the engine out of the initial module graph, + * so a thin client never pulls the scenario machinery. + */ + private ensureScenarioRuntime(): Promise> { + this.scenarioRuntimePromise ??= this.createScenarioRuntime(); + return this.scenarioRuntimePromise; + } + + private async createScenarioRuntime(): Promise> { + const { ScenarioService } = await import('../scenario/ScenarioService.js'); + const configured = this.options.runtime?.planStateStore; + let planStore: PlanStateStore | null; + if (configured !== undefined) { + planStore = configured; + } else { + const { createIndexedDbPlanStateStore } = await import( + '../scenario/engine/IndexedDbPlanStore.js' + ); + planStore = createIndexedDbPlanStateStore(); + } + this.scenarioRuntime = new ScenarioService( + this.ctx, + { + player: this.player, + inventory: this.inventory, + config: this.remoteConfig, + stores: this.stores, + }, + this.battlePass, + planStore, + ); + return this.scenarioRuntime; + } + + private async sendRuntimeEvent( + runtime: ScenarioService, + event: string, + ): Promise { + try { + await runtime.send(event); + } catch (error) { + console.warn(`RudderClient: scenario event "${event}" failed after login`, error); + } + } + + /** @internal */ + stopRuntimeAfterLogout(): void { + this.runtimeStarted = false; + this.syncEngine.stop(); + this.scenarioRuntime?.clear(); + this.resetAll(); + } + + private get managedDomains(): ManagedDomain[] { + return [ + this.player, + this.catalog, + this.inventory, + this.remoteConfig, + this.storage, + this.stores, + ]; + } + + /** Re-login: refetch every domain that is in use. */ + private invalidateAll(): void { + for (const domain of this.managedDomains) domain.invalidate(); + } + + /** Logout: drop all cached data. */ + private resetAll(): void { + for (const domain of this.managedDomains) domain.reset(); + } +} + +/** + * Test/internal access to the scenario runtime, which is deliberately not part + * of the public client surface (it is driven through `client.effects`). + * Lazily loads the scenario engine on first call. + * + * @internal + */ +export function getScenarioRuntime( + client: RudderClient, +): Promise> { + return ( + client as unknown as { ensureScenarioRuntime(): Promise> } + ).ensureScenarioRuntime(); +} diff --git a/src/client/RudderClientOptions.ts b/src/client/RudderClientOptions.ts new file mode 100644 index 0000000..00608a0 --- /dev/null +++ b/src/client/RudderClientOptions.ts @@ -0,0 +1,56 @@ +import type { TokenStore } from '../token/TokenStore.js'; +import type { PlanStateStore } from '../scenario/engine/IndexedDbPlanStore.js'; + +/** + * Advanced runtime knobs. Mainly for tests and non-browser hosts; browser + * consumers can ignore these and take the defaults. + */ +export interface RudderRuntimeOptions { + /** + * Scenario plan persistence backend. `undefined` uses the default + * IndexedDB store (created lazily with the scenario runtime); pass `null` + * to disable persistence entirely. + */ + planStateStore?: PlanStateStore | null; + + /** + * Scenario event fired automatically after login. `undefined` uses + * `'player_login'`; pass `null` to fire nothing. + */ + loginEvent?: string | null; +} + +export interface RudderClientOptions { + /** Base URL of the LiveOps API gateway (e.g., "https://api.rudder.build"). */ + baseUrl: string; + + /** Project key that identifies the game project. */ + projectKey: string; + + /** + * Token store for persisting and retrieving authentication tokens. + * Defaults to localStorage with a silent in-memory fallback where + * localStorage is unavailable (SSR, private mode). + */ + tokenStore?: TokenStore; + + /** Per-request timeout in milliseconds (default: 10000). */ + requestTimeoutMs?: number; + + /** Revision sync poll interval in milliseconds (default: 30000, ±20% jitter). */ + syncIntervalMs?: number; + + /** + * Called when an effect handler (or effect preparation) throws. + * Defaults to `console.error`. + */ + onEffectError?: (error: unknown) => void; + + /** Advanced runtime knobs (plan persistence, login event). */ + runtime?: RudderRuntimeOptions; +} + +/** Client options with all defaults resolved — what `client.options` exposes. */ +export interface ResolvedRudderClientOptions extends RudderClientOptions { + tokenStore: TokenStore; +} diff --git a/src/client/RudderError.ts b/src/client/RudderError.ts new file mode 100644 index 0000000..4e5799d --- /dev/null +++ b/src/client/RudderError.ts @@ -0,0 +1,57 @@ +/** + * Typed error hierarchy for the LiveOps Web SDK. + * + * All errors extend `RudderError`. Network errors, HTTP errors, and + * authentication errors have dedicated subclasses for granular handling. + * The machine-readable `code` is the server-provided {@link RudderErrorCode} + * when known, or any other string for unknown servers / SDK-local codes + * (e.g. `sdk/invalid-options` for client configuration errors). + */ + +import type { RudderErrorCode } from '../generated/errors.js'; + +/** Code carried by SDK errors: a known server code, or any other string. */ +export type RudderErrorCodeLike = RudderErrorCode | (string & {}); + +/** Code for client-side configuration validation failures. */ +export const SDK_ERROR_INVALID_OPTIONS = 'sdk/invalid-options'; + +/** Base error class for all SDK errors. */ +export class RudderError extends Error { + public readonly code?: RudderErrorCodeLike; + + constructor(message: string, options?: { cause?: unknown; code?: RudderErrorCodeLike }) { + super(message, options); + this.name = 'RudderError'; + this.code = options?.code; + } +} + +/** Thrown when a network request fails (fetch throws, timeout, DNS, etc.). */ +export class RudderNetworkError extends RudderError { + constructor(cause: unknown) { + super(`Network error: ${cause instanceof Error ? cause.message : String(cause)}`, { cause }); + this.name = 'RudderNetworkError'; + } +} + +/** Thrown when the server returns a non-2xx HTTP status (except 401). */ +export class RudderHttpError extends RudderError { + constructor( + public readonly status: number, + public readonly statusText: string, + public readonly body: string, + code?: RudderErrorCodeLike, + ) { + super(`HTTP ${status} ${statusText}`, { code }); + this.name = 'RudderHttpError'; + } +} + +/** Thrown when the server returns HTTP 401 Unauthorized. */ +export class RudderAuthError extends RudderHttpError { + constructor(statusText: string, body: string, code?: RudderErrorCodeLike) { + super(401, statusText, body, code); + this.name = 'RudderAuthError'; + } +} diff --git a/src/core/context.ts b/src/core/context.ts new file mode 100644 index 0000000..4eb014b --- /dev/null +++ b/src/core/context.ts @@ -0,0 +1,16 @@ +import type { ResolvedRudderClientOptions } from '../client/RudderClientOptions.js'; +import type { EffectsCenter } from '../effects/EffectsCenter.js'; + +/** + * RudderContext — the low-level surface every feature service depends on: + * the HTTP transport, client options, and the effects bus. + * + * Built once by {@link RudderClient} and injected into each service. Services + * depend on this narrow interface instead of on the client itself, so the + * module graph stays acyclic (client → services → context, never back). + */ +export interface RudderContext { + readonly options: ResolvedRudderClientOptions; + readonly effects: EffectsCenter; + request(method: string, path: string, body?: unknown): Promise; +} diff --git a/src/device/DeviceId.ts b/src/device/DeviceId.ts new file mode 100644 index 0000000..43cdb2e --- /dev/null +++ b/src/device/DeviceId.ts @@ -0,0 +1,29 @@ +/** + * Device ID provider — generates and persists a unique device identifier. + * + * Uses `crypto.randomUUID()` (available in all evergreen browsers) and + * stores the generated UUID in localStorage under `rudder_device_id`. + */ + +const STORAGE_KEY = 'rudder_device_id'; + +/** + * Returns the stored device ID or creates a new one if none exists. + * The device ID is persisted in localStorage and survives page reloads. + */ +export function getOrCreateDeviceId(): string { + try { + const existing = localStorage.getItem(STORAGE_KEY); + if (existing) return existing; + } catch { + // localStorage unavailable — generate ephemeral ID. + } + + const id = crypto.randomUUID(); + try { + localStorage.setItem(STORAGE_KEY, id); + } catch { + // Storage unavailable — ID will be ephemeral for this session. + } + return id; +} diff --git a/src/domains/CatalogDomain.ts b/src/domains/CatalogDomain.ts new file mode 100644 index 0000000..fc484c6 --- /dev/null +++ b/src/domains/CatalogDomain.ts @@ -0,0 +1,24 @@ +import { SyncedState } from '../state/SyncedState.js'; +import type { RudderContext } from '../core/context.js'; +import { api } from '../generated/api.js'; +import type { CatalogItem } from '../generated/catalog.js'; + +/** + * CatalogDomain — the item catalog, keyed by slug. Synced under revision + * key `catalog`. Feeds the merged {@link InventoryDomain} view. + */ +export class CatalogDomain extends SyncedState> { + readonly syncKey = 'catalog'; + + constructor(ctx: RudderContext) { + super(async () => { + const response = await api.listCatalogItems(ctx); + const map = new Map(); + for (const item of response.items ?? []) { + if (!item.slug) continue; + map.set(item.slug, item); + } + return map; + }); + } +} diff --git a/src/domains/ConfigDomain.ts b/src/domains/ConfigDomain.ts new file mode 100644 index 0000000..1c7939f --- /dev/null +++ b/src/domains/ConfigDomain.ts @@ -0,0 +1,33 @@ +import { RemoteConfigState, type RemoteConfigShape } from '../state/RemoteConfigState.js'; +import type { RudderContext } from '../core/context.js'; +import { api } from '../generated/api.js'; +import type { RemoteConfig } from '../generated/remote-config.js'; + +/** + * ConfigDomain — typed remote configuration as an observable entity. Synced + * under revision key `config`. Extends {@link RemoteConfigState}, which adds + * typed `get()` and scenario override support. + */ +export class ConfigDomain< + TConfig extends RemoteConfigShape = RemoteConfigShape, +> extends RemoteConfigState { + readonly syncKey = 'config'; + + constructor(ctx: RudderContext) { + super(async () => { + const response = await api.listSdkRemoteConfigs(ctx); + const map = new Map(); + if (response.configs) { + for (const [key, config] of Object.entries(response.configs)) { + // The server already filters inactive configs out (the game cache skips + // !active) and may omit `active` on the wire, so keep anything that is + // not explicitly inactive instead of dropping configs that carry no flag. + if (config && config.active !== false) { + map.set(config.key ?? key, config); + } + } + } + return map; + }); + } +} diff --git a/src/domains/InventoryDomain.ts b/src/domains/InventoryDomain.ts new file mode 100644 index 0000000..7b10a5c --- /dev/null +++ b/src/domains/InventoryDomain.ts @@ -0,0 +1,31 @@ +import { SyncedState } from '../state/SyncedState.js'; +import type { RudderContext } from '../core/context.js'; +import { api } from '../generated/api.js'; +import { mergeInventory, type InventoryItem } from '../state/inventory.js'; +import type { CatalogDomain } from './CatalogDomain.js'; + +/** + * InventoryDomain — owned items merged with their catalog entries. Synced + * under revision key `inventory`; also refreshes when the catalog changes. + */ +export class InventoryDomain extends SyncedState { + readonly syncKey = 'inventory'; + + constructor(ctx: RudderContext, catalog: CatalogDomain) { + super(async () => { + const [response, catalogItems] = await Promise.all([ + api.getInventory(ctx), + catalog.load(), + ]); + return mergeInventory(response.items ?? [], catalogItems); + }); + + // Catalog changes flow into the merged inventory view. + let previousCatalog = catalog.data; + catalog.onChange((snapshot) => { + if (snapshot.data === previousCatalog) return; + previousCatalog = snapshot.data; + this.invalidate(); + }); + } +} diff --git a/src/domains/PlayerDomain.ts b/src/domains/PlayerDomain.ts new file mode 100644 index 0000000..b23a607 --- /dev/null +++ b/src/domains/PlayerDomain.ts @@ -0,0 +1,16 @@ +import { SyncedState } from '../state/SyncedState.js'; +import type { RudderContext } from '../core/context.js'; +import { api } from '../generated/api.js'; +import type { PlayerProfile } from '../generated/player.js'; + +/** + * PlayerDomain — the observable player profile (identity + wallets). + * Synced under revision key `profile`. + */ +export class PlayerDomain extends SyncedState { + readonly syncKey = 'profile'; + + constructor(ctx: RudderContext) { + super(() => api.getPlayerInformation(ctx)); + } +} diff --git a/src/domains/StorageDomain.ts b/src/domains/StorageDomain.ts new file mode 100644 index 0000000..3dc9202 --- /dev/null +++ b/src/domains/StorageDomain.ts @@ -0,0 +1,29 @@ +import { SyncedState } from '../state/SyncedState.js'; +import type { RudderContext } from '../core/context.js'; +import { api } from '../generated/api.js'; +import type { GetStorageResponse, StorageItem } from '../generated/storage.js'; + +/** + * StorageDomain — player key/value storage as an observable entity, plus its + * mutations. Synced under revision key `storage`; mutations invalidate it so + * subscribers observe fresh data. + */ +export class StorageDomain extends SyncedState { + readonly syncKey = 'storage'; + + constructor(private readonly ctx: RudderContext) { + super(() => api.getStorage(ctx, { limit: 100 })); + } + + /** Saves player storage items, then invalidates. */ + async save(items: StorageItem[]): Promise { + await api.updateStorage(this.ctx, { items }); + this.invalidate(); + } + + /** Deletes player storage items of the given type, then invalidates. */ + async delete(type: string): Promise { + await api.deleteStorage(this.ctx, { type }); + this.invalidate(); + } +} diff --git a/src/domains/StoresDomain.ts b/src/domains/StoresDomain.ts new file mode 100644 index 0000000..0542d65 --- /dev/null +++ b/src/domains/StoresDomain.ts @@ -0,0 +1,62 @@ +import { SyncedState } from '../state/SyncedState.js'; +import type { RudderContext } from '../core/context.js'; +import { api } from '../generated/api.js'; +import { ShopHandle, type BuyOptions, type PurchaseOffer } from '../state/shops.js'; +import type { PurchaseOfferResponse } from '../generated/stores.js'; + +/** + * StoresDomain — the observable list of stores, plus the purchase executor + * that every offer/store handle shares. Synced under revision key `stores`. + * + * A successful purchase runs `onPurchase`, which the client wires to invalidate + * the profile, inventory, and store list so subscribers observe fresh data. + */ +export class StoresDomain extends SyncedState { + readonly syncKey = 'stores'; + + /** Shared purchase executor: `stores.purchase(slug, offerId, opts)`. */ + readonly purchase: PurchaseOffer; + + private readonly ctx: RudderContext; + + constructor(ctx: RudderContext, onPurchase: () => void) { + // Built before super() so it captures params, not `this`; the store/offer + // handles created in the loader all delegate to this one executor. + const purchase: PurchaseOffer = async ( + storeSlug: string, + offerId: string, + options: BuyOptions = {}, + ): Promise => { + const response = await api.purchaseOffer( + ctx, + { storeSlug, offerId }, + { + storeSlug, + offerId, + idempotencyKey: options.idempotencyKey ?? crypto.randomUUID(), + }, + ); + onPurchase(); + return response; + }; + + super(async () => { + const response = await api.listSdkStores(ctx); + return (response.stores ?? []).map((store) => new ShopHandle(purchase, store)); + }); + + this.ctx = ctx; + this.purchase = purchase; + } + + /** + * Resolves a store by slug (cache-first). Used by scenario store nodes. + * @internal + */ + async getBySlug(slug: string): Promise { + const cached = this.data?.find((store) => store.slug === slug); + if (cached) return cached; + const store = await api.getStore(this.ctx, { slug }); + return new ShopHandle(this.purchase, store); + } +} diff --git a/src/effects/EffectsCenter.ts b/src/effects/EffectsCenter.ts new file mode 100644 index 0000000..4c7b466 --- /dev/null +++ b/src/effects/EffectsCenter.ts @@ -0,0 +1,277 @@ +import type { PurchaseOfferResponse } from '../generated/stores.js'; +import type { BuyOptions, OfferHandle, ShopHandle } from '../state/shops.js'; +import type { + BattlePassLevelSession, + BattlePassSession, + LeaderboardSession, + NotificationSession, + QuestSession, + StoreSession, + WaitSession, +} from '../scenario/engine/sessions.js'; +import type { PlanRun, ScenarioRunFailedEvent } from '../scenario/engine/types.js'; +import type { + AddBattlePassXpResponse, + ClaimBattlePassRewardResponse, + GetBattlePassProgressResponse, + PurchaseBattlePassPremiumResponse, +} from '../generated/battlepass.js'; + +export type EffectUnsubscribe = () => void; + +export interface NotificationEffect { + readonly title: string; + readonly message: string; + done(): Promise; +} + +export interface StoreOfferEffect { + readonly store: ShopHandle; + readonly offers: readonly OfferHandle[]; + readonly message?: string; + buy(offer: OfferHandle, options?: BuyOptions): Promise; + dismiss(): Promise; +} + +export interface LeaderboardEffect { + end(): Promise; + rewardClaimed(): Promise; +} + +export interface ConfigChangedEffect { + readonly key: string; +} + +/** A scenario wait node became active; the run resumes at `deadlineUtc`. */ +export interface WaitEffect { + readonly deadlineUtc: Date; +} + +/** A scenario run reached a terminal end successfully. */ +export interface ScenarioCompletedEffect { + readonly runId: string; + readonly scenarioId: string; +} + +/** + * A scenario run failed at a node (transport gave up, or the node type is not + * supported by this SDK). Surfaced so the game can react instead of the failure + * being swallowed into a console warning. + */ +export interface ScenarioFailedEffect { + readonly runId: string; + readonly scenarioId: string; + readonly nodeId: string; + readonly error: Error; +} + +/** + * A scenario quest node became active. Report objective progress; the node + * auto-completes server-side once every objective is satisfied. + */ +export interface QuestEffect { + readonly name: string; + readonly objectives: ReadonlyArray>; + reportProgress(objectiveId: string, amount?: number): Promise; +} + +/** A scenario battle pass node became active. */ +export interface BattlePassEffect { + getProgress(): Promise; + addXp(source: string, amount: number): Promise; + claimReward(level: number, track?: string): Promise; + purchasePremium(): Promise; + levelUp(): Promise; + end(): Promise; +} + +/** A scenario battlepass_level node became active (a single claimable tier). */ +export interface BattlePassLevelEffect { + readonly level: number; + claim(): Promise; +} + +type EffectHandler = (effect: TEffect) => void | Promise; + +export interface Effects { + onNotification(handler: EffectHandler): EffectUnsubscribe; + onStoreOffer(handler: EffectHandler): EffectUnsubscribe; + onLeaderboard(handler: EffectHandler): EffectUnsubscribe; + onConfigChanged(handler: EffectHandler): EffectUnsubscribe; + onWait(handler: EffectHandler): EffectUnsubscribe; + onScenarioCompleted(handler: EffectHandler): EffectUnsubscribe; + onScenarioFailed(handler: EffectHandler): EffectUnsubscribe; + onQuest(handler: EffectHandler): EffectUnsubscribe; + onBattlePass(handler: EffectHandler): EffectUnsubscribe; + onBattlePassLevel(handler: EffectHandler): EffectUnsubscribe; +} + +export class EffectsCenter implements Effects { + private readonly notificationHandlers = new Set>(); + private readonly storeOfferHandlers = new Set>(); + private readonly leaderboardHandlers = new Set>(); + private readonly configChangedHandlers = new Set>(); + private readonly waitHandlers = new Set>(); + private readonly scenarioCompletedHandlers = new Set>(); + private readonly scenarioFailedHandlers = new Set>(); + private readonly questHandlers = new Set>(); + private readonly battlePassHandlers = new Set>(); + private readonly battlePassLevelHandlers = new Set>(); + + constructor( + private readonly onError: (error: unknown) => void = (error) => + console.error('[Rudder] Effect handler failed', error), + ) {} + + onNotification(handler: EffectHandler): EffectUnsubscribe { + return this.addHandler(this.notificationHandlers, handler); + } + + onStoreOffer(handler: EffectHandler): EffectUnsubscribe { + return this.addHandler(this.storeOfferHandlers, handler); + } + + onLeaderboard(handler: EffectHandler): EffectUnsubscribe { + return this.addHandler(this.leaderboardHandlers, handler); + } + + onConfigChanged(handler: EffectHandler): EffectUnsubscribe { + return this.addHandler(this.configChangedHandlers, handler); + } + + onWait(handler: EffectHandler): EffectUnsubscribe { + return this.addHandler(this.waitHandlers, handler); + } + + onScenarioCompleted(handler: EffectHandler): EffectUnsubscribe { + return this.addHandler(this.scenarioCompletedHandlers, handler); + } + + onScenarioFailed(handler: EffectHandler): EffectUnsubscribe { + return this.addHandler(this.scenarioFailedHandlers, handler); + } + + onQuest(handler: EffectHandler): EffectUnsubscribe { + return this.addHandler(this.questHandlers, handler); + } + + onBattlePass(handler: EffectHandler): EffectUnsubscribe { + return this.addHandler(this.battlePassHandlers, handler); + } + + onBattlePassLevel(handler: EffectHandler): EffectUnsubscribe { + return this.addHandler(this.battlePassLevelHandlers, handler); + } + + /** @internal */ + emitNotification(session: NotificationSession): void { + this.emit(this.notificationHandlers, { + title: session.title, + message: session.message, + done: () => session.complete(), + }); + } + + /** @internal */ + emitStoreOffer(session: StoreSession): void { + session.getStore() + .then((store) => { + this.emit(this.storeOfferHandlers, { + store, + offers: store.offers, + message: session.get('message', undefined as string | undefined), + buy: (offer, options) => session.buy(offer, options), + dismiss: () => session.decline(), + }); + }) + .catch((error) => this.onError(error)); + } + + /** @internal */ + emitLeaderboard(session: LeaderboardSession): void { + this.emit(this.leaderboardHandlers, { + end: () => session.end(), + rewardClaimed: () => session.rewardClaimed(), + }); + } + + /** @internal */ + emitConfigChanged(key: string): void { + this.emit(this.configChangedHandlers, { key }); + } + + /** @internal */ + emitWait(session: WaitSession): void { + this.emit(this.waitHandlers, { deadlineUtc: session.deadlineUtc }); + } + + /** @internal */ + emitScenarioCompleted(run: PlanRun): void { + this.emit(this.scenarioCompletedHandlers, { + runId: run.runId, + scenarioId: run.scenarioId, + }); + } + + /** @internal */ + emitScenarioFailed(event: ScenarioRunFailedEvent): void { + this.emit(this.scenarioFailedHandlers, { + runId: event.run.runId, + scenarioId: event.run.scenarioId, + nodeId: event.nodeId, + error: event.error, + }); + } + + /** @internal */ + emitQuest(session: QuestSession): void { + this.emit(this.questHandlers, { + name: session.name, + objectives: session.objectives, + reportProgress: (objectiveId, amount) => session.reportProgress(objectiveId, amount), + }); + } + + /** @internal */ + emitBattlePass(session: BattlePassSession): void { + this.emit(this.battlePassHandlers, { + getProgress: () => session.getProgress(), + addXp: (source, amount) => session.addXp(source, amount), + claimReward: (level, track) => session.claimReward(level, track), + purchasePremium: () => session.purchasePremium(), + levelUp: () => session.levelUp(), + end: () => session.end(), + }); + } + + /** @internal */ + emitBattlePassLevel(session: BattlePassLevelSession): void { + this.emit(this.battlePassLevelHandlers, { + level: session.level, + claim: () => session.claim(), + }); + } + + private addHandler( + handlers: Set>, + handler: EffectHandler, + ): EffectUnsubscribe { + handlers.add(handler); + return () => { + handlers.delete(handler); + }; + } + + private emit( + handlers: Set>, + effect: TEffect, + ): void { + for (const handler of handlers) { + try { + Promise.resolve(handler(effect)).catch((error) => this.onError(error)); + } catch (error) { + this.onError(error); + } + } + } +} diff --git a/src/generated/api.ts b/src/generated/api.ts new file mode 100644 index 0000000..f6c7f90 --- /dev/null +++ b/src/generated/api.ts @@ -0,0 +1,228 @@ +// Code generated by apigen. DO NOT EDIT. + +import type { LoginViaDeviceRequest, LoginViaDeviceResponse, RefreshAccessTokenRequest, RefreshAccessTokenResponse } from './auth.js'; +import type { AddBattlePassXpRequest, AddBattlePassXpResponse, ClaimBattlePassRewardRequest, ClaimBattlePassRewardResponse, GetBattlePassProgressRequest, GetBattlePassProgressResponse, PurchaseBattlePassPremiumRequest, PurchaseBattlePassPremiumResponse } from './battlepass.js'; +import type { ListCatalogItemsResponse } from './catalog.js'; +import type { GetInventoryResponse } from './inventory.js'; +import type { GetRankingResponse, SubmitScoreRequest } from './leaderboards.js'; +import type { PlayerProfile } from './player.js'; +import type { ClaimQuestRequest, ClaimQuestResponse, ListQuestsRequest, ListQuestsResponse } from './quests.js'; +import type { ListRemoteConfigsResponse, RemoteConfig } from './remote-config.js'; +import type { GetScenarioRunRequest, GetScenarioRunResponse, HandleScenarioCallbackRequest, HandleScenarioCallbackResponse, TriggerScenarioRequest, TriggerScenarioResponse, UpdateScenarioCounterRequest, UpdateScenarioCounterResponse } from './scenarios.js'; +import type { GetStorageResponse, UpdateStorageRequest } from './storage.js'; +import type { ListStoresResponse, PurchaseOfferRequest, PurchaseOfferResponse, Store } from './stores.js'; +import type { DeleteUgcResponse, GetDownloadUrlResponse, GetUploadUrlResponse, ListUgcResponse, SubmitUgcRequest, UgcSubmission } from './ugc.js'; + +/** The minimal transport contract the generated API functions call through. */ +export interface Transport { + request(method: string, path: string, body?: unknown): Promise; +} + +function enc(value: string | number): string { + return encodeURIComponent(String(value)); +} + +function qs( + params: Array<[string, string | number | boolean | null | undefined]>, +): string { + const parts: string[] = []; + for (const [key, value] of params) { + if (value !== null && value !== undefined && value !== '') { + parts.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`); + } + } + return parts.length === 0 ? '' : `?${parts.join('&')}`; +} + +/** Typed request functions, one per SDK operation. */ +export const api = { + addBattlePassXp: ( + t: Transport, + body: AddBattlePassXpRequest, + ): Promise => + t.request('POST', '/sdk/v1/battlepass/xp', body), + + claimBattlePassReward: ( + t: Transport, + body: ClaimBattlePassRewardRequest, + ): Promise => + t.request('POST', '/sdk/v1/battlepass/claim', body), + + claimQuest: ( + t: Transport, + body: ClaimQuestRequest, + ): Promise => + t.request('POST', '/sdk/v1/quests/claim', body), + + deleteStorage: ( + t: Transport, + query?: { type?: string }, + ): Promise => + t.request('DELETE', `/sdk/v1/storage${qs([['type', query?.type]])}`), + + deleteUgc: ( + t: Transport, + path: { id: string }, + ): Promise => + t.request('DELETE', `/sdk/v1/ugc/${enc(path.id)}`), + + getBattlePassProgress: ( + t: Transport, + body: GetBattlePassProgressRequest, + ): Promise => + t.request('POST', '/sdk/v1/battlepass/progress', body), + + getDownloadUrl: ( + t: Transport, + path: { id: string }, + ): Promise => + t.request('GET', `/sdk/v1/ugc/${enc(path.id)}/download`), + + getInventory: ( + t: Transport, + ): Promise => + t.request('GET', '/sdk/v1/inventory'), + + getPlayerInformation: ( + t: Transport, + ): Promise => + t.request('GET', '/sdk/v1/player/information'), + + getRanking: ( + t: Transport, + path: { slug: string }, + query?: { limit?: number }, + ): Promise => + t.request('GET', `/sdk/v1/leaderboards/${enc(path.slug)}/ranking${qs([['limit', query?.limit]])}`), + + getRemoteConfig: ( + t: Transport, + path: { key: string }, + ): Promise => + t.request('GET', `/sdk/v1/remote-configs/${enc(path.key)}`), + + getRevisions: ( + t: Transport, + ): Promise<{ [key: string]: number }> => + t.request<{ [key: string]: number }>('GET', '/sdk/v1/sync'), + + getScenarioRun: ( + t: Transport, + body: GetScenarioRunRequest, + ): Promise => + t.request('POST', '/sdk/v1/scenarios/run', body), + + getStorage: ( + t: Transport, + query?: { types?: string; limit?: number; cursor?: string }, + ): Promise => + t.request('GET', `/sdk/v1/storage${qs([['types', query?.types], ['limit', query?.limit], ['cursor', query?.cursor]])}`), + + getStore: ( + t: Transport, + path: { slug: string }, + ): Promise => + t.request('GET', `/sdk/v1/stores/${enc(path.slug)}`), + + getUgc: ( + t: Transport, + path: { id: string }, + ): Promise => + t.request('GET', `/sdk/v1/ugc/${enc(path.id)}`), + + getUploadUrl: ( + t: Transport, + query?: { filename?: string }, + ): Promise => + t.request('GET', `/sdk/v1/ugc/upload-url${qs([['filename', query?.filename]])}`), + + handleScenarioCallback: ( + t: Transport, + body: HandleScenarioCallbackRequest, + ): Promise => + t.request('POST', '/sdk/v1/scenarios/callback', body), + + listCatalogItems: ( + t: Transport, + ): Promise => + t.request('GET', '/sdk/v1/catalog'), + + listQuests: ( + t: Transport, + body: ListQuestsRequest, + ): Promise => + t.request('POST', '/sdk/v1/quests/list', body), + + listSdkRemoteConfigs: ( + t: Transport, + ): Promise => + t.request('GET', '/sdk/v1/remote-configs'), + + listSdkStores: ( + t: Transport, + ): Promise => + t.request('GET', '/sdk/v1/stores'), + + listUgc: ( + t: Transport, + query?: { status?: string; limit?: number; cursor?: string }, + ): Promise => + t.request('GET', `/sdk/v1/ugc${qs([['status', query?.status], ['limit', query?.limit], ['cursor', query?.cursor]])}`), + + loginViaDevice: ( + t: Transport, + body: LoginViaDeviceRequest, + ): Promise => + t.request('POST', '/sdk/v1/authorization/device', body), + + purchaseBattlePassPremium: ( + t: Transport, + body: PurchaseBattlePassPremiumRequest, + ): Promise => + t.request('POST', '/sdk/v1/battlepass/premium', body), + + purchaseOffer: ( + t: Transport, + path: { storeSlug: string; offerId: string }, + body: PurchaseOfferRequest, + ): Promise => + t.request('POST', `/sdk/v1/stores/${enc(path.storeSlug)}/offers/${enc(path.offerId)}/purchase`, body), + + refreshAccessToken: ( + t: Transport, + body: RefreshAccessTokenRequest, + ): Promise => + t.request('POST', '/sdk/v1/authorization/refresh', body), + + submitScore: ( + t: Transport, + path: { slug: string }, + body: SubmitScoreRequest, + ): Promise => + t.request('POST', `/sdk/v1/leaderboards/${enc(path.slug)}/submit-score`, body), + + submitUgc: ( + t: Transport, + body: SubmitUgcRequest, + ): Promise => + t.request('POST', '/sdk/v1/ugc', body), + + triggerScenario: ( + t: Transport, + body: TriggerScenarioRequest, + ): Promise => + t.request('POST', '/sdk/v1/scenarios/trigger', body), + + updateScenarioCounter: ( + t: Transport, + body: UpdateScenarioCounterRequest, + ): Promise => + t.request('POST', '/sdk/v1/scenarios/counter', body), + + updateStorage: ( + t: Transport, + body: UpdateStorageRequest, + ): Promise => + t.request('PUT', '/sdk/v1/storage', body), + +}; diff --git a/src/generated/auth.ts b/src/generated/auth.ts new file mode 100644 index 0000000..3885fb1 --- /dev/null +++ b/src/generated/auth.ts @@ -0,0 +1,24 @@ +// Code generated by apigen. DO NOT EDIT. + +export interface LoginViaDeviceRequest { + "deviceId"?: string; + "key"?: string; + "language"?: string; + "nickname"?: string; + "region"?: string; +} + +export interface LoginViaDeviceResponse { + "accessToken"?: string; + "refreshToken"?: string; +} + +export interface RefreshAccessTokenRequest { + "refreshToken"?: string; +} + +export interface RefreshAccessTokenResponse { + "accessToken"?: string; + "refreshToken"?: string; +} + diff --git a/src/generated/battlepass.ts b/src/generated/battlepass.ts new file mode 100644 index 0000000..68c9d74 --- /dev/null +++ b/src/generated/battlepass.ts @@ -0,0 +1,71 @@ +// Code generated by apigen. DO NOT EDIT. + +import type { ExecutionPlan } from './common.js'; + +export interface AddBattlePassXpRequest { + "amount"?: number; + "nodeId"?: string; + "runId"?: string; + "scenarioId"?: string; + "source"?: string; +} + +export interface AddBattlePassXpResponse { + "level"?: number; + "leveledUp"?: boolean; + "maxLevel"?: boolean; + "plan"?: ExecutionPlan; + "xp"?: number; +} + +export interface BattlePassReward { + "amount"?: number; + "currency"?: string; + "itemId"?: string; +} + +export interface ClaimBattlePassRewardRequest { + "level"?: number; + "nodeId"?: string; + "runId"?: string; + "scenarioId"?: string; + "track"?: string; +} + +export interface ClaimBattlePassRewardResponse { + "alreadyClaimed"?: boolean; + "error"?: string; + "granted"?: BattlePassReward[]; + "success"?: boolean; +} + +export interface ClaimedTier { + "level"?: number; + "track"?: string; +} + +export interface GetBattlePassProgressRequest { + "nodeId"?: string; + "scenarioId"?: string; +} + +export interface GetBattlePassProgressResponse { + "claimedTiers"?: ClaimedTier[]; + "level"?: number; + "premiumOwned"?: boolean; + "xp"?: number; +} + +export interface PurchaseBattlePassPremiumRequest { + "idempotencyKey"?: string; + "nodeId"?: string; + "runId"?: string; + "scenarioId"?: string; +} + +export interface PurchaseBattlePassPremiumResponse { + "error"?: string; + "plan"?: ExecutionPlan; + "success"?: boolean; +} + diff --git a/src/generated/catalog.ts b/src/generated/catalog.ts new file mode 100644 index 0000000..c6d996d --- /dev/null +++ b/src/generated/catalog.ts @@ -0,0 +1,13 @@ +// Code generated by apigen. DO NOT EDIT. + +export interface CatalogItem { + "name"?: string; + "properties"?: { [key: string]: unknown }; + "slug"?: string; + "tags"?: string[]; +} + +export interface ListCatalogItemsResponse { + "items"?: CatalogItem[]; +} + diff --git a/src/generated/common.ts b/src/generated/common.ts new file mode 100644 index 0000000..586986e --- /dev/null +++ b/src/generated/common.ts @@ -0,0 +1,44 @@ +// Code generated by apigen. DO NOT EDIT. + +export interface BoundaryNode { + "callbackUrl"?: string; + "enforcement"?: "client" | "server"; + "enteredAt"?: string; + "nodeId"?: string; + "sourceHandle"?: string; + "sourceNodeId"?: string; + "waitDeadline"?: string; +} + +export interface ErrorResponse { + "code"?: "early_completion" | "forbidden" | "level_not_reached" | "node_not_active" | "objectives_incomplete" | "run_expired" | "run_not_active" | "scenario_not_active" | "unknown_run"; + "error"?: string; + "requestId"?: string; +} + +export interface ExecutionPlan { + "boundaryNodes"?: BoundaryNode[]; + "context"?: { [key: string]: unknown }; + "edges"?: PlanEdge[]; + "nodes"?: ExecutionPlanNode[]; + "planId"?: string; + "runId"?: string; + "scenarioId"?: string; + "startNodeId"?: string; + "userId"?: string; +} + +export interface ExecutionPlanNode { + "data"?: { [key: string]: unknown }; + "id"?: string; + "type"?: string; +} + +export interface PlanEdge { + "id"?: string; + "source"?: string; + "sourceHandle"?: string; + "target"?: string; + "targetHandle"?: string; +} + diff --git a/src/generated/errors.ts b/src/generated/errors.ts new file mode 100644 index 0000000..7886264 --- /dev/null +++ b/src/generated/errors.ts @@ -0,0 +1,15 @@ +// Code generated by apigen. DO NOT EDIT. + +export type RudderErrorCode = "early_completion" | "forbidden" | "level_not_reached" | "node_not_active" | "objectives_incomplete" | "run_expired" | "run_not_active" | "scenario_not_active" | "unknown_run"; + +export const RudderErrorCodes = { + earlyCompletion: "early_completion", + forbidden: "forbidden", + levelNotReached: "level_not_reached", + nodeNotActive: "node_not_active", + objectivesIncomplete: "objectives_incomplete", + runExpired: "run_expired", + runNotActive: "run_not_active", + scenarioNotActive: "scenario_not_active", + unknownRun: "unknown_run", +} as const; diff --git a/src/generated/index.ts b/src/generated/index.ts new file mode 100644 index 0000000..766db4e --- /dev/null +++ b/src/generated/index.ts @@ -0,0 +1,17 @@ +// Code generated by apigen. DO NOT EDIT. + +export * from './auth.js'; +export * from './battlepass.js'; +export * from './catalog.js'; +export * from './common.js'; +export * from './inventory.js'; +export * from './leaderboards.js'; +export * from './player.js'; +export * from './quests.js'; +export * from './remote-config.js'; +export * from './scenarios.js'; +export * from './storage.js'; +export * from './stores.js'; +export * from './ugc.js'; +export * from './api.js'; +export * from './errors.js'; diff --git a/src/generated/inventory.ts b/src/generated/inventory.ts new file mode 100644 index 0000000..92a02ff --- /dev/null +++ b/src/generated/inventory.ts @@ -0,0 +1,14 @@ +// Code generated by apigen. DO NOT EDIT. + +export interface GetInventoryResponse { + "items"?: PlayerInventoryItem[]; +} + +export interface PlayerInventoryItem { + "amount"?: number; + "nameOverride"?: string; + "propertiesOverride"?: { [key: string]: unknown }; + "slug"?: string; + "updatedAt"?: string; +} + diff --git a/src/generated/leaderboards.ts b/src/generated/leaderboards.ts new file mode 100644 index 0000000..e7f995c --- /dev/null +++ b/src/generated/leaderboards.ts @@ -0,0 +1,19 @@ +// Code generated by apigen. DO NOT EDIT. + +export interface GetRankingResponse { + "entries"?: RankEntry[]; + "total"?: number; +} + +export interface RankEntry { + "playerId"?: string; + "playerName"?: string; + "rank"?: number; + "score"?: number; +} + +export interface SubmitScoreRequest { + "score"?: number; + "slug"?: string; +} + diff --git a/src/generated/player.ts b/src/generated/player.ts new file mode 100644 index 0000000..dd934d4 --- /dev/null +++ b/src/generated/player.ts @@ -0,0 +1,21 @@ +// Code generated by apigen. DO NOT EDIT. + +export interface Player { + "createdAt"?: string; + "id"?: string; + "language"?: string; + "nickname"?: string; + "projectId"?: string; + "region"?: string; +} + +export interface PlayerProfile { + "player"?: Player; + "wallets"?: Wallet[]; +} + +export interface Wallet { + "balance"?: number; + "currency"?: string; +} + diff --git a/src/generated/quests.ts b/src/generated/quests.ts new file mode 100644 index 0000000..fbb7f4e --- /dev/null +++ b/src/generated/quests.ts @@ -0,0 +1,42 @@ +// Code generated by apigen. DO NOT EDIT. + +export interface ClaimQuestRequest { + "questId"?: string; +} + +export interface ClaimQuestResponse { + "alreadyClaimed"?: boolean; + "error"?: string; + "granted"?: QuestReward[]; + "success"?: boolean; +} + +export interface ListQuestsRequest { +} + +export interface ListQuestsResponse { + "quests"?: Quest[]; +} + +export interface Quest { + "id"?: string; + "name"?: string; + "objectives"?: QuestObjectiveProgress[]; + "rewards"?: QuestReward[]; + "status"?: string; +} + +export interface QuestObjectiveProgress { + "completed"?: boolean; + "current"?: number; + "metric"?: string; + "objectiveId"?: string; + "target"?: number; +} + +export interface QuestReward { + "amount"?: number; + "currency"?: string; + "itemId"?: string; +} + diff --git a/src/generated/remote-config.ts b/src/generated/remote-config.ts new file mode 100644 index 0000000..5023e90 --- /dev/null +++ b/src/generated/remote-config.ts @@ -0,0 +1,19 @@ +// Code generated by apigen. DO NOT EDIT. + +export interface ListRemoteConfigsResponse { + "configs"?: { [key: string]: RemoteConfig }; +} + +export interface RemoteConfig { + "active"?: boolean; + "createdAt"?: string; + "description"?: string; + "environment"?: string; + "id"?: string; + "key"?: string; + "projectId"?: string; + "updatedAt"?: string; + "value"?: string; + "valueType"?: string; +} + diff --git a/src/generated/scenarios.ts b/src/generated/scenarios.ts new file mode 100644 index 0000000..bc9d143 --- /dev/null +++ b/src/generated/scenarios.ts @@ -0,0 +1,46 @@ +// Code generated by apigen. DO NOT EDIT. + +import type { ExecutionPlan } from './common.js'; + +export interface GetScenarioRunRequest { + "runId"?: string; +} + +export interface GetScenarioRunResponse { + "plan"?: ExecutionPlan; + "runId"?: string; + "status"?: string; +} + +export interface HandleScenarioCallbackRequest { + "handle"?: string; + "nodeId"?: string; + "runId"?: string; + "scenarioId"?: string; +} + +export interface HandleScenarioCallbackResponse { + "plan"?: ExecutionPlan; +} + +export interface TriggerScenarioRequest { + "event"?: string; +} + +export interface TriggerScenarioResponse { + "plans"?: ExecutionPlan[]; +} + +export interface UpdateScenarioCounterRequest { + "amount"?: number; + "counterKey"?: string; + "nodeId"?: string; + "runId"?: string; + "scenarioId"?: string; +} + +export interface UpdateScenarioCounterResponse { + "completed"?: boolean; + "plan"?: ExecutionPlan; +} + diff --git a/src/generated/storage.ts b/src/generated/storage.ts new file mode 100644 index 0000000..28133c9 --- /dev/null +++ b/src/generated/storage.ts @@ -0,0 +1,18 @@ +// Code generated by apigen. DO NOT EDIT. + +export interface GetStorageResponse { + "items"?: StorageItem[]; + "nextCursor"?: string; +} + +export interface StorageItem { + "data"?: string; + "id"?: string; + "type"?: string; +} + +export interface UpdateStorageRequest { + "idempotencyKey"?: string; + "items"?: StorageItem[]; +} + diff --git a/src/generated/stores.ts b/src/generated/stores.ts new file mode 100644 index 0000000..8e372a9 --- /dev/null +++ b/src/generated/stores.ts @@ -0,0 +1,54 @@ +// Code generated by apigen. DO NOT EDIT. + +export interface ListStoresResponse { + "stores"?: Store[]; + "total"?: number; +} + +export interface Offer { + "contents"?: OfferContent[]; + "createdAt"?: string; + "id"?: string; + "maxPurchases"?: number; + "name"?: string; + "price"?: OfferPrice; + "updatedAt"?: string; +} + +export interface OfferContent { + "amount"?: number; + "itemId"?: string; +} + +export interface OfferPrice { + "amount"?: number; + "currency"?: string; +} + +export interface PurchaseOfferRequest { + "idempotencyKey"?: string; + "offerId"?: string; + "storeSlug"?: string; +} + +export interface PurchaseOfferResponse { + "error"?: string; + "purchaseId"?: string; + "success"?: boolean; +} + +export interface Store { + "createdAt"?: string; + "data"?: { [key: string]: unknown }; + "description"?: string; + "environment"?: string; + "id"?: string; + "name"?: string; + "offers"?: Offer[]; + "projectId"?: string; + "scenarioId"?: string; + "slug"?: string; + "status"?: string; + "updatedAt"?: string; +} + diff --git a/src/generated/ugc.ts b/src/generated/ugc.ts new file mode 100644 index 0000000..a4ab08f --- /dev/null +++ b/src/generated/ugc.ts @@ -0,0 +1,47 @@ +// Code generated by apigen. DO NOT EDIT. + +export interface DeleteUgcResponse { + "success"?: boolean; +} + +export interface GetDownloadUrlResponse { + "downloadUrl"?: string; +} + +export interface GetUploadUrlResponse { + "fileKey"?: string; + "uploadUrl"?: string; +} + +export interface ListUgcResponse { + "items"?: UgcSubmission[]; + "nextCursor"?: string; +} + +export interface SubmitUgcRequest { + "description"?: string; + "fileKey"?: string; + "fileSize"?: number; + "metadata"?: { [key: string]: unknown }; + "name"?: string; +} + +export interface SubmittedBy { + "userId"?: string; + "username"?: string; +} + +export interface UgcSubmission { + "description"?: string; + "fileSize"?: number; + "fileUrl"?: string; + "id"?: string; + "metadata"?: { [key: string]: unknown }; + "name"?: string; + "reviewedAt"?: string; + "reviewedBy"?: string; + "status"?: string; + "submittedAt"?: string; + "submittedBy"?: SubmittedBy; +} + diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..f935bd8 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,103 @@ +// @rudder/web-sdk — LiveOps Web SDK for browser games + +// Client +export { RudderClient } from './client/RudderClient.js'; +export type { RudderClientOptions } from './client/RudderClientOptions.js'; +export { + RudderError, + RudderNetworkError, + RudderHttpError, + RudderAuthError, + SDK_ERROR_INVALID_OPTIONS, +} from './client/RudderError.js'; +export type { RudderErrorCodeLike } from './client/RudderError.js'; + +// Token management +export type { TokenStore } from './token/TokenStore.js'; +export { createDefaultTokenStore, createLocalStorageTokenStore } from './token/TokenStore.js'; + +// Services — types only; instances live on the client +// (client.auth, client.leaderboards, client.battlePass, client.quests). +export type { + AuthService, + AuthState, + AuthStateListener, + LoginWithDeviceOptions, +} from './auth/AuthService.js'; +export type { LeaderboardsService, LeaderboardHandle } from './leaderboards/LeaderboardsService.js'; +export type { BattlePassService } from './battlepass/BattlePassService.js'; +export type { QuestsService } from './quests/QuestsService.js'; + +// Observable state primitives +export { SyncedState } from './state/SyncedState.js'; +export type { + SyncedSnapshot, + SyncedStatus, + SyncedListener, +} from './state/SyncedState.js'; +export { RemoteConfigState } from './state/RemoteConfigState.js'; +export type { RemoteConfigShape } from './state/RemoteConfigState.js'; +export { ShopHandle, OfferHandle } from './state/shops.js'; +export type { BuyOptions } from './state/shops.js'; +export type { InventoryItem } from './state/inventory.js'; + +// Domains — types only; instances live on the client +// (client.player, client.stores, client.remoteConfig, …). +export type { PlayerDomain } from './domains/PlayerDomain.js'; +export type { CatalogDomain } from './domains/CatalogDomain.js'; +export type { InventoryDomain } from './domains/InventoryDomain.js'; +export type { ConfigDomain } from './domains/ConfigDomain.js'; +export type { StorageDomain } from './domains/StorageDomain.js'; +export type { StoresDomain } from './domains/StoresDomain.js'; + +// Effects +export type { + ConfigChangedEffect, + Effects, + EffectUnsubscribe, + LeaderboardEffect, + NotificationEffect, + StoreOfferEffect, + WaitEffect, + ScenarioCompletedEffect, + ScenarioFailedEffect, + QuestEffect, + BattlePassEffect, + BattlePassLevelEffect, +} from './effects/EffectsCenter.js'; + +// Generated wire types — the shapes returned by the observable state and +// handles, so consumers can name them instead of re-declaring mirrors. +export type { Player, PlayerProfile, Wallet } from './generated/player.js'; +export type { CatalogItem } from './generated/catalog.js'; +export type { PlayerInventoryItem } from './generated/inventory.js'; +export type { + Store, + Offer, + OfferContent, + OfferPrice, + PurchaseOfferResponse, +} from './generated/stores.js'; +export type { RankEntry } from './generated/leaderboards.js'; +export type { StorageItem, GetStorageResponse } from './generated/storage.js'; +export type { RemoteConfig } from './generated/remote-config.js'; +export type { + Quest, + QuestObjectiveProgress, + QuestReward, + ClaimQuestResponse, +} from './generated/quests.js'; +export type { + BattlePassReward, + ClaimedTier, + AddBattlePassXpRequest, + AddBattlePassXpResponse, + ClaimBattlePassRewardRequest, + ClaimBattlePassRewardResponse, + PurchaseBattlePassPremiumRequest, + PurchaseBattlePassPremiumResponse, + GetBattlePassProgressRequest, + GetBattlePassProgressResponse, +} from './generated/battlepass.js'; +export type { RudderErrorCode } from './generated/errors.js'; +export { RudderErrorCodes } from './generated/errors.js'; diff --git a/src/leaderboards/LeaderboardsService.ts b/src/leaderboards/LeaderboardsService.ts new file mode 100644 index 0000000..151852e --- /dev/null +++ b/src/leaderboards/LeaderboardsService.ts @@ -0,0 +1,52 @@ +import type { RudderContext } from '../core/context.js'; +import { api } from '../generated/api.js'; +import type { RankEntry } from '../generated/leaderboards.js'; + +/** + * A cached handle for a specific leaderboard. + * + * Created by `LeaderboardsService.findBySlug()` — subsequent calls with + * the same slug return the same handle, avoiding redundant fetches. + */ +export class LeaderboardHandle { + private entries: RankEntry[] = []; + + constructor( + public readonly slug: string, + private readonly ctx: RudderContext, + ) {} + + getEntries(): readonly RankEntry[] { + return this.entries; + } + + async submit(score: number): Promise { + return api.submitScore(this.ctx, { slug: this.slug }, { slug: this.slug, score }); + } + + async list(limit = 100): Promise { + const response = await api.getRanking( + this.ctx, + { slug: this.slug }, + { limit: limit > 0 ? limit : undefined }, + ); + this.entries = response.entries ?? []; + return this.entries; + } +} + +export class LeaderboardsService { + private readonly cache = new Map(); + + constructor(private readonly ctx: RudderContext) {} + + /** Returns a cached handle for the given leaderboard slug. */ + findBySlug(slug: string): LeaderboardHandle { + let handle = this.cache.get(slug); + if (!handle) { + handle = new LeaderboardHandle(slug, this.ctx); + this.cache.set(slug, handle); + } + return handle; + } +} diff --git a/src/quests/QuestsService.ts b/src/quests/QuestsService.ts new file mode 100644 index 0000000..de7987f --- /dev/null +++ b/src/quests/QuestsService.ts @@ -0,0 +1,26 @@ +/** + * QuestsService — call-and-response access to the global quest endpoints. + * + * These are the player's global quests (list + claim), distinct from scenario + * quest nodes (which advance via {@link QuestSession}). Global quests have no + * sync-revision key, so this is a plain service; re-list after a claim. + */ + +import type { RudderContext } from '../core/context.js'; +import { api } from '../generated/api.js'; +import type { Quest, ClaimQuestResponse } from '../generated/quests.js'; + +export class QuestsService { + constructor(private readonly ctx: RudderContext) {} + + /** Lists the player's quests with per-objective progress and rewards. */ + async list(): Promise { + const response = await api.listQuests(this.ctx, {}); + return response.quests ?? []; + } + + /** Claims a completed quest's rewards (idempotent server-side). */ + claim(questId: string): Promise { + return api.claimQuest(this.ctx, { questId }); + } +} diff --git a/src/scenario/ScenarioService.ts b/src/scenario/ScenarioService.ts new file mode 100644 index 0000000..06d79ad --- /dev/null +++ b/src/scenario/ScenarioService.ts @@ -0,0 +1,593 @@ +import type { RudderContext } from '../core/context.js'; +import type { RemoteConfigShape, RemoteConfigState } from '../state/RemoteConfigState.js'; +import type { ShopHandle } from '../state/shops.js'; +import { api } from '../generated/api.js'; +import type { TriggerScenarioResponse } from '../generated/scenarios.js'; +import type { ExecutionPlan } from '../generated/common.js'; +import type { PlanStateStore } from './engine/IndexedDbPlanStore.js'; + +/** + * The subset of domains the scenario runtime drives: it invalidates the player + * and inventory after server mutations, patches remote config for override + * nodes, and resolves stores for store nodes. + */ +export interface ScenarioDomains { + readonly player: { invalidate(): void }; + readonly inventory: { invalidate(): void }; + readonly config: RemoteConfigState; + readonly stores: { getBySlug(slug: string): Promise }; +} +import { + RudderNetworkError, + RudderHttpError, +} from '../client/RudderError.js'; + +import { + findNode, + matchingEdges, + matchingBoundaryNodes, + completedHandleKey, + durationToMs, +} from './engine/DagWalker.js'; +import { RuntimeRun, PlanRun, type ActiveNodeState, type ScenarioRunFailedEvent } from './engine/types.js'; +import { + ScenarioNodeContext, + NotificationSession, + WaitSession, + StoreSession, + LeaderboardSession, + QuestSession, + BattlePassSession, + BattlePassLevelSession, +} from './engine/sessions.js'; +import type { BattlePassService } from '../battlepass/BattlePassService.js'; + +/** + * Error thrown when a boundary HTTP call fails with a transient error + * (network failure or server 5xx). The caller should NOT advance the run; + * the node stays active and the handle stays pending for retry on reconnect. + */ +class TransientBoundaryError extends Error { + constructor(public readonly inner: unknown) { + super('Transient boundary error'); + this.name = 'TransientBoundaryError'; + } +} + +/** Returns true for network / 5xx errors that may succeed on retry. */ +function isTransientHttpError(err: unknown): boolean { + if (err instanceof RudderNetworkError) return true; + if (err instanceof RudderHttpError && err.status >= 500) return true; + return false; +} + + +export class ScenarioService { + private readonly runs = new Map(); + private readonly waitTimers = new Map>(); + + constructor( + private readonly ctx: RudderContext, + private readonly domains: ScenarioDomains, + private readonly battlePass: BattlePassService, + private readonly planStore: PlanStateStore | null, + ) {} + + // ---- Public Events (subscribe to receive node dispatches) ---- + + onNotification?: (session: NotificationSession) => void; + onWait?: (session: WaitSession) => void; + onStore?: (session: StoreSession) => void; + onLeaderboard?: (session: LeaderboardSession) => void; + onRunCompleted?: (run: PlanRun) => void; + onRunFailed?: (event: ScenarioRunFailedEvent) => void; + onCompleted?: () => void; + + get isRunning(): boolean { + return this.runs.size > 0; + } + + get activeRuns(): readonly PlanRun[] { + return [...this.runs.values()].map((r) => this.toPlanRun(r)); + } + + // ---- Public API ---- + + /** Triggers scenarios by event name. Returns all started plans. */ + async send(eventName: string): Promise { + const response = await api.triggerScenario(this.ctx, { event: eventName }); + this.domains.player.invalidate(); + this.domains.inventory.invalidate(); + this.startPlans(response.plans ?? []); + return response; + } + + /** Completes the current active node with the given handle. */ + async respond(handle: string): Promise { + for (const run of this.runs.values()) { + for (const nodeId of run.activeNodes.keys()) { + await this.completeNodeAsync(run.runId, nodeId, handle); + return; + } + } + } + + /** + * Restores persisted scenario state from IndexedDB. + * Call once after constructing the client. + */ + async restore(): Promise { + const store = this.planStore; + if (!store) return; + try { + await store.load(); + const json = store.state; + if (!json) return; + const persisted = JSON.parse(json) as { runs: Array<{ + runId: string; + plan: ExecutionPlan; + activeNodes: ActiveNodeState[]; + completedHandles: string[]; + }> }; + for (const saved of persisted.runs ?? []) { + let plan = saved.plan; + let activeNodes = saved.activeNodes; + let completedHandles = saved.completedHandles; + + try { + const response = await api.getScenarioRun(this.ctx, { runId: saved.runId }); + + if (response?.status === 'unknown_run' || response?.status === 'expired') { + continue; + } + + if (response?.plan) { + plan = response.plan; + activeNodes = []; + completedHandles = []; + } + } catch { + // Network/server error — fall back to persisted local state. + } + + const run = new RuntimeRun(saved.runId, plan); + for (const handle of completedHandles ?? []) { + run.completedHandles.add(handle); + } + this.runs.set(run.runId, run); + if (activeNodes.length > 0) { + for (const nodeState of activeNodes) { + run.activeNodes.set(nodeState.nodeId, nodeState); + this.dispatchActiveNode(run, nodeState, true); + } + } else if (plan?.nodes?.length) { + const startNode = findNode(plan, plan.startNodeId) ?? plan.nodes[0]; + if (startNode?.id) { + this.activateNode(run, startNode.id); + } + } + } + await this.persist(); + } catch { + // Corrupted state — clear and continue. + this.clear(); + } + } + + /** Clears all active runs and persisted state. */ + clear(): void { + for (const timer of this.waitTimers.values()) { + clearTimeout(timer); + } + this.waitTimers.clear(); + this.runs.clear(); + this.persist().catch(() => {}); + } + + // ---- Internal: Plan lifecycle ---- + + private startPlans(plans: ExecutionPlan[]): void { + for (const plan of plans ?? []) { + this.startPlan(plan); + } + } + + private startPlan(plan: ExecutionPlan): void { + if (!plan.nodes?.length) return; + if (plan.boundaryNodes?.length && !plan.runId) { + throw new Error('ExecutionPlan has boundaryNodes but missing runId'); + } + // Dedup: server returned a plan with a runId already active — skip without + // restarting wait timers or re-dispatching node sessions. + if (plan.runId && this.runs.has(plan.runId)) return; + const startNode = findNode(plan, plan.startNodeId) ?? plan.nodes[0]; + if (!startNode?.id) return; + const runId = plan.runId ?? crypto.randomUUID().replace(/-/g, ''); + const run = new RuntimeRun(runId, plan); + this.runs.set(run.runId, run); + this.activateNode(run, startNode.id); + this.persist().catch(() => {}); + } + + private activateNode( + run: RuntimeRun, + nodeId: string, + restoredState?: ActiveNodeState, + ): void { + const node = findNode(run.plan, nodeId); + if (!node?.id) return; + const state = restoredState ?? { nodeId }; + run.activeNodes.set(nodeId, state); + this.dispatchActiveNode(run, state, restoredState !== undefined); + } + + // ---- Internal: Node Dispatch ---- + + private dispatchActiveNode( + run: RuntimeRun, + state: ActiveNodeState, + restored: boolean, + ): void { + const node = findNode(run.plan, state.nodeId); + if (!node) { + run.activeNodes.delete(state.nodeId); + this.checkRunCompleted(run); + return; + } + + const ctx = new ScenarioNodeContext( + this.toPlanRun(run), + node, + (rid, nid, h) => this.completeNodeInternal(rid, nid, h, true), + (rid, nid, ck, amt) => this.updateProgressInternal(rid, nid, ck, amt), + ); + + switch (node.type) { + case 'wait': + this.dispatchWait(run, state, ctx); + break; + case 'remote_config_override': + this.dispatchRemoteConfigOverride(run, state, ctx); + break; + case 'notification': + { + const session = new NotificationSession(ctx); + this.ctx.effects.emitNotification(session); + this.onNotification?.(session); + } + break; + case 'store': + { + const session = new StoreSession(ctx, this.domains.stores); + this.ctx.effects.emitStoreOffer(session); + this.onStore?.(session); + } + break; + case 'leaderboard': + { + const session = new LeaderboardSession(ctx); + this.ctx.effects.emitLeaderboard(session); + this.onLeaderboard?.(session); + } + break; + case 'quest': + this.ctx.effects.emitQuest(new QuestSession(ctx)); + break; + case 'battlepass': + this.ctx.effects.emitBattlePass(new BattlePassSession(ctx, this.battlePass)); + break; + case 'battlepass_level': + this.ctx.effects.emitBattlePassLevel(new BattlePassLevelSession(ctx)); + break; + default: + // Unsupported node type — fail the run (surfaced via onScenarioFailed) + // instead of leaving it stalled on a node no handler will complete. + console.warn( + `[Rudder] Unsupported scenario node type '${node.type}' (${node.id})`, + ); + this.failRun( + run, + state.nodeId, + new Error(`Unsupported scenario node type '${node.type}'`), + ); + break; + } + } + + private dispatchWait( + run: RuntimeRun, + state: ActiveNodeState, + ctx: ScenarioNodeContext, + ): void { + // Prefer server-provided waitDeadline from the plan boundary over local calculation. + // The server stamps waitDeadline on server-enforced wait boundaries (see StampBoundaries). + if (!state.waitDeadlineUtc) { + const boundary = (run.plan.boundaryNodes ?? []).find( + b => b.sourceNodeId === state.nodeId && b.waitDeadline, + ); + if (boundary?.waitDeadline) { + state.waitDeadlineUtc = boundary.waitDeadline; + } else { + const data = ctx.data; + const duration = (data.duration as number) ?? 0; + const unit = (data.unit as string) ?? 'seconds'; + const ms = durationToMs(duration, unit); + state.waitDeadlineUtc = new Date(Date.now() + ms).toISOString(); + } + this.persist().catch(() => {}); + } + + const deadline = new Date(state.waitDeadlineUtc).getTime(); + const session = new WaitSession(ctx, new Date(deadline)); + this.onWait?.(session); + this.ctx.effects.emitWait(session); + + const remaining = deadline - Date.now(); + if (remaining <= 0) { + this.completeNode(run.runId, state.nodeId, 'onComplete'); + } else { + const timerKey = `${run.runId}:${state.nodeId}`; + clearTimeout(this.waitTimers.get(timerKey)); + this.waitTimers.set( + timerKey, + setTimeout(() => { + this.completeNode(run.runId, state.nodeId, 'onComplete'); + this.waitTimers.delete(timerKey); + }, remaining), + ); + } + } + + private dispatchRemoteConfigOverride( + run: RuntimeRun, + state: ActiveNodeState, + ctx: ScenarioNodeContext, + ): void { + const patches = ctx.data.patches as Array<{ + path?: string; + valueType?: string; + value?: string; + }> | undefined; + + if (patches) { + for (const patch of patches) { + if (patch.path) { + this.domains.config.applyOverride( + patch.path, + patch.value ?? '', + patch.valueType ?? 'json', + ); + this.ctx.effects.emitConfigChanged(patch.path); + } + } + } + + this.completeNode(run.runId, state.nodeId, 'output'); + } + + // ---- Internal: Node completion ---- + + /** Synchronous fire-and-forget completion. */ + private completeNode(runId: string, nodeId: string, handle: string): void { + this.completeNodeInternal(runId, nodeId, handle, true).catch(() => {}); + } + + /** Async completion — called by sessions. */ + async completeNodeAsync( + runId: string, + nodeId: string, + handle: string, + ): Promise { + return this.completeNodeInternal(runId, nodeId, handle, true); + } + + private async completeNodeInternal( + runId: string, + nodeId: string, + handle: string, + continueOnBoundary: boolean, + ): Promise { + const run = this.runs.get(runId); + if (!run) return; + + const key = completedHandleKey(nodeId, handle); + if (run.completedHandles.has(key)) return; // idempotent + + // Mark this transition as in flight so that a transiently-empty + // activeNodes (e.g. between this node and an auto-completing successor) + // does not cause the run to be reported complete prematurely. + run.pendingTransitions++; + + try { + let boundaryContinued = false; + if (continueOnBoundary) { + try { + boundaryContinued = await this.continueBoundary(run, nodeId, handle); + } catch (err: unknown) { + if (err instanceof TransientBoundaryError) { + // Transient error — don't advance the run. + // Node stays active, handle stays pending for retry on reconnect. + run.pendingTransitions--; + await this.persist(); + return; + } + throw err; // terminal — handled by outer catch + } + } + + // Only now — after the server has confirmed — mark the handle and node. + run.completedHandles.add(key); + run.activeNodes.delete(nodeId); + await this.persist(); + + if (!boundaryContinued) { + for (const edge of matchingEdges(run.plan, nodeId, handle)) { + if (edge.target) { + this.activateNode(run, edge.target); + } + } + } + + run.pendingTransitions--; + this.checkRunCompleted(run); + await this.persist(); + } catch (err) { + run.pendingTransitions--; + this.failRun( + run, + nodeId, + err instanceof Error ? err : new Error(String(err)), + ); + } + } + private async continueBoundary( + run: RuntimeRun, + nodeId: string, + handle: string, + ): Promise { + const boundaries = [...matchingBoundaryNodes(run.plan, nodeId, handle)]; + if (boundaries.length === 0) return false; + + for (const boundary of boundaries) { + try { + const response = await api.handleScenarioCallback(this.ctx, { + scenarioId: run.plan.scenarioId, + nodeId: boundary.sourceNodeId, + handle: boundary.sourceHandle, + runId: run.runId, + }); + this.domains.player.invalidate(); + this.domains.inventory.invalidate(); + if (response?.plan) { + this.startPlan(response.plan); + } + } catch (err: unknown) { + // Boundary call failed — try to reconcile with server. + let reconciled = false; + try { + const reconcile = await api.getScenarioRun(this.ctx, { runId: run.runId }); + + if (reconcile?.status === 'unknown_run' || reconcile?.status === 'expired') { + this.runs.delete(run.runId); + await this.persist(); + reconciled = true; + } else if (reconcile?.plan) { + const newRun = new RuntimeRun(run.runId, reconcile.plan); + this.runs.set(run.runId, newRun); + const startNode = findNode(reconcile.plan, reconcile.plan.startNodeId) ?? reconcile.plan.nodes?.[0]; + if (startNode?.id) { + this.activateNode(newRun, startNode.id); + } + reconciled = true; + } + } catch { + // Reconciliation also failed. + } + + if (!reconciled) { + // Reconcile did not resolve — distinguish transient from terminal. + if (isTransientHttpError(err)) { + throw new TransientBoundaryError(err); + } + // Terminal error (typed error code from the server) — let the caller failRun. + throw err; + } + // If reconciled, the boundary was handled (run corrected or removed). + // Fall through to continue to the next boundary. + } + } + return true; + } + + // ---- Internal: Counter progress ---- + + async updateProgressInternal( + runId: string, + nodeId: string, + counterKey: string, + amount: number, + ): Promise { + const run = this.runs.get(runId); + try { + const response = await api.updateScenarioCounter(this.ctx, { + scenarioId: run?.plan.scenarioId ?? '', + nodeId, + counterKey, + amount, + runId, + }); + // The server reports objective completion; it no longer returns a plan from the + // counter endpoint. On completion, cross the quest's onComplete boundary (which + // grants rewards + advances the run) — idempotent if the consumer also calls complete(). + if (response?.completed) { + await this.completeNodeInternal(runId, nodeId, 'onComplete', true); + } + } catch { + // Counter update failure does not fail the run. + } + } + + // ---- Internal: Run lifecycle ---- + + private checkRunCompleted(run: RuntimeRun): void { + if (run.activeNodes.size > 0) return; + // A node transition is still settling (e.g. an auto-completing + // remote_config_override node is about to activate its successor). + // Wait for it to finish before declaring the run complete. + if (run.pendingTransitions > 0) return; + if (!this.runs.has(run.runId)) return; // already completed/removed + this.runs.delete(run.runId); + const planRun = this.toPlanRun(run); + this.onRunCompleted?.(planRun); + this.onCompleted?.(); + this.ctx.effects.emitScenarioCompleted(planRun); + this.persist().catch(() => {}); + } + + private failRun( + run: RuntimeRun, + nodeId: string, + error: Error, + ): void { + console.warn( + `[Rudder] Scenario run ${run.runId} failed at node ${nodeId}: ${error.message}`, + ); + this.runs.delete(run.runId); + const event: ScenarioRunFailedEvent = { run: this.toPlanRun(run), nodeId, error }; + this.onRunFailed?.(event); + this.ctx.effects.emitScenarioFailed(event); + this.persist().catch(() => {}); + } + + // ---- Internal: Helpers ---- + + private toPlanRun(run: RuntimeRun): PlanRun { + return new PlanRun( + run.runId, + run.plan.planId ?? '', + run.plan.scenarioId ?? '', + run.plan.userId ?? '', + [...run.activeNodes.keys()], + run.plan, + ); + } + + private async persist(): Promise { + const store = this.planStore; + if (!store) return; + const state = JSON.stringify({ + runs: [...this.runs.values()].map((run) => ({ + runId: run.runId, + plan: run.plan, + activeNodes: [...run.activeNodes.values()], + completedHandles: [...run.completedHandles], + })), + }); + store.state = state; + try { + await store.save(state); + } catch { + // Best-effort persistence. + } + } +} diff --git a/src/scenario/engine/DagWalker.ts b/src/scenario/engine/DagWalker.ts new file mode 100644 index 0000000..22ed93d --- /dev/null +++ b/src/scenario/engine/DagWalker.ts @@ -0,0 +1,74 @@ +import type { ExecutionPlan, ExecutionPlanNode, PlanEdge, BoundaryNode } from '../../generated/common.js'; + +/** Finds a node by ID within a plan (linear scan of the nodes array). */ +export function findNode( + plan: ExecutionPlan, + nodeId: string | undefined, +): ExecutionPlanNode | undefined { + if (!nodeId || !plan.nodes) return undefined; + return plan.nodes.find((n) => n.id === nodeId); +} + +/** Yields all edges whose source node and sourceHandle match. */ +export function* matchingEdges( + plan: ExecutionPlan, + sourceNodeId: string, + sourceHandle: string, +): Generator { + for (const edge of plan.edges ?? []) { + if ( + edge.source === sourceNodeId && + (edge.sourceHandle ?? '') === (sourceHandle ?? '') + ) { + yield edge; + } + } +} + +/** Yields all boundary nodes matching the completed node + handle. */ +export function* matchingBoundaryNodes( + plan: ExecutionPlan, + sourceNodeId: string, + sourceHandle: string, +): Generator { + for (const boundary of plan.boundaryNodes ?? []) { + if ( + boundary.sourceNodeId === sourceNodeId && + (boundary.sourceHandle ?? '') === (sourceHandle ?? '') + ) { + yield boundary; + } + } +} + +/** Returns a deduplication key for completed node+handle pairs. */ +export function completedHandleKey(nodeId: string, handle: string): string { + return `${nodeId}:${handle ?? ''}`; +} + +/** Converts wait duration+unit to milliseconds. Unit required; defaults to seconds when unit is unrecognized. */ +export function durationToMs(duration: number, unit: string): number { + switch ((unit ?? 'seconds').toLowerCase()) { + case 'days': + case 'day': + case 'd': + return duration * 86_400_000; + case 'hours': + case 'hour': + case 'hr': + case 'h': + return duration * 3_600_000; + case 'minutes': + case 'minute': + case 'min': + case 'm': + return duration * 60_000; + case 'seconds': + case 'second': + case 'sec': + case 's': + return duration * 1000; + default: + return duration * 1000; // default: seconds + } +} diff --git a/src/scenario/engine/IndexedDbPlanStore.ts b/src/scenario/engine/IndexedDbPlanStore.ts new file mode 100644 index 0000000..aa15a6f --- /dev/null +++ b/src/scenario/engine/IndexedDbPlanStore.ts @@ -0,0 +1,94 @@ +/** + * IndexedDB-backed plan state store for persisting scenario execution state. + * + * The C# SDK uses a synchronous `IPlanStateStore { string State { get; set; } }`. + * IndexedDB is inherently async, so we provide an async `load()` / `save()` API + * alongside a synchronous `state` property for immediate reads after load. + */ + +export interface PlanStateStore { + /** Current serialized state (available after `load()` or `save()`). */ + state: string | null; + + /** Loads persisted state from IndexedDB. Call once on SDK initialization. */ + load(): Promise; + + /** Persists the current state to IndexedDB. */ + save(state: string | null): Promise; +} + +const DB_NAME = 'RudderPlanState'; +const STORE_NAME = 'state'; +const KEY = 'active_runs'; +const DB_VERSION = 1; + +export function createIndexedDbPlanStateStore(): PlanStateStore { + let dbPromise: Promise | null = null; + let cachedState: string | null = null; + + function getDb(): Promise { + if (!dbPromise) { + dbPromise = new Promise((resolve, reject) => { + const request = indexedDB.open(DB_NAME, DB_VERSION); + request.onupgradeneeded = () => { + if (!request.result.objectStoreNames.contains(STORE_NAME)) { + request.result.createObjectStore(STORE_NAME); + } + }; + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + }); + } + return dbPromise; + } + + return { + get state(): string | null { + return cachedState; + }, + + set state(value: string | null) { + cachedState = value; + // Best-effort async write — does not block the setter. + getDb() + .then((db) => { + const tx = db.transaction(STORE_NAME, 'readwrite'); + if (value === null) { + tx.objectStore(STORE_NAME).delete(KEY); + } else { + tx.objectStore(STORE_NAME).put(value, KEY); + } + }) + .catch(() => { + // IndexedDB write failed — state is still in memory. + }); + }, + + async load(): Promise { + const db = await getDb(); + return new Promise((resolve, reject) => { + const tx = db.transaction(STORE_NAME, 'readonly'); + const req = tx.objectStore(STORE_NAME).get(KEY); + req.onsuccess = () => { + cachedState = (req.result as string) ?? null; + resolve(); + }; + req.onerror = () => reject(req.error); + }); + }, + + async save(state: string | null): Promise { + cachedState = state; + const db = await getDb(); + return new Promise((resolve, reject) => { + const tx = db.transaction(STORE_NAME, 'readwrite'); + const req = + state === null + ? tx.objectStore(STORE_NAME).delete(KEY) + : tx.objectStore(STORE_NAME).put(state, KEY); + req.onsuccess = () => resolve(); + req.onerror = () => reject(req.error); + }); + }, + }; +} diff --git a/src/scenario/engine/sessions.ts b/src/scenario/engine/sessions.ts new file mode 100644 index 0000000..d1cdfef --- /dev/null +++ b/src/scenario/engine/sessions.ts @@ -0,0 +1,342 @@ +import type { ExecutionPlanNode } from '../../generated/common.js'; +import type { PlanRun } from './types.js'; +import type { BuyOptions, OfferHandle, ShopHandle } from '../../state/shops.js'; +import type { PurchaseOfferResponse } from '../../generated/stores.js'; +import type { BattlePassService } from '../../battlepass/BattlePassService.js'; +import type { + AddBattlePassXpResponse, + ClaimBattlePassRewardResponse, + GetBattlePassProgressResponse, + PurchaseBattlePassPremiumResponse, +} from '../../generated/battlepass.js'; + +/** Resolves a store handle by slug — satisfied by the stores domain. */ +interface StoreResolver { + getBySlug(slug: string): Promise; +} + +// ---- ScenarioNodeContext ---- + +/** + * Context object passed to each session. + * Wraps the current run, plan node, and raw node data. + * The session calls `complete(handle)` to advance the DAG. + */ +export class ScenarioNodeContext { + constructor( + public readonly run: PlanRun, + public readonly node: ExecutionPlanNode, + private readonly onComplete: (runId: string, nodeId: string, handle: string) => Promise, + private readonly onProgress: (runId: string, nodeId: string, counterKey: string, amount: number) => Promise, + ) {} + + /** Extracts a typed value from node data. */ + get(key: string, defaultValue: T): T { + const data = this.node.data as Record | undefined; + const value = data?.[key]; + if (value === undefined || value === null) return defaultValue; + return value as unknown as T; + } + + /** Full node data as a typed record. */ + get data(): Record { + return (this.node.data as Record) ?? {}; + } + + /** Completes the current node with the given output handle. */ + async complete(handle: string): Promise { + await this.onComplete(this.run.runId, this.node.id!, handle); + } + + /** Updates counter progress for server-managed scenario nodes. */ + async updateProgress(counterKey: string, amount: number): Promise { + await this.onProgress(this.run.runId, this.node.id!, counterKey, amount); + } +} + +// ---- NotificationSession ---- + +export class NotificationSession { + public readonly title: string; + public readonly message: string; + + constructor(private readonly context: ScenarioNodeContext) { + this.title = context.get('title', ''); + this.message = context.get('message', ''); + } + + get(key: string, defaultValue: T): T { + return this.context.get(key, defaultValue); + } + + get data(): Record { + return this.context.data; + } + + get node(): ExecutionPlanNode { + return this.context.node; + } + + async complete(): Promise { + await this.context.complete('output'); + } +} + +// ---- WaitSession ---- + +export class WaitSession { + constructor( + private readonly context: ScenarioNodeContext, + public readonly deadlineUtc: Date, + ) {} + + get(key: string, defaultValue: T): T { + return this.context.get(key, defaultValue); + } + + get data(): Record { + return this.context.data; + } + + get node(): ExecutionPlanNode { + return this.context.node; + } +} + +// ---- StoreSession ---- + +export class StoreSession { + private resolved = false; + + constructor( + private readonly context: ScenarioNodeContext, + private readonly stores: StoreResolver, + ) {} + + get isResolved(): boolean { + return this.resolved; + } + + get(key: string, defaultValue: T): T { + return this.context.get(key, defaultValue); + } + + get data(): Record { + return this.context.data; + } + + get node(): ExecutionPlanNode { + return this.context.node; + } + + async getStore(): Promise { + return this.stores.getBySlug(this.get('storeSlug', '')); + } + + async buy( + offer: OfferHandle, + options?: BuyOptions, + ): Promise { + if (this.resolved) { + return { success: false, error: 'store session already resolved' }; + } + const purchase = await offer.buy(options); + if (purchase.success) { + this.resolved = true; + await this.context.complete('onPurchase'); + } + return purchase; + } + + async decline(): Promise { + if (this.resolved) return; + this.resolved = true; + await this.context.complete('onDecline'); + } +} + +// ---- LeaderboardSession ---- + +export class LeaderboardSession { + private resolved = false; + + constructor(private readonly context: ScenarioNodeContext) {} + + get isResolved(): boolean { + return this.resolved; + } + + get(key: string, defaultValue: T): T { + return this.context.get(key, defaultValue); + } + + get data(): Record { + return this.context.data; + } + + get node(): ExecutionPlanNode { + return this.context.node; + } + + async end(): Promise { + if (this.resolved) return; + this.resolved = true; + await this.context.complete('onEnd'); + } + + async rewardClaimed(): Promise { + if (this.resolved) return; + this.resolved = true; + await this.context.complete('onRewardClaimed'); + } +} + +// ---- QuestSession ---- + +/** + * A scenario quest node. The game reports objective progress; the server + * auto-completes the node (crossing `onComplete`, which grants rewards and + * advances the run) once every objective is satisfied. + */ +export class QuestSession { + constructor(private readonly context: ScenarioNodeContext) {} + + get name(): string { + return this.context.get('name', ''); + } + + get objectives(): Array> { + return this.context.get('objectives', [] as Array>); + } + + get(key: string, defaultValue: T): T { + return this.context.get(key, defaultValue); + } + + get data(): Record { + return this.context.data; + } + + get node(): ExecutionPlanNode { + return this.context.node; + } + + /** + * Reports progress toward an objective. When the reported counter completes + * every objective, the server signals completion and the node crosses + * `onComplete` automatically. + */ + async reportProgress(objectiveId: string, amount = 1): Promise { + await this.context.updateProgress(objectiveId, amount); + } +} + +// ---- BattlePassSession ---- + +/** + * A scenario battlepass node. Exposes the battlepass operations (xp, premium, + * progress) bound to this node's scenario/node/run ids, plus explicit boundary + * crossings (`onLevelUp`, `onPremiumPurchase`) the game drives from its UI. The + * server validates each crossing (e.g. onLevelUp requires the level be reached). + */ +export class BattlePassSession { + constructor( + private readonly context: ScenarioNodeContext, + private readonly battlePass: BattlePassService, + ) {} + + get data(): Record { + return this.context.data; + } + + get node(): ExecutionPlanNode { + return this.context.node; + } + + get(key: string, defaultValue: T): T { + return this.context.get(key, defaultValue); + } + + private ids(): { scenarioId: string; nodeId: string; runId: string } { + return { + scenarioId: this.context.run.scenarioId, + nodeId: this.context.node.id!, + runId: this.context.run.runId, + }; + } + + /** Current xp/level/premium/claimed-tiers for this node. */ + getProgress(): Promise { + const { scenarioId, nodeId } = this.ids(); + return this.battlePass.getProgress(scenarioId, nodeId); + } + + /** Credits XP from a configured source. */ + addXp(source: string, amount: number): Promise { + const { scenarioId, nodeId, runId } = this.ids(); + return this.battlePass.addXp({ scenarioId, nodeId, source, amount, runId }); + } + + /** Claims a tier reward at a reached level. */ + claimReward(level: number, track?: string): Promise { + const { scenarioId, nodeId, runId } = this.ids(); + return this.battlePass.claimReward({ scenarioId, nodeId, level, track, runId }); + } + + /** Purchases premium, then crosses `onPremiumPurchase` on success. */ + async purchasePremium(): Promise { + const { scenarioId, nodeId, runId } = this.ids(); + const response = await this.battlePass.purchasePremium({ + scenarioId, + nodeId, + idempotencyKey: crypto.randomUUID(), + runId, + }); + if (response.success) { + await this.context.complete('onPremiumPurchase'); + } + return response; + } + + /** Crosses `onLevelUp` (server validates the node's level was reached). */ + async levelUp(): Promise { + await this.context.complete('onLevelUp'); + } + + /** Ends the battlepass node via `onComplete`. */ + async end(): Promise { + await this.context.complete('onComplete'); + } +} + +// ---- BattlePassLevelSession ---- + +/** + * A scenario battlepass_level node — a single claimable tier. `claim()` crosses + * `onComplete`, which the server accepts only once the player has reached the + * node's configured level. + */ +export class BattlePassLevelSession { + constructor(private readonly context: ScenarioNodeContext) {} + + get level(): number { + return this.context.get('level', 0); + } + + get data(): Record { + return this.context.data; + } + + get node(): ExecutionPlanNode { + return this.context.node; + } + + get(key: string, defaultValue: T): T { + return this.context.get(key, defaultValue); + } + + /** Claims this tier; crosses `onComplete` (server checks level reached). */ + async claim(): Promise { + await this.context.complete('onComplete'); + } +} diff --git a/src/scenario/engine/types.ts b/src/scenario/engine/types.ts new file mode 100644 index 0000000..c8b340b --- /dev/null +++ b/src/scenario/engine/types.ts @@ -0,0 +1,63 @@ +import type { ExecutionPlan } from '../../generated/common.js'; + +/** + * Active node state within a running scenario plan. + * For wait nodes, this includes the deadline. + */ +export interface ActiveNodeState { + nodeId: string; + waitDeadlineUtc?: string; // ISO 8601 string, set for wait nodes +} + +/** + * A running scenario plan instance — tracks active nodes and completed handles. + */ +export class RuntimeRun { + public readonly activeNodes = new Map(); + public readonly completedHandles = new Set(); + /** + * Number of node transitions currently in flight for this run. + * A transition begins when a node starts completing and ends once its + * successors have been activated. While > 0 the run must not be considered + * complete, even if `activeNodes` is transiently empty (e.g. an + * auto-completing remote_config_override node between two client nodes). + */ + public pendingTransitions = 0; + + constructor( + public readonly runId: string, + public readonly plan: ExecutionPlan, + ) {} +} + +/** Serialization format for a persisted run. */ +export interface PersistedRun { + runId: string; + plan: ExecutionPlan; + activeNodes: ActiveNodeState[]; + completedHandles: string[]; +} + +/** Top-level serialization format for all active runs. */ +export interface PersistedState { + runs: PersistedRun[]; +} + +/** Read-only snapshot of a running plan, surfaced in events. */ +export class PlanRun { + constructor( + public readonly runId: string, + public readonly planId: string, + public readonly scenarioId: string, + public readonly userId: string, + public readonly activeNodeIds: readonly string[], + public readonly plan: ExecutionPlan, + ) {} +} + +/** Failure event payload. */ +export interface ScenarioRunFailedEvent { + run: PlanRun; + nodeId: string; + error: Error; +} diff --git a/src/state/RemoteConfigState.ts b/src/state/RemoteConfigState.ts new file mode 100644 index 0000000..8cc5554 --- /dev/null +++ b/src/state/RemoteConfigState.ts @@ -0,0 +1,70 @@ +import type { RemoteConfig } from '../generated/remote-config.js'; +import { SyncedState } from './SyncedState.js'; + +export type RemoteConfigShape = Record; +type RemoteConfigKey = Extract; + +/** + * RemoteConfigState — typed remote configuration as an observable entity. + * + * `get()` parses values synchronously from the loaded snapshot based on their + * declared `valueType` and returns the default value while not loaded. + */ +export class RemoteConfigState< + TConfig extends RemoteConfigShape = RemoteConfigShape, +> extends SyncedState> { + get>(key: TKey): TConfig[TKey] | undefined; + get>(key: TKey, defaultValue: TConfig[TKey]): TConfig[TKey]; + get>( + key: TKey, + defaultValue?: TConfig[TKey], + ): TConfig[TKey] | undefined { + const config = this.data?.get(key); + if (!config || config.value == null) return defaultValue; + return parseValue(config.value, config.valueType, defaultValue); + } + + /** + * Applies a scenario remote_config_override patch to the current snapshot. + * + * @internal + */ + applyOverride(key: string, value: string, valueType = 'json'): void { + const next = new Map(this.data ?? []); + next.set(key, { + key, + value, + valueType, + active: true, + } as RemoteConfig); + this.set(next); + } +} + +function parseValue( + value: string, + valueType: string | undefined, + defaultValue: T | undefined, +): T | undefined { + try { + switch ((valueType ?? '').toLowerCase()) { + case 'int': + case 'integer': + return Number.parseInt(value, 10) as unknown as T; + case 'float': + case 'double': + case 'number': + return Number.parseFloat(value) as unknown as T; + case 'bool': + case 'boolean': + return (value === 'true') as unknown as T; + case 'json': + case 'object': + return JSON.parse(value) as T; + default: + return value as unknown as T; + } + } catch { + return defaultValue; + } +} diff --git a/src/state/SyncEngine.ts b/src/state/SyncEngine.ts new file mode 100644 index 0000000..9b922d2 --- /dev/null +++ b/src/state/SyncEngine.ts @@ -0,0 +1,150 @@ +import type { RudderContext } from '../core/context.js'; +import { api } from '../generated/api.js'; + +export const DEFAULT_SYNC_INTERVAL_MS = 30_000; + +const REVISIONS_STORAGE_KEY = 'rudder_revisions'; +const JITTER_RATIO = 0.2; + +/** A domain the sync engine can refresh when its server revision grows. */ +export interface SyncableDomain { + readonly syncKey: string; + invalidate(): void; +} + +/** + * SyncEngine — polls GET /sdk/v1/sync and invalidates state entities whose + * server-side revision grew. + * + * The first successful sync after login only fixes the baseline (entities + * were just warmed); revisions are persisted to localStorage and cleared on + * logout. Polling pauses while the document is hidden, and poll errors never + * stop the loop (401s are handled by the transport refresh flow). + */ +export class SyncEngine { + private timer: ReturnType | undefined; + private revisions: Record = {}; + private baselineEstablished = false; + private running = false; + private polling = false; + + private readonly byKey: Map; + + constructor( + private readonly ctx: RudderContext, + domains: readonly SyncableDomain[], + private readonly intervalMs: number = DEFAULT_SYNC_INTERVAL_MS, + ) { + this.byKey = new Map(domains.map((domain) => [domain.syncKey, domain])); + } + + start(): void { + if (this.running) return; + this.running = true; + this.revisions = readPersistedRevisions(); + this.baselineEstablished = false; + if (typeof document !== 'undefined') { + document.addEventListener('visibilitychange', this.onVisibilityChange); + } + void this.poll(); + } + + stop(): void { + this.running = false; + if (this.timer) { + clearTimeout(this.timer); + this.timer = undefined; + } + if (typeof document !== 'undefined') { + document.removeEventListener('visibilitychange', this.onVisibilityChange); + } + this.revisions = {}; + this.baselineEstablished = false; + clearPersistedRevisions(); + } + + private readonly onVisibilityChange = (): void => { + if (this.running && !document.hidden) { + void this.poll(); + } + }; + + private schedule(): void { + if (!this.running) return; + if (this.timer) clearTimeout(this.timer); + const jitter = 1 + (Math.random() * 2 - 1) * JITTER_RATIO; + this.timer = setTimeout(() => { + void this.poll(); + }, this.intervalMs * jitter); + } + + private async poll(): Promise { + if (!this.running || this.polling) return; + if (typeof document !== 'undefined' && document.hidden) { + this.schedule(); + return; + } + this.polling = true; + try { + const revisions = await api.getRevisions(this.ctx); + this.applyRevisions(revisions ?? {}); + } catch { + // Network/server errors must not stop the loop. + } finally { + this.polling = false; + } + this.schedule(); + } + + private applyRevisions(next: Record): void { + // On the first sync of a session, only establish the baseline UNLESS we + // restored revisions persisted by a previous session — in that case diff + // against them so entities changed while the tab was gone are refreshed. + const skipDiff = !this.baselineEstablished && Object.keys(this.revisions).length === 0; + this.baselineEstablished = true; + if (!skipDiff) { + for (const [key, revision] of Object.entries(next)) { + if (typeof revision !== 'number') continue; + if (revision > (this.revisions[key] ?? 0)) { + this.invalidate(key); + } + } + } + this.revisions = next; + persistRevisions(next); + } + + private invalidate(key: string): void { + // Unknown keys have no registered domain and are ignored. + this.byKey.get(key)?.invalidate(); + } +} + +function readPersistedRevisions(): Record { + try { + const raw = localStorage.getItem(REVISIONS_STORAGE_KEY); + if (!raw) return {}; + const parsed = JSON.parse(raw); + return parsed && typeof parsed === 'object' + ? (parsed as Record) + : {}; + } catch { + return {}; + } +} + +function persistRevisions(revisions: Record): void { + try { + localStorage.setItem(REVISIONS_STORAGE_KEY, JSON.stringify(revisions)); + } catch { + // Storage unavailable — best effort. + } +} + +function clearPersistedRevisions(): void { + try { + localStorage.removeItem(REVISIONS_STORAGE_KEY); + } catch { + // Storage unavailable — silently ignore. + } +} diff --git a/src/state/SyncedState.ts b/src/state/SyncedState.ts new file mode 100644 index 0000000..54e39fc --- /dev/null +++ b/src/state/SyncedState.ts @@ -0,0 +1,152 @@ +/** + * SyncedState — an observable cell for a single server-synced entity. + * + * Tracks `{ status, data, error }`, deduplicates parallel loads, and notifies + * listeners on every data/status change. `onChange` calls the callback + * immediately with the current snapshot, which makes syncing into external + * stores (React/zustand) trivial. + */ + +export type SyncedStatus = 'idle' | 'loading' | 'ready' | 'error'; + +export interface SyncedSnapshot { + status: SyncedStatus; + data?: T; + error?: Error; +} + +export type SyncedListener = (snapshot: SyncedSnapshot) => void; + +export class SyncedState { + private snapshot: SyncedSnapshot = { status: 'idle' }; + private pending: Promise | undefined; + private generation = 0; + private invalidateScheduled = false; + private readonly listeners = new Set>(); + + constructor(private readonly loader: () => Promise) {} + + /** Current data, or undefined when not loaded yet. */ + get data(): T | undefined { + return this.snapshot.data; + } + + get status(): SyncedStatus { + return this.snapshot.status; + } + + get error(): Error | undefined { + return this.snapshot.error; + } + + /** + * Subscribes to snapshot changes. The callback fires immediately with the + * current snapshot. Returns an unsubscribe function. + */ + onChange(callback: SyncedListener): () => void { + this.listeners.add(callback); + callback(this.snapshot); + return () => { + this.listeners.delete(callback); + }; + } + + /** Loads the entity if needed; deduplicates parallel loads. */ + load(): Promise { + if (this.snapshot.status === 'ready') { + return Promise.resolve(this.snapshot.data as T); + } + return this.startLoad(false); + } + + /** Forces a (re)load even when data is already present. */ + reload(): Promise { + return this.startLoad(true); + } + + /** + * Refetches when the entity is in use (has listeners or was loaded before); + * otherwise no-ops. Coalesced: multiple invalidations within the same tick + * (e.g. one mutation touching several boundaries) trigger a single reload. + * + * @internal + */ + invalidate(): void { + if (this.invalidateScheduled) return; + if (this.listeners.size === 0 && this.snapshot.status === 'idle') return; + this.invalidateScheduled = true; + queueMicrotask(() => { + // A reset() or set() between scheduling and flushing cancels the reload + // (fresh data arrived, or the entity was dropped on logout). + if (!this.invalidateScheduled) return; + this.invalidateScheduled = false; + if (this.listeners.size === 0 && this.snapshot.status === 'idle') return; + this.startLoad(true).catch(() => {}); + }); + } + + /** + * Applies externally-provided data (e.g. scenario config patches). + * + * @internal + */ + set(data: T): void { + this.generation++; + this.pending = undefined; + this.invalidateScheduled = false; + this.setSnapshot({ status: 'ready', data }); + } + + /** + * Drops all state back to idle (logout). + * + * @internal + */ + reset(): void { + this.generation++; + this.pending = undefined; + this.invalidateScheduled = false; + this.setSnapshot({ status: 'idle' }); + } + + private startLoad(force: boolean): Promise { + if (this.pending && !force) return this.pending; + const generation = ++this.generation; + this.setSnapshot({ status: 'loading', data: this.snapshot.data }); + this.pending = this.loader() + .then((data) => { + if (generation === this.generation) { + this.setSnapshot({ status: 'ready', data }); + } + return data; + }) + .catch((err) => { + const error = err instanceof Error ? err : new Error(String(err)); + if (generation === this.generation) { + this.setSnapshot({ status: 'error', error, data: this.snapshot.data }); + } + throw error; + }) + .finally(() => { + if (generation === this.generation) { + this.pending = undefined; + } + }); + return this.pending; + } + + private setSnapshot(next: SyncedSnapshot): void { + const previous = this.snapshot; + if ( + previous.status === next.status && + previous.data === next.data && + previous.error === next.error + ) { + return; + } + this.snapshot = next; + for (const callback of this.listeners) { + callback(next); + } + } +} diff --git a/src/state/inventory.ts b/src/state/inventory.ts new file mode 100644 index 0000000..3ab3f33 --- /dev/null +++ b/src/state/inventory.ts @@ -0,0 +1,29 @@ +import type { CatalogItem } from '../generated/catalog.js'; +import type { PlayerInventoryItem } from '../generated/inventory.js'; + +/** A fully-resolved owned item: player inventory row merged with its catalog entry. */ +export interface InventoryItem { + slug: string; + amount: number; + name: string; + properties: Record; + tags: string[]; +} + +/** @internal Merges owned inventory rows with their catalog entries. */ +export function mergeInventory( + owned: PlayerInventoryItem[], + catalog: Map, +): InventoryItem[] { + return owned.map((item) => { + const slug = item.slug ?? ''; + const entry = catalog.get(slug); + return { + slug, + amount: item.amount ?? 0, + name: item.nameOverride || entry?.name || slug, + properties: { ...(entry?.properties ?? {}), ...(item.propertiesOverride ?? {}) }, + tags: entry?.tags ?? [], + }; + }); +} diff --git a/src/state/shops.ts b/src/state/shops.ts new file mode 100644 index 0000000..257067a --- /dev/null +++ b/src/state/shops.ts @@ -0,0 +1,72 @@ +import type { + Offer, + OfferContent, + OfferPrice, + Store, + PurchaseOfferResponse, +} from '../generated/stores.js'; + +export interface BuyOptions { + idempotencyKey?: string; +} + +/** + * Wire-level purchase executor provided by the stores domain + * (`StoresDomain.purchase`). + */ +export type PurchaseOffer = ( + storeSlug: string, + offerId: string, + options?: BuyOptions, +) => Promise; + +export class OfferHandle { + public readonly id: string; + public readonly name?: string; + public readonly price?: OfferPrice; + public readonly contents: OfferContent[]; + public readonly maxPurchases?: number; + + constructor( + private readonly purchase: PurchaseOffer, + private readonly storeSlug: string, + private readonly offer: Offer, + ) { + if (!offer.id) { + throw new Error('Rudder Stores: offer.id is required'); + } + this.id = offer.id; + this.name = offer.name; + this.price = offer.price; + this.contents = offer.contents ?? []; + this.maxPurchases = offer.maxPurchases; + } + + async buy(options: BuyOptions = {}): Promise { + return this.purchase(this.storeSlug, this.id, options); + } +} + +export class ShopHandle { + public readonly slug: string; + public readonly name?: string; + public readonly description?: string; + public readonly data?: { [key: string]: unknown }; + public readonly offers: OfferHandle[]; + + constructor( + purchase: PurchaseOffer, + store: Store, + ) { + if (!store.slug) { + throw new Error('Rudder Stores: store.slug is required'); + } + this.slug = store.slug; + this.name = store.name; + this.description = store.description; + this.data = store.data; + this.offers = (store.offers ?? []).map( + (offer) => new OfferHandle(purchase, this.slug, offer), + ); + } +} diff --git a/src/token/TokenStore.ts b/src/token/TokenStore.ts new file mode 100644 index 0000000..f4c0b89 --- /dev/null +++ b/src/token/TokenStore.ts @@ -0,0 +1,101 @@ +/** + * Token persistence interface and localStorage-backed default implementation. + * + * The TokenStore is injected via RudderClientOptions. When none is provided + * the client uses {@link createDefaultTokenStore}: localStorage in browsers, + * with a silent in-memory fallback where localStorage is unavailable + * (SSR, private mode). Consumers can provide a custom implementation for + * alternative storage backends (e.g., sessionStorage, secure cookies). + */ +export interface TokenStore { + /** Returns the current access token, or null if not authenticated. */ + getAccessToken(): string | null; + + /** Returns the current refresh token, or null if not available. */ + getRefreshToken(): string | null; + + /** Persists access and refresh tokens after a successful login. */ + saveTokens(accessToken: string, refreshToken: string): void; + + /** Removes all stored tokens (logout). */ + clear(): void; +} + +const STORAGE_KEY_ACCESS = 'rudder_access_token'; +const STORAGE_KEY_REFRESH = 'rudder_refresh_token'; + +/** + * Creates the default TokenStore: localStorage-backed when usable, otherwise + * a silent in-memory fallback (tokens live for the page session only). + */ +export function createDefaultTokenStore(): TokenStore { + try { + const probe = 'rudder_storage_probe'; + localStorage.setItem(probe, '1'); + localStorage.removeItem(probe); + return createLocalStorageTokenStore(); + } catch { + return createMemoryTokenStore(); + } +} + +/** + * Creates a TokenStore backed by the browser's localStorage. + * + * Uses keys `rudder_access_token` and `rudder_refresh_token`. + */ +export function createLocalStorageTokenStore(): TokenStore { + return { + getAccessToken(): string | null { + try { + return localStorage.getItem(STORAGE_KEY_ACCESS); + } catch { + return null; + } + }, + + getRefreshToken(): string | null { + try { + return localStorage.getItem(STORAGE_KEY_REFRESH); + } catch { + return null; + } + }, + + saveTokens(accessToken: string, refreshToken: string): void { + try { + localStorage.setItem(STORAGE_KEY_ACCESS, accessToken); + localStorage.setItem(STORAGE_KEY_REFRESH, refreshToken); + } catch { + // Storage full or unavailable — silently ignore. + } + }, + + clear(): void { + try { + localStorage.removeItem(STORAGE_KEY_ACCESS); + localStorage.removeItem(STORAGE_KEY_REFRESH); + } catch { + // Storage unavailable — silently ignore. + } + }, + }; +} + +function createMemoryTokenStore(): TokenStore { + let accessToken: string | null = null; + let refreshToken: string | null = null; + + return { + getAccessToken: () => accessToken, + getRefreshToken: () => refreshToken, + saveTokens(access, refresh) { + accessToken = access; + refreshToken = refresh; + }, + clear() { + accessToken = null; + refreshToken = null; + }, + }; +} diff --git a/src/transport/request.ts b/src/transport/request.ts new file mode 100644 index 0000000..53e30ee --- /dev/null +++ b/src/transport/request.ts @@ -0,0 +1,188 @@ +/** + * Core HTTP transport — fetch-based with auth injection, timeouts, GET retries, + * single-flight token refresh, and typed error mapping. + */ + +import { RudderNetworkError, RudderHttpError, RudderAuthError } from '../client/RudderError.js'; +import type { TokenStore } from '../token/TokenStore.js'; +import type { + RefreshAccessTokenRequest, + RefreshAccessTokenResponse, +} from '../generated/auth.js'; + +export const DEFAULT_REQUEST_TIMEOUT_MS = 10_000; + +const MAX_GET_RETRIES = 2; +const RETRY_BASE_DELAY_MS = 300; + +export interface RequestContext { + baseUrl: string; + tokenStore: TokenStore; + requestTimeoutMs?: number; + /** Single-flight refresh state shared by all requests of one client. */ + refreshState?: { pending?: Promise }; + /** Called when the refresh flow fails — lets the client stop its runtime. */ + onAuthFailure?: () => void; +} + +/** + * Sends an HTTP request to the LiveOps API. + * + * - Builds absolute URL from `baseUrl + path` + * - Injects `Authorization: Bearer ` header (if token is available) + * - Serializes body as JSON, deserializes response as JSON + * - Aborts the request after `requestTimeoutMs` (default 10s) + * - Retries GET requests up to 2 times (300ms × 2^n backoff) on network errors and 5xx + * - On 401 runs a single-flight token refresh and retries the request once; + * when the refresh fails, clears tokens and throws RudderAuthError + * - Maps HTTP errors to typed exceptions (RudderNetworkError, RudderHttpError, RudderAuthError) + * - Returns undefined for 204 No Content responses + */ +export async function request( + method: string, + path: string, + body: unknown | undefined, + ctx: RequestContext, +): Promise { + let response = await requestWithRetry(method, path, body, ctx); + + if (response.status === 401) { + const refreshed = await refreshTokens(ctx); + if (refreshed) { + response = await requestWithRetry(method, path, body, ctx); + } else { + ctx.tokenStore.clear(); + ctx.onAuthFailure?.(); + const bodyText = await response.text().catch(() => ''); + throw new RudderAuthError(response.statusText, bodyText); + } + } + + if (!response.ok) { + const bodyText = await response.text().catch(() => ''); + + // Try to extract machine-readable code from error body. + let code: string | undefined; + try { + const parsed = JSON.parse(bodyText); + if (parsed && typeof parsed.code === 'string') { + code = parsed.code; + } + } catch { + // non-JSON body — ignore + } + + if (response.status === 401) { + throw new RudderAuthError(response.statusText, bodyText); + } + throw new RudderHttpError(response.status, response.statusText, bodyText, code); + } + + // 204 No Content — no body to parse. + if (response.status === 204) { + return undefined as unknown as TResponse; + } + + return response.json() as Promise; +} + +/** Fetches with per-attempt timeout; retries GET on network errors and 5xx. */ +async function requestWithRetry( + method: string, + path: string, + body: unknown | undefined, + ctx: RequestContext, +): Promise { + const retryable = method === 'GET'; + for (let attempt = 0; ; attempt++) { + try { + const response = await fetchOnce(method, path, body, ctx); + if (retryable && response.status >= 500 && attempt < MAX_GET_RETRIES) { + await sleep(RETRY_BASE_DELAY_MS * 2 ** attempt); + continue; + } + return response; + } catch (err) { + if (retryable && attempt < MAX_GET_RETRIES) { + await sleep(RETRY_BASE_DELAY_MS * 2 ** attempt); + continue; + } + throw new RudderNetworkError(err); + } + } +} + +async function fetchOnce( + method: string, + path: string, + body: unknown | undefined, + ctx: RequestContext, +): Promise { + const url = `${ctx.baseUrl.replace(/\/+$/, '')}${path}`; + + const headers: Record = { + 'Content-Type': 'application/json', + }; + + const token = ctx.tokenStore.getAccessToken(); + if (token) { + headers['Authorization'] = `Bearer ${token}`; + } + + const controller = new AbortController(); + const timeout = ctx.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS; + const timer = setTimeout(() => controller.abort(), timeout); + try { + return await fetch(url, { + method, + headers, + body: body !== undefined ? JSON.stringify(body) : undefined, + signal: controller.signal, + }); + } finally { + clearTimeout(timer); + } +} + +/** + * Single-flight refresh: concurrent 401s share one refresh request. + * Resolves to true when a new token pair was stored. + */ +async function refreshTokens(ctx: RequestContext): Promise { + const state = (ctx.refreshState ??= {}); + state.pending ??= doRefreshTokens(ctx).finally(() => { + state.pending = undefined; + }); + return state.pending; +} + +async function doRefreshTokens(ctx: RequestContext): Promise { + const refreshToken = ctx.tokenStore.getRefreshToken(); + if (!refreshToken) return false; + + const url = `${ctx.baseUrl.replace(/\/+$/, '')}/sdk/v1/authorization/refresh`; + const controller = new AbortController(); + const timeout = ctx.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS; + const timer = setTimeout(() => controller.abort(), timeout); + try { + const response = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ refreshToken } satisfies RefreshAccessTokenRequest), + signal: controller.signal, + }); + if (!response.ok) return false; + const tokens = (await response.json()) as RefreshAccessTokenResponse; + if (!tokens.accessToken || !tokens.refreshToken) return false; + ctx.tokenStore.saveTokens(tokens.accessToken, tokens.refreshToken); + return true; + } catch { + return false; + } finally { + clearTimeout(timer); + } +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/src/transport/url.ts b/src/transport/url.ts new file mode 100644 index 0000000..77abb68 --- /dev/null +++ b/src/transport/url.ts @@ -0,0 +1,23 @@ +/** + * URL utility helpers for encoding and query string construction. + */ + +/** URL-encodes a single value for use in path/query segments. */ +export function encodeUrl(value: string): string { + return encodeURIComponent(value); +} + +/** + * Builds a query string from an array of key-value pairs. + * Null/undefined/empty values are skipped. + * Returns an empty string if no valid params, or a leading `?` followed by the query. + */ +export function buildQuery(params: Array<[string, string | null | undefined]>): string { + const parts: string[] = []; + for (const [key, value] of params) { + if (value != null && value !== '') { + parts.push(`${encodeURIComponent(key)}=${encodeURIComponent(value)}`); + } + } + return parts.length === 0 ? '' : `?${parts.join('&')}`; +} diff --git a/test/AuthService.test.ts b/test/AuthService.test.ts new file mode 100644 index 0000000..bfc24af --- /dev/null +++ b/test/AuthService.test.ts @@ -0,0 +1,264 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { createTestClient } from './helpers/createClient.js'; +import { RudderNetworkError, RudderAuthError } from '../src/client/RudderError.js'; + +function jsonResponse(body: unknown): Response { + return new Response(JSON.stringify(body), { status: 200 }); +} + +describe('AuthService', () => { + beforeEach(() => { + // Mock crypto.randomUUID for deterministic device ID. + vi.stubGlobal('crypto', { + randomUUID: () => '00000000-0000-4000-a000-000000000001', + }); + localStorage.clear(); + }); + + it('loginWithDevice sends correct request body', async () => { + const client = createTestClient(); + const fetchMock = vi.fn().mockImplementation((url: string) => { + if (url.includes('/sdk/v1/remote-configs')) { + return Promise.resolve(jsonResponse({ configs: {} })); + } + return Promise.resolve(jsonResponse({ + accessToken: 'access-token-123', + refreshToken: 'refresh-token-456', + })); + }); + vi.stubGlobal('fetch', fetchMock); + + const response = await client.auth.loginWithDevice({ region: 'us', language: 'en' }); + + expect(fetchMock).toHaveBeenCalledTimes(8); + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toContain('/sdk/v1/authorization/device'); + const body = JSON.parse(init.body); + expect(body.key).toBe('test-project-key'); + expect(body.deviceId).toBe('00000000-0000-4000-a000-000000000001'); + expect(body.region).toBe('us'); + expect(body.language).toBe('en'); + expect(response.accessToken).toBe('access-token-123'); + expect(response.refreshToken).toBe('refresh-token-456'); + expect(fetchMock.mock.calls.map(([url]) => new URL(String(url)).pathname)).toEqual([ + '/sdk/v1/authorization/device', + '/sdk/v1/remote-configs', + '/sdk/v1/player/information', + '/sdk/v1/stores', + '/sdk/v1/catalog', + '/sdk/v1/scenarios/trigger', + // The login event invalidates the warmed profile → refetch. + '/sdk/v1/player/information', + // Sync engine baseline poll, fired right after login. + '/sdk/v1/sync', + ]); + }); + + it('loginWithDevice saves tokens on success', async () => { + const client = createTestClient(); + vi.stubGlobal('fetch', vi.fn().mockImplementation((url: string) => { + if (url.includes('/sdk/v1/remote-configs')) { + return Promise.resolve(jsonResponse({ configs: {} })); + } + return Promise.resolve(jsonResponse({ + accessToken: 'at', + refreshToken: 'rt', + })); + })); + + await client.auth.loginWithDevice(); + expect(client.options.tokenStore.getAccessToken()).toBe('at'); + expect(client.options.tokenStore.getRefreshToken()).toBe('rt'); + }); + + it('loginWithDevice throws RudderNetworkError on network failure', async () => { + const client = createTestClient(); + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Offline'))); + await expect(client.auth.loginWithDevice()).rejects.toThrow(RudderNetworkError); + }); + + it('loginWithDevice throws RudderAuthError on 401', async () => { + const client = createTestClient(); + vi.stubGlobal('fetch', vi.fn().mockResolvedValue( + new Response('unauthorized', { status: 401, statusText: 'Unauthorized' }), + )); + await expect(client.auth.loginWithDevice()).rejects.toThrow(RudderAuthError); + }); + + it('logout clears tokens', () => { + const client = createTestClient(); + client.options.tokenStore.saveTokens('at', 'rt'); + client.auth.logout(); + expect(client.options.tokenStore.getAccessToken()).toBeNull(); + expect(client.options.tokenStore.getRefreshToken()).toBeNull(); + }); + + it('loginWithDevice sends token in Authorization header after login', async () => { + const client = createTestClient(); + const fetchMock = vi.fn().mockImplementation((url: string) => { + if (url.includes('/sdk/v1/authorization/device')) { + return Promise.resolve(jsonResponse({ + accessToken: 'at2', + refreshToken: 'rt2', + })); + } + if (url.includes('/sdk/v1/remote-configs')) { + return Promise.resolve(jsonResponse({ configs: {} })); + } + return Promise.resolve(jsonResponse({ player: null, wallets: [] })); + }); + vi.stubGlobal('fetch', fetchMock); + + await client.auth.loginWithDevice(); + + // Subsequent authenticated calls should include the token. + const response = await client.player.reload(); + expect(response).toEqual({ player: null, wallets: [] }); + const playerCall = fetchMock.mock.calls.find(([url]) => + String(url).includes('/sdk/v1/player/information'), + ); + const headers = playerCall?.[1].headers; + expect(headers['Authorization']).toBe('Bearer at2'); + }); + + it('loginWithDevice only fires player_login from the client runtime', async () => { + const client = createTestClient(); + const fetchMock = vi.fn().mockImplementation((url: string) => { + if (url.includes('/sdk/v1/authorization/device')) { + return Promise.resolve(jsonResponse({ + accessToken: 'at-new', + refreshToken: 'rt-new', + })); + } + if (url.includes('/sdk/v1/remote-configs')) { + return Promise.resolve(jsonResponse({ configs: {} })); + } + if (url.includes('/sdk/v1/scenarios/trigger')) { + return Promise.resolve(jsonResponse({ plans: [] })); + } + if (url.includes('/sdk/v1/stores')) { + return Promise.resolve(jsonResponse({ stores: [], total: 0 })); + } + return Promise.resolve(jsonResponse({ player: null, wallets: [] })); + }); + vi.stubGlobal('fetch', fetchMock); + + await client.auth.loginWithDevice(); + + const triggerBodies = fetchMock.mock.calls + .filter(([url]) => String(url).includes('/sdk/v1/scenarios/trigger')) + .map(([, init]) => JSON.parse(init.body)); + expect(triggerBodies.map((body) => body.event)).toEqual(['player_login']); + }); + + it('loginWithDevice isolates post-login scenario trigger failures', async () => { + const client = createTestClient(); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + vi.stubGlobal('fetch', vi.fn().mockImplementation((url: string) => { + if (url.includes('/sdk/v1/authorization/device')) { + return Promise.resolve(jsonResponse({ + accessToken: 'at', + refreshToken: 'rt', + })); + } + if (url.includes('/sdk/v1/remote-configs')) { + return Promise.resolve(jsonResponse({ configs: {} })); + } + if (url.includes('/sdk/v1/scenarios/trigger')) { + return Promise.resolve(new Response('scenario failed', { status: 500, statusText: 'Internal Server Error' })); + } + if (url.includes('/sdk/v1/stores')) { + return Promise.resolve(jsonResponse({ stores: [], total: 0 })); + } + return Promise.resolve(jsonResponse({ player: null, wallets: [] })); + })); + + await expect(client.auth.loginWithDevice()).resolves.toMatchObject({ accessToken: 'at' }); + expect(warn).toHaveBeenCalled(); + warn.mockRestore(); + }); + + it('loginWithDevice defaults to region global and language en', async () => { + const client = createTestClient(); + const fetchMock = vi.fn().mockImplementation((url: string) => { + if (url.includes('/sdk/v1/remote-configs')) { + return Promise.resolve(jsonResponse({ configs: {} })); + } + return Promise.resolve(jsonResponse({ accessToken: 'at', refreshToken: 'rt' })); + }); + vi.stubGlobal('fetch', fetchMock); + + await client.auth.loginWithDevice(); + const [, init] = fetchMock.mock.calls[0]; + const body = JSON.parse(init.body); + expect(body.region).toBe('global'); + expect(body.language).toBe('en'); + }); +}); + +describe('auth state', () => { + beforeEach(() => { + vi.stubGlobal('crypto', { + randomUUID: () => '00000000-0000-4000-a000-000000000001', + }); + localStorage.clear(); + }); + + function stubLoginFlow(): void { + vi.stubGlobal('fetch', vi.fn().mockImplementation((url: string) => { + if (url.includes('/sdk/v1/remote-configs')) { + return Promise.resolve(jsonResponse({ configs: {} })); + } + if (url.includes('/sdk/v1/scenarios/trigger')) { + return Promise.resolve(jsonResponse({ plans: [] })); + } + return Promise.resolve(jsonResponse({ accessToken: 'at', refreshToken: 'rt' })); + })); + } + + it('onAuthStateChange fires immediately, then on login and logout', async () => { + const client = createTestClient(); + stubLoginFlow(); + + const states: string[] = []; + const unsubscribe = client.auth.onAuthStateChange((state) => states.push(state)); + expect(states).toEqual(['signed-out']); + expect(client.auth.isAuthenticated).toBe(false); + + await client.auth.loginWithDevice(); + expect(states).toEqual(['signed-out', 'signed-in']); + expect(client.auth.isAuthenticated).toBe(true); + + client.auth.logout(); + expect(states).toEqual(['signed-out', 'signed-in', 'signed-out']); + expect(client.auth.isAuthenticated).toBe(false); + + unsubscribe(); + client.auth.logout(); + expect(states).toHaveLength(3); + }); + + it('starts signed-in when the token store already holds a token', () => { + const client = createTestClient(); + client.options.tokenStore.saveTokens('at', 'rt'); + const states: string[] = []; + client.auth.onAuthStateChange((state) => states.push(state)); + expect(states).toEqual(['signed-in']); + }); + + it('emits signed-out when the transport session expires (refresh fails)', async () => { + const client = createTestClient(); + client.options.tokenStore.saveTokens('expired-at', 'expired-rt'); + vi.stubGlobal('fetch', vi.fn().mockResolvedValue( + new Response('unauthorized', { status: 401, statusText: 'Unauthorized' }), + )); + + const states: string[] = []; + client.auth.onAuthStateChange((state) => states.push(state)); + expect(states).toEqual(['signed-in']); + + await expect(client.player.reload()).rejects.toThrow(RudderAuthError); + expect(states).toEqual(['signed-in', 'signed-out']); + expect(client.auth.isAuthenticated).toBe(false); + }); +}); diff --git a/test/PublicApi.test.ts b/test/PublicApi.test.ts new file mode 100644 index 0000000..b8060e5 --- /dev/null +++ b/test/PublicApi.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from 'vitest'; +import * as sdk from '../src/index.js'; +import { RudderClient } from '../src/index.js'; + +describe('public API surface', () => { + it('does not expose scenario runtime types from the root package', () => { + expect('ScenarioService' in sdk).toBe(false); + expect('NotificationSession' in sdk).toBe(false); + expect('StoreSession' in sdk).toBe(false); + expect('WaitSession' in sdk).toBe(false); + expect('LeaderboardSession' in sdk).toBe(false); + expect('PlanRun' in sdk).toBe(false); + expect('createIndexedDbPlanStateStore' in sdk).toBe(false); + }); + + it('keeps helpers, domain classes, and service constructors out of the barrel', () => { + // Device id helper — internal. + expect('getOrCreateDeviceId' in sdk).toBe(false); + // Domain classes — exported as types only. + for (const name of [ + 'PlayerDomain', + 'CatalogDomain', + 'InventoryDomain', + 'ConfigDomain', + 'StorageDomain', + 'StoresDomain', + ]) { + expect(name in sdk).toBe(false); + } + // Service constructors — exported as types only. + for (const name of [ + 'AuthService', + 'LeaderboardsService', + 'LeaderboardHandle', + 'BattlePassService', + 'BattlepassService', + 'QuestsService', + ]) { + expect(name in sdk).toBe(false); + } + }); + + it('exposes the expected value exports', () => { + for (const name of [ + 'RudderClient', + 'RudderError', + 'RudderNetworkError', + 'RudderHttpError', + 'RudderAuthError', + 'SDK_ERROR_INVALID_OPTIONS', + 'RudderErrorCodes', + 'createDefaultTokenStore', + 'createLocalStorageTokenStore', + 'SyncedState', + 'RemoteConfigState', + 'ShopHandle', + 'OfferHandle', + ]) { + expect(name in sdk, name).toBe(true); + } + }); + + it('exposes effects without a public scenarios service on the client', () => { + const client = new RudderClient({ + baseUrl: 'https://api.test.rudder.build', + projectKey: 'test-project', + runtime: { planStateStore: null, loginEvent: null }, + }); + + expect(client.effects).toBeDefined(); + expect('scenarios' in client).toBe(false); + }); + + it('uses the canonical glossary property names on the client', () => { + const client = new RudderClient({ + baseUrl: 'https://api.test.rudder.build', + projectKey: 'test-project', + runtime: { planStateStore: null, loginEvent: null }, + }); + + expect(client.remoteConfig).toBeDefined(); + expect('config' in client).toBe(false); + expect(client.battlePass).toBeDefined(); + expect('battlepass' in client).toBe(false); + expect(typeof client.effects.onBattlePass).toBe('function'); + expect(typeof client.effects.onBattlePassLevel).toBe('function'); + expect('onBattlepass' in client.effects).toBe(false); + expect('onBattlepassLevel' in client.effects).toBe(false); + }); +}); diff --git a/test/RudderClient.test.ts b/test/RudderClient.test.ts new file mode 100644 index 0000000..754c09d --- /dev/null +++ b/test/RudderClient.test.ts @@ -0,0 +1,217 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { RudderClient } from '../src/client/RudderClient.js'; +import { + RudderError, + RudderNetworkError, + RudderHttpError, + RudderAuthError, + SDK_ERROR_INVALID_OPTIONS, +} from '../src/client/RudderError.js'; +import { createFakeTokenStore } from './helpers/FakeTokenStore.js'; + +describe('RudderClient', () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('constructs with valid options', () => { + const client = new RudderClient({ + baseUrl: 'https://api.example.com', + projectKey: 'proj_123', + tokenStore: createFakeTokenStore(), + }); + expect(client.auth).toBeDefined(); + }); + + it('throws RudderError with sdk/invalid-options if baseUrl is missing', () => { + let caught: unknown; + try { + new RudderClient({ baseUrl: '', projectKey: 'proj_123' }); + } catch (error) { + caught = error; + } + expect(caught).toBeInstanceOf(RudderError); + expect((caught as RudderError).code).toBe(SDK_ERROR_INVALID_OPTIONS); + expect((caught as Error).message).toContain('baseUrl'); + }); + + it('throws RudderError with sdk/invalid-options if projectKey is missing', () => { + let caught: unknown; + try { + new RudderClient({ baseUrl: 'https://api.example.com', projectKey: '' }); + } catch (error) { + caught = error; + } + expect(caught).toBeInstanceOf(RudderError); + expect((caught as RudderError).code).toBe(SDK_ERROR_INVALID_OPTIONS); + expect((caught as Error).message).toContain('projectKey'); + }); + + it('creates a default localStorage-backed token store when tokenStore is omitted', () => { + localStorage.clear(); + const client = new RudderClient({ + baseUrl: 'https://api.example.com', + projectKey: 'proj_123', + }); + client.options.tokenStore.saveTokens('at', 'rt'); + expect(localStorage.getItem('rudder_access_token')).toBe('at'); + expect(client.options.tokenStore.getAccessToken()).toBe('at'); + client.options.tokenStore.clear(); + }); + + it('falls back to an in-memory token store when localStorage is unavailable', () => { + vi.stubGlobal('localStorage', undefined); + const client = new RudderClient({ + baseUrl: 'https://api.example.com', + projectKey: 'proj_123', + }); + client.options.tokenStore.saveTokens('at', 'rt'); + expect(client.options.tokenStore.getAccessToken()).toBe('at'); + expect(client.options.tokenStore.getRefreshToken()).toBe('rt'); + client.options.tokenStore.clear(); + expect(client.options.tokenStore.getAccessToken()).toBeNull(); + }); +}); + +describe('transport error mapping', () => { + let client: RudderClient; + + beforeEach(() => { + client = new RudderClient({ + baseUrl: 'https://api.example.com', + projectKey: 'proj_123', + tokenStore: createFakeTokenStore(), + }); + }); + + it('throws RudderNetworkError with the underlying error as cause', async () => { + const underlying = new Error('Network down'); + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(underlying)); + let caught: unknown; + await client.player.reload().catch((error) => { + caught = error; + }); + expect(caught).toBeInstanceOf(RudderNetworkError); + expect((caught as RudderNetworkError).cause).toBe(underlying); + }); + + it('throws RudderAuthError on 401', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue( + new Response('unauthorized', { status: 401, statusText: 'Unauthorized' }), + )); + await expect(client.player.reload()) + .rejects.toThrow(RudderAuthError); + }); + + it('throws RudderHttpError with the server error code on non-401 HTTP error', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue( + new Response(JSON.stringify({ code: 'run_expired', error: 'gone' }), { + status: 410, + statusText: 'Gone', + }), + )); + let caught: unknown; + await client.player.reload().catch((error) => { + caught = error; + }); + expect(caught).toBeInstanceOf(RudderHttpError); + expect((caught as RudderHttpError).code).toBe('run_expired'); + }); + + it('returns undefined for 204 No Content', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue( + new Response(null, { status: 204, statusText: 'No Content' }), + )); + const result = await client.storage.save([]); + expect(result).toBeUndefined(); + }); + + it('returns parsed JSON for 200 responses', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue( + new Response(JSON.stringify({ player: { id: 'player-1' }, wallets: [] }), { status: 200 }), + )); + const result = await client.player.reload(); + expect(result).toEqual({ player: { id: 'player-1' }, wallets: [] }); + }); +}); + +describe('lazy scenario runtime', () => { + beforeEach(() => { + localStorage.clear(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('does not create the scenario runtime until first use', async () => { + const client = new RudderClient({ + baseUrl: 'https://api.example.com', + projectKey: 'proj_123', + runtime: { planStateStore: null, loginEvent: null }, + }); + const internals = client as unknown as { + scenarioRuntime: unknown; + ensureScenarioRuntime(): Promise; + }; + expect(internals.scenarioRuntime).toBeNull(); + await internals.ensureScenarioRuntime(); + expect(internals.scenarioRuntime).not.toBeNull(); + }); + + it('delivers effects to handlers subscribed before the runtime is ready', async () => { + const client = new RudderClient({ + baseUrl: 'https://api.example.com', + projectKey: 'proj_123', + runtime: { planStateStore: null }, // default loginEvent: player_login + }); + + const notifications: string[] = []; + client.effects.onNotification((effect) => { + notifications.push(effect.message); + return effect.done(); + }); + + vi.stubGlobal('fetch', vi.fn().mockImplementation((url: string, init?: RequestInit) => { + const path = new URL(url).pathname; + if (path === '/sdk/v1/authorization/device') { + return Promise.resolve(new Response( + JSON.stringify({ accessToken: 'at', refreshToken: 'rt' }), + { status: 200 }, + )); + } + if (path === '/sdk/v1/scenarios/trigger') { + return Promise.resolve(new Response(JSON.stringify({ + plans: [{ + planId: 'plan-1', + scenarioId: 'scenario-1', + userId: 'user-1', + startNodeId: 'start', + runId: 'run-1', + nodes: [{ id: 'start', type: 'notification', data: { message: 'Welcome!' } }], + edges: [], + boundaryNodes: [], + }], + }), { status: 200 })); + } + if (path === '/sdk/v1/remote-configs') { + return Promise.resolve(new Response(JSON.stringify({ configs: {} }), { status: 200 })); + } + if (path === '/sdk/v1/stores') { + return Promise.resolve(new Response(JSON.stringify({ stores: [], total: 0 }), { status: 200 })); + } + if (path === '/sdk/v1/catalog') { + return Promise.resolve(new Response(JSON.stringify({ items: [] }), { status: 200 })); + } + if (path === '/sdk/v1/sync') { + return Promise.resolve(new Response(JSON.stringify({}), { status: 200 })); + } + void init; + return Promise.resolve(new Response(JSON.stringify({ player: null, wallets: [] }), { status: 200 })); + })); + + await client.auth.loginWithDevice(); + await vi.waitFor(() => expect(notifications).toEqual(['Welcome!'])); + client.dispose(); + }); +}); diff --git a/test/ScenarioService.test.ts b/test/ScenarioService.test.ts new file mode 100644 index 0000000..7ac0e4d --- /dev/null +++ b/test/ScenarioService.test.ts @@ -0,0 +1,528 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { ScenarioService } from '../src/scenario/ScenarioService.js'; +import { getScenarioRuntime, RudderClient } from '../src/client/RudderClient.js'; +import { createFakeTokenStore } from './helpers/FakeTokenStore.js'; +import type { ExecutionPlan, ExecutionPlanNode, PlanEdge } from '../src/generated/common.js'; +import { NotificationSession, StoreSession, WaitSession, LeaderboardSession } from '../src/scenario/engine/sessions.js'; + +/** Builds a simple execution plan with the given nodes and edges. */ +function makePlan(overrides?: Partial): ExecutionPlan { + return { + planId: 'plan-1', + scenarioId: 'scenario-1', + userId: 'user-1', + startNodeId: 'start', + runId: 'run-1', + nodes: [], + edges: [], + boundaryNodes: [], + context: undefined, + ...overrides, + }; +} + +function makeNode(id: string, type: string, data?: Record): ExecutionPlanNode { + return { id, type, data: data as ExecutionPlanNode['data'] }; +} + +function makeEdge(source: string, sourceHandle: string, target: string): PlanEdge { + return { id: `edge-${source}-${target}`, source, sourceHandle, target }; +} + +async function createClientWithScenario(): Promise<{ client: RudderClient; scenarios: ScenarioService }> { + const client = new RudderClient({ + baseUrl: 'https://api.test.rudder.build', + projectKey: 'test-key', + tokenStore: createFakeTokenStore(), + runtime: { planStateStore: null, loginEvent: null }, // Disable IndexedDB for tests + }); + return { client, scenarios: await getScenarioRuntime(client) }; +} + +describe('ScenarioService', () => { + beforeEach(() => { + vi.stubGlobal('crypto', { + randomUUID: () => '00000000-0000-4000-a000-000000000001', + }); + }); + + describe('send (trigger)', () => { + it('calls POST /sdk/v1/scenarios/trigger and starts plans', async () => { + const { client, scenarios } = await createClientWithScenario(); + + const plan = makePlan({ + nodes: [makeNode('start', 'notification')], + }); + + vi.stubGlobal('fetch', vi.fn().mockResolvedValue( + new Response(JSON.stringify({ plans: [plan] }), { status: 200 }), + )); + + const onNotification = vi.fn(); + scenarios.onNotification = onNotification; + + const response = await scenarios.send('level_complete'); + + expect(response.plans).toHaveLength(1); + expect(scenarios.isRunning).toBe(true); + expect(onNotification).toHaveBeenCalledOnce(); + expect(onNotification.mock.calls[0][0]).toBeInstanceOf(NotificationSession); + }); + }); + + describe('node dispatch', () => { + it('notification node fires onNotification', async () => { + const { scenarios } = await createClientWithScenario(); + const onNotif = vi.fn(); + scenarios.onNotification = onNotif; + + const plan = makePlan({ + nodes: [makeNode('start', 'notification')], + }); + vi.stubGlobal('fetch', vi.fn().mockResolvedValue( + new Response(JSON.stringify({ plans: [plan] }), { status: 200 }), + )); + + await scenarios.send('test'); + expect(onNotif).toHaveBeenCalledOnce(); + }); + + it('store node fires onStore', async () => { + const { scenarios } = await createClientWithScenario(); + const onStore = vi.fn(); + scenarios.onStore = onStore; + + const plan = makePlan({ + nodes: [makeNode('start', 'store')], + }); + vi.stubGlobal('fetch', vi.fn().mockResolvedValue( + new Response(JSON.stringify({ plans: [plan] }), { status: 200 }), + )); + + await scenarios.send('test'); + expect(onStore).toHaveBeenCalledOnce(); + expect(onStore.mock.calls[0][0]).toBeInstanceOf(StoreSession); + }); + + it('wait node fires onWait', async () => { + const { scenarios } = await createClientWithScenario(); + const onWait = vi.fn(); + scenarios.onWait = onWait; + + const plan = makePlan({ + nodes: [makeNode('start', 'wait', { duration: 5, unit: 'minutes' })], + }); + vi.stubGlobal('fetch', vi.fn().mockResolvedValue( + new Response(JSON.stringify({ plans: [plan] }), { status: 200 }), + )); + + await scenarios.send('test'); + expect(onWait).toHaveBeenCalledOnce(); + expect(onWait.mock.calls[0][0]).toBeInstanceOf(WaitSession); + }); + + it('leaderboard node fires onLeaderboard', async () => { + const { scenarios } = await createClientWithScenario(); + const onLb = vi.fn(); + scenarios.onLeaderboard = onLb; + + const plan = makePlan({ + nodes: [makeNode('start', 'leaderboard')], + }); + vi.stubGlobal('fetch', vi.fn().mockResolvedValue( + new Response(JSON.stringify({ plans: [plan] }), { status: 200 }), + )); + + await scenarios.send('test'); + expect(onLb).toHaveBeenCalledOnce(); + expect(onLb.mock.calls[0][0]).toBeInstanceOf(LeaderboardSession); + }); + + it('quest node dispatches onQuest instead of stalling', async () => { + const { client, scenarios } = await createClientWithScenario(); + const onQuest = vi.fn(); + client.effects.onQuest(onQuest); + + const plan = makePlan({ + nodes: [ + makeNode('start', 'quest', { + name: 'Daily', + objectives: [{ objectiveId: 'kills', target: 10 }], + }), + ], + }); + vi.stubGlobal('fetch', vi.fn().mockResolvedValue( + new Response(JSON.stringify({ plans: [plan] }), { status: 200 }), + )); + + await scenarios.send('test'); + expect(onQuest).toHaveBeenCalledOnce(); + expect(onQuest.mock.calls[0][0].name).toBe('Daily'); + // Active and waiting for progress — not stalled, not failed. + expect(scenarios.isRunning).toBe(true); + }); + + it('battlepass node dispatches onBattlePass', async () => { + const { client, scenarios } = await createClientWithScenario(); + const onBp = vi.fn(); + client.effects.onBattlePass(onBp); + + const plan = makePlan({ + nodes: [makeNode('start', 'battlepass', { premiumPrice: 100 })], + }); + vi.stubGlobal('fetch', vi.fn().mockResolvedValue( + new Response(JSON.stringify({ plans: [plan] }), { status: 200 }), + )); + + await scenarios.send('test'); + expect(onBp).toHaveBeenCalledOnce(); + expect(scenarios.isRunning).toBe(true); + }); + + it('battlepass_level node dispatches onBattlePassLevel', async () => { + const { client, scenarios } = await createClientWithScenario(); + const onLevel = vi.fn(); + client.effects.onBattlePassLevel(onLevel); + + const plan = makePlan({ + nodes: [makeNode('start', 'battlepass_level', { level: 3 })], + }); + vi.stubGlobal('fetch', vi.fn().mockResolvedValue( + new Response(JSON.stringify({ plans: [plan] }), { status: 200 }), + )); + + await scenarios.send('test'); + expect(onLevel).toHaveBeenCalledOnce(); + expect(onLevel.mock.calls[0][0].level).toBe(3); + }); + + it('remote_config_override node applies patches internally and auto-completes', async () => { + const { client, scenarios } = await createClientWithScenario(); + + const plan = makePlan({ + nodes: [ + makeNode('start', 'remote_config_override', { + patches: [ + { path: 'difficulty', valueType: 'string', value: 'hard' }, + ], + }), + ], + }); + vi.stubGlobal('fetch', vi.fn().mockResolvedValue( + new Response(JSON.stringify({ plans: [plan] }), { status: 200 }), + )); + + await scenarios.send('test'); + expect(client.remoteConfig.get('difficulty', '')).toBe('hard'); + // RemoteConfigOverride auto-completes asynchronously — wait for the promise. + await new Promise((r) => setTimeout(r, 50)); + expect(scenarios.isRunning).toBe(false); + }); + + it('unsupported node type fails the run and surfaces onScenarioFailed', async () => { + const { client, scenarios } = await createClientWithScenario(); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const onFailed = vi.fn(); + client.effects.onScenarioFailed(onFailed); + + const plan = makePlan({ + nodes: [makeNode('start', 'unknown_type')], + }); + vi.stubGlobal('fetch', vi.fn().mockResolvedValue( + new Response(JSON.stringify({ plans: [plan] }), { status: 200 }), + )); + + await scenarios.send('test'); + expect(warn).toHaveBeenCalledWith( + expect.stringContaining('Unsupported scenario node type'), + ); + // The run must NOT stall: it fails and the game is notified. + expect(onFailed).toHaveBeenCalledOnce(); + expect(scenarios.isRunning).toBe(false); + warn.mockRestore(); + }); + }); + + describe('DAG traversal', () => { + it('completing a node follows matching edges', async () => { + const { scenarios } = await createClientWithScenario(); + + const onNotif = vi.fn(); + scenarios.onNotification = onNotif; + + // start(notif) → complete("output") → node2(notif) + const plan = makePlan({ + nodes: [ + makeNode('start', 'notification'), + makeNode('node2', 'notification'), + ], + edges: [makeEdge('start', 'output', 'node2')], + }); + + let callCount = 0; + vi.stubGlobal('fetch', vi.fn().mockImplementation(() => { + callCount++; + if (callCount === 1) { + return Promise.resolve(new Response(JSON.stringify({ plans: [plan] }), { status: 200 })); + } + return Promise.resolve(new Response(JSON.stringify({}), { status: 200 })); + })); + + await scenarios.send('test'); + expect(onNotif).toHaveBeenCalledOnce(); // start node dispatched + + // Complete the notification node with handle "output" + const session = onNotif.mock.calls[0][0] as NotificationSession; + await session.complete(); + expect(onNotif).toHaveBeenCalledTimes(2); + }); + + it('completing a node with already-completed handle is idempotent', async () => { + const { scenarios } = await createClientWithScenario(); + + const onNotif = vi.fn(); + scenarios.onNotification = onNotif; + + const plan = makePlan({ + nodes: [ + makeNode('start', 'notification'), + makeNode('node2', 'notification'), + ], + edges: [makeEdge('start', 'output', 'node2')], + }); + + vi.stubGlobal('fetch', vi.fn().mockResolvedValue( + new Response(JSON.stringify({ plans: [plan] }), { status: 200 }), + )); + + await scenarios.send('test'); + expect(scenarios.activeRuns).toHaveLength(1); + + const session = onNotif.mock.calls[0][0] as NotificationSession; + await session.complete(); + + // node2 should now be active + expect(scenarios.activeRuns).toHaveLength(1); + + // Complete node2 + const session2 = onNotif.mock.calls[1][0] as NotificationSession; + await session2.complete(); + + // Run should be completed + expect(scenarios.isRunning).toBe(false); + }); + + it('run completion fires onRunCompleted and onCompleted', async () => { + const { scenarios } = await createClientWithScenario(); + + const onCompleted = vi.fn(); + const onRunCompleted = vi.fn(); + scenarios.onCompleted = onCompleted; + scenarios.onRunCompleted = onRunCompleted; + + const plan = makePlan({ + nodes: [makeNode('start', 'notification')], + }); + vi.stubGlobal('fetch', vi.fn().mockResolvedValue( + new Response(JSON.stringify({ plans: [plan] }), { status: 200 }), + )); + + await scenarios.send('test'); + const session = (scenarios as unknown as { onNotification?: (s: NotificationSession) => void }).onNotification?.( + // Use the mock calls to get the session + vi.mocked(vi.fn()).mock.calls[0]?.[0] as NotificationSession, + ); + // We need to get the session from the mock spy + // Actually let's trigger via respond() + scenarios.respond('output'); + // Fire-and-forget — wait a tick + await new Promise((r) => setTimeout(r, 10)); + + expect(onRunCompleted).toHaveBeenCalled(); + expect(onCompleted).toHaveBeenCalled(); + }); + }); + + describe('wait nodes', () => { + it('sets a deadline and fires onWait', async () => { + const { scenarios } = await createClientWithScenario(); + const onWait = vi.fn(); + scenarios.onWait = onWait; + + const plan = makePlan({ + nodes: [makeNode('start', 'wait', { duration: 10, unit: 'minutes' })], + }); + vi.stubGlobal('fetch', vi.fn().mockResolvedValue( + new Response(JSON.stringify({ plans: [plan] }), { status: 200 }), + )); + + await scenarios.send('test'); + expect(onWait).toHaveBeenCalledOnce(); + const session = onWait.mock.calls[0][0] as WaitSession; + expect(session.deadlineUtc).toBeInstanceOf(Date); + // Deadline should be ~10 minutes from now. + const diff = session.deadlineUtc.getTime() - Date.now(); + expect(diff).toBeGreaterThan(9 * 60 * 1000); + expect(diff).toBeLessThan(11 * 60 * 1000); + }); + + it('completes immediately if deadline has already passed', async () => { + const { scenarios } = await createClientWithScenario(); + const onWait = vi.fn(); + const onCompleted = vi.fn(); + scenarios.onWait = onWait; + scenarios.onCompleted = onCompleted; + + // Duration of 0 should result in an immediate completion. + const plan = makePlan({ + nodes: [makeNode('start', 'wait', { duration: 0, unit: 'seconds' })], + }); + vi.stubGlobal('fetch', vi.fn().mockResolvedValue( + new Response(JSON.stringify({ plans: [plan] }), { status: 200 }), + )); + + await scenarios.send('test'); + expect(onWait).toHaveBeenCalledOnce(); + // Wait for the setTimeout(0) to fire. + await new Promise((r) => setTimeout(r, 50)); + expect(onCompleted).toHaveBeenCalled(); + }); + }); + + describe('store session', () => { + it('buy() purchases the selected offer and completes with onPurchase', async () => { + const { scenarios } = await createClientWithScenario(); + const onStore = vi.fn(); + scenarios.onStore = onStore; + + const plan = makePlan({ + nodes: [makeNode('start', 'store', { storeSlug: 'starter' })], + }); + vi.stubGlobal('fetch', vi.fn().mockImplementation((url: string, init?: RequestInit) => { + const parsed = new URL(url); + if (init?.method === 'POST' && parsed.pathname === '/sdk/v1/scenarios/trigger') { + return Promise.resolve(new Response(JSON.stringify({ plans: [plan] }), { status: 200 })); + } + if (init?.method === 'GET' && parsed.pathname === '/sdk/v1/stores/starter') { + return Promise.resolve(new Response(JSON.stringify({ + name: 'Starter', + slug: 'starter', + offers: [{ id: 'pack_1', name: 'Starter Pack' }], + }), { status: 200 })); + } + if (init?.method === 'POST' && parsed.pathname === '/sdk/v1/stores/starter/offers/pack_1/purchase') { + return Promise.resolve(new Response(JSON.stringify({ success: true, purchaseId: 'purchase-1' }), { status: 200 })); + } + return Promise.resolve(new Response(JSON.stringify({}), { status: 200 })); + })); + + await scenarios.send('test'); + const session = onStore.mock.calls[0][0] as StoreSession; + + const onCompleted = vi.fn(); + scenarios.onCompleted = onCompleted; + + const store = await session.getStore(); + const purchase = await session.buy(store.offers[0]); + expect(purchase.success).toBe(true); + expect(session.isResolved).toBe(true); + + // Second call should be a no-op. + const duplicate = await session.buy(store.offers[0]); + expect(duplicate.success).toBe(false); + }); + + it('decline() completes with onDecline', async () => { + const { scenarios } = await createClientWithScenario(); + const onStore = vi.fn(); + scenarios.onStore = onStore; + + const plan = makePlan({ + nodes: [makeNode('start', 'store')], + }); + vi.stubGlobal('fetch', vi.fn().mockResolvedValue( + new Response(JSON.stringify({ plans: [plan] }), { status: 200 }), + )); + + await scenarios.send('test'); + const session = onStore.mock.calls[0][0] as StoreSession; + await session.decline(); + expect(session.isResolved).toBe(true); + }); + }); + + describe('respond / clear', () => { + it('respond completes the first active node', async () => { + const { scenarios } = await createClientWithScenario(); + const onNotif = vi.fn(); + scenarios.onNotification = onNotif; + + const plan = makePlan({ + nodes: [ + makeNode('start', 'notification'), + makeNode('node2', 'notification'), + ], + edges: [makeEdge('start', 'output', 'node2')], + }); + vi.stubGlobal('fetch', vi.fn().mockResolvedValue( + new Response(JSON.stringify({ plans: [plan] }), { status: 200 }), + )); + + await scenarios.send('test'); + + // respond() completes the first active node with the given handle. + scenarios.respond('output'); + + // Wait for async completion. + await new Promise((r) => setTimeout(r, 50)); + + // node2 should now be active (second notification dispatched). + expect(scenarios.isRunning).toBe(true); + }); + + it('clear removes all runs', async () => { + const { scenarios } = await createClientWithScenario(); + + const plan = makePlan({ + nodes: [makeNode('start', 'notification')], + }); + vi.stubGlobal('fetch', vi.fn().mockResolvedValue( + new Response(JSON.stringify({ plans: [plan] }), { status: 200 }), + )); + + await scenarios.send('test'); + expect(scenarios.isRunning).toBe(true); + + scenarios.clear(); + expect(scenarios.isRunning).toBe(false); + }); + }); + + describe('runId filtering', () => { + it('rejects plans with duplicate runId', async () => { + const { scenarios } = await createClientWithScenario(); + + const onNotification = vi.fn(); + scenarios.onNotification = onNotification; + + const plan = makePlan({ + runId: 'run-1', + nodes: [makeNode('start', 'notification')], + }); + + vi.stubGlobal('fetch', vi.fn().mockImplementation(() => + Promise.resolve(new Response(JSON.stringify({ plans: [plan] }), { status: 200 })), + )); + + // First send creates a run + await scenarios.send('first-event'); + expect(scenarios.activeRuns).toHaveLength(1); + expect(onNotification).toHaveBeenCalledTimes(1); + + // Second send with same runId is rejected by startPlan dedup + await scenarios.send('second-event'); + expect(scenarios.activeRuns).toHaveLength(1); + expect(onNotification).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/test/e2e-prod/_setup/adminClient.ts b/test/e2e-prod/_setup/adminClient.ts new file mode 100644 index 0000000..5de493a --- /dev/null +++ b/test/e2e-prod/_setup/adminClient.ts @@ -0,0 +1,77 @@ +// Minimal fetch wrapper for the LiveOps platform/admin HTTP API used by the seed +// and by tests' admin-side assertions. Retries network errors and 5xx with backoff; +// fails fast on 4xx. + +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +export class ApiError extends Error { + constructor( + public readonly status: number, + public readonly path: string, + public readonly body: string, + ) { + super(`HTTP ${status} on ${path}: ${body.slice(0, 400)}`); + this.name = 'ApiError'; + } +} + +export interface AdminClient { + setToken(token: string): void; + readonly token: string | undefined; + get(path: string, opts?: { auth?: boolean }): Promise; + post(path: string, body?: unknown, opts?: { auth?: boolean }): Promise; + put(path: string, body?: unknown, opts?: { auth?: boolean }): Promise; + del(path: string, opts?: { auth?: boolean }): Promise; +} + +export function createAdminClient(baseUrl: string): AdminClient { + let token: string | undefined; + + async function request( + method: string, + path: string, + body?: unknown, + opts?: { auth?: boolean }, + ): Promise { + const url = `${baseUrl}${path}`; + const headers: Record = { 'Content-Type': 'application/json' }; + if (opts?.auth !== false && token) headers['Authorization'] = `Bearer ${token}`; + + let lastErr: unknown; + for (let attempt = 0; attempt < 4; attempt++) { + try { + const res = await fetch(url, { + method, + headers, + body: body === undefined ? undefined : JSON.stringify(body), + }); + const text = await res.text(); + if (res.status >= 500) { + lastErr = new ApiError(res.status, path, text); + await sleep(300 * (attempt + 1)); + continue; + } + if (!res.ok) throw new ApiError(res.status, path, text); + return (text ? JSON.parse(text) : undefined) as T; + } catch (err) { + if (err instanceof ApiError) throw err; // 4xx — don't retry + lastErr = err; // network error — retry + await sleep(300 * (attempt + 1)); + } + } + throw lastErr; + } + + return { + setToken(t: string) { + token = t; + }, + get token() { + return token; + }, + get: (path, opts) => request('GET', path, undefined, opts), + post: (path, body, opts) => request('POST', path, body, opts), + put: (path, body, opts) => request('PUT', path, body, opts), + del: (path, opts) => request('DELETE', path, undefined, opts), + }; +} diff --git a/test/e2e-prod/_setup/artifact.ts b/test/e2e-prod/_setup/artifact.ts new file mode 100644 index 0000000..649fb89 --- /dev/null +++ b/test/e2e-prod/_setup/artifact.ts @@ -0,0 +1,19 @@ +import { existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +import type { SeedArtifact } from './types.js'; + +export const ARTIFACT_PATH = resolve(process.cwd(), '.e2e-prod.local.json'); + +export function writeArtifact(artifact: SeedArtifact): void { + writeFileSync(ARTIFACT_PATH, JSON.stringify(artifact, null, 2)); +} + +export function readArtifact(): SeedArtifact | null { + if (!existsSync(ARTIFACT_PATH)) return null; + return JSON.parse(readFileSync(ARTIFACT_PATH, 'utf8')) as SeedArtifact; +} + +export function removeArtifact(): void { + if (existsSync(ARTIFACT_PATH)) rmSync(ARTIFACT_PATH); +} diff --git a/test/e2e-prod/_setup/globalSetup.ts b/test/e2e-prod/_setup/globalSetup.ts new file mode 100644 index 0000000..86d6f93 --- /dev/null +++ b/test/e2e-prod/_setup/globalSetup.ts @@ -0,0 +1,48 @@ +// Vitest globalSetup for the production e2e suite. +// +// Setup: seeds a fresh project on prod (unless RUDDER_E2E_PROJECT_KEY is provided) and +// writes .e2e-prod.local.json for the tests to read. +// Teardown: deletes the seeded project (default). Set RUDDER_E2E_KEEP=1 to keep it. + +import { runSeed, deleteProject } from './seed.js'; +import { readArtifact, writeArtifact, removeArtifact } from './artifact.js'; +import type { SeedArtifact } from './types.js'; + +export default async function setup(): Promise<() => Promise> { + const baseUrl = process.env.RUDDER_E2E_BASE_URL ?? 'https://api.rudder.build'; + + // Externally-provided project: skip seeding, just verify an artifact/env exists. + if (process.env.RUDDER_E2E_PROJECT_KEY) { + const existing = readArtifact(); + if (!existing) { + writeArtifact({ + baseUrl, + projectKey: process.env.RUDDER_E2E_PROJECT_KEY, + // Remaining fields are unknown for an external project; tests relying on the + // seeded dataset/admin token will require a real seed run. + } as unknown as SeedArtifact); + } + return async () => {}; + } + + const log = (m: string) => console.log(`[seed] ${m}`); + log(`seeding ${baseUrl} ...`); + const artifact = await runSeed(baseUrl, log); + writeArtifact(artifact); + log(`seed complete: project=${artifact.projectId}`); + + return async () => { + if (process.env.RUDDER_E2E_KEEP === '1') { + console.log('[seed] RUDDER_E2E_KEEP=1 — leaving project in place'); + return; + } + try { + await deleteProject(artifact); + console.log(`[seed] deleted project ${artifact.projectId}`); + } catch (err) { + console.warn(`[seed] failed to delete project ${artifact.projectId}: ${String(err)}`); + } finally { + removeArtifact(); + } + }; +} diff --git a/test/e2e-prod/_setup/harness.ts b/test/e2e-prod/_setup/harness.ts new file mode 100644 index 0000000..2e5e593 --- /dev/null +++ b/test/e2e-prod/_setup/harness.ts @@ -0,0 +1,99 @@ +// Test-side helpers: load the seed artifact, build SDK clients against prod, and +// reach the admin API for wallet grants / player inspection. + +import { RudderClient } from '../../../src/client/RudderClient.js'; +import type { RemoteConfigShape } from '../../../src/state/RemoteConfigState.js'; +import type { TokenStore } from '../../../src/token/TokenStore.js'; +import { createAdminClient, type AdminClient } from './adminClient.js'; +import { readArtifact } from './artifact.js'; +import type { SeedArtifact } from './types.js'; + +type RuntimeOptions = { + planStateStore?: { + state: string | null; + load(): Promise; + save(state: string | null): Promise; + } | null; + loginEvent?: string | null; +}; + +/** Loads the seed artifact; RUDDER_E2E_* env vars override file values. */ +export function loadArtifact(): SeedArtifact { + const file = readArtifact(); + if (!file) { + throw new Error( + 'No seed artifact (.e2e-prod.local.json). Run `npm run test:e2e-prod` (globalSetup seeds automatically).', + ); + } + return { + ...file, + baseUrl: process.env.RUDDER_E2E_BASE_URL ?? file.baseUrl, + projectKey: process.env.RUDDER_E2E_PROJECT_KEY ?? file.projectKey, + }; +} + +export function createInMemoryTokenStore(): TokenStore { + let access: string | null = null; + let refresh: string | null = null; + return { + getAccessToken: () => access, + getRefreshToken: () => refresh, + saveTokens: (a, r) => { + access = a; + refresh = r; + }, + clear: () => { + access = null; + refresh = null; + }, + }; +} + +/** A client with no auth yet (in-memory tokens, no IndexedDB persistence). */ +export function makeProdClient( + artifact: SeedArtifact = loadArtifact(), + runtime: RuntimeOptions = { planStateStore: null, loginEvent: null }, +): RudderClient { + return new RudderClient({ + baseUrl: artifact.baseUrl, + projectKey: artifact.projectKey, + tokenStore: createInMemoryTokenStore(), + runtime, + }); +} + +/** A device-authenticated client. In Node each call mints a fresh player (ephemeral device id). */ +export async function freshPlayer( + artifact: SeedArtifact = loadArtifact(), + runtime?: RuntimeOptions, +): Promise> { + const client = makeProdClient(artifact, runtime); + await client.auth.loginWithDevice({ region: 'en', language: 'en' }); + return client; +} + +/** Admin (operator) API client authenticated with the seeded operator token. */ +export function adminApi(artifact: SeedArtifact = loadArtifact()): AdminClient { + const api = createAdminClient(artifact.baseUrl); + api.setToken(artifact.adminToken); + return api; +} + +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +/** Retries a read until ok(result) holds — absorbs release/cache reload lag. */ +export async function withReadRetry( + fn: () => Promise, + ok: (value: T) => boolean, + tries = 8, + gapMs = 1500, +): Promise { + let last = await fn(); + if (ok(last)) return last; + for (let i = 1; i < tries; i++) { + await sleep(gapMs); + last = await fn(); + if (ok(last)) return last; + } + return last; +} diff --git a/test/e2e-prod/_setup/seed.ts b/test/e2e-prod/_setup/seed.ts new file mode 100644 index 0000000..a052107 --- /dev/null +++ b/test/e2e-prod/_setup/seed.ts @@ -0,0 +1,311 @@ +// Production seed: registers an operator, creates a project, configures staging +// content (items, remote configs, leaderboard, store with free+paid offers, a +// global quest, a scenario), and promotes it to the prod snapshot the runtime SDK serves. +// Excludes realtime & analytics. +// +// The scenario flow uses the SDK runtime's exact node `data` keys and handle names — +// the game's plan builder copies node.data verbatim into the execution plan +// (see liveops-game/shared/fsm/plan_builder.go), so what we store is what the SDK runs. + +import { createAdminClient, type AdminClient } from './adminClient.js'; +import type { SeedArtifact } from './types.js'; + +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +interface ReleaseResponse { + id: string; + status: string; + version: number; +} + +interface QuestResponse { + id: string; + name: string; + status: string; + objectives?: Array<{ id: string; metric: string; target: number }>; + rewards?: Array<{ itemId?: string; currency?: string; amount: number }>; +} + +interface StoreResponse { + id: string; + name: string; + data?: { slug?: string }; + offers?: Array<{ + id: string; + name: string; + contents?: Array<{ itemId: string; amount: number }>; + }>; +} + +function buildFlow(scenarioSlug: string) { + return { + nodes: [ + { id: 'trigger_1', type: 'trigger', data: { triggerType: 'event', onEvent: 'session_start' } }, + { id: 'notify_1', type: 'notification', data: { title: 'Welcome', message: 'Welcome to the game!' } }, + { + id: 'rc_1', + type: 'remote_config_override', + data: { patches: [{ path: 'spawn_rate', valueType: 'float', value: '3.0' }] }, + }, + { id: 'store_1', type: 'store', data: { storeSlug: scenarioSlug } }, + { id: 'cond_1', type: 'condition', data: { logic: 'AND', rules: [] } }, + { id: 'notify_2', type: 'notification', data: { title: 'Thanks', message: 'Enjoy your purchase!' } }, + ], + edges: [ + { id: 'e1', source: 'trigger_1', sourceHandle: 'onActivate', target: 'notify_1' }, + { id: 'e2', source: 'notify_1', sourceHandle: 'output', target: 'rc_1' }, + { id: 'e3', source: 'rc_1', sourceHandle: 'output', target: 'store_1' }, + { id: 'e4', source: 'store_1', sourceHandle: 'onPurchase', target: 'cond_1' }, + { id: 'e5', source: 'cond_1', sourceHandle: 'true', target: 'notify_2' }, + ], + }; +} + +async function pollRelease( + api: AdminClient, + projPath: string, + env: string, + releaseId: string, + log: (m: string) => void, +): Promise { + const deadline = Date.now() + 60_000; + while (Date.now() < deadline) { + const releases = await api.get( + `${projPath}/releases?environment=${env}&limit=20&offset=0`, + ); + const rel = releases.find((r) => r.id === releaseId); + if (rel) { + if (rel.status === 'completed') { + log(`release ${releaseId} completed (v${rel.version})`); + return; + } + if (rel.status === 'failed') { + throw new Error(`release ${releaseId} failed`); + } + } + await sleep(1500); + } + throw new Error(`release ${releaseId} did not complete within 60s`); +} + +export async function runSeed( + baseUrl: string, + log: (m: string) => void = () => {}, +): Promise { + const api = createAdminClient(baseUrl); + const ts = Date.now(); + const authoringEnv = 'staging'; + const runtimeEnv = 'prod'; + const q = `?environment=${authoringEnv}`; + const prodQ = `?environment=${runtimeEnv}`; + const adminEmail = `e2e+${ts}@rudder.build`; + const password = `Passw0rd!e2e-${ts}`; + + // 1. Register operator (open registration; no approval gate at the API layer). + const auth = await api.post<{ accessToken: string; refreshToken: string }>( + '/platform/v1/authorization/register', + { email: adminEmail, password, fullName: 'E2E Bot' }, + { auth: false }, + ); + api.setToken(auth.accessToken); + log(`registered ${adminEmail}`); + + // 2. Project. + const project = await api.post<{ id: string; key: string; name: string }>( + '/platform/v1/projects', + { name: `e2e-${ts}` }, + ); + const projPath = `/platform/v1/projects/${project.id}`; + log(`project ${project.id} key=${project.key}`); + + // 3. Staging environment (idempotent — may already exist). + try { + await api.post(`${projPath}/environments`, { projectId: project.id, name: authoringEnv }); + } catch { + // already exists / not required — content POSTs below are the real test + } + + // 4. Item (granted as a paid offer's contents). + const item = await api.post<{ id: string; name: string }>(`${projPath}/items${q}`, { + projectId: project.id, + environment: authoringEnv, + name: 'Gold Coin', + tags: ['currency'], + }); + + // 5. Remote configs — one per value type. + const remoteConfigs: Array<{ key: string; value: string; valueType: string }> = [ + { key: 'max_energy', value: '100', valueType: 'int' }, + { key: 'spawn_rate', value: '1.5', valueType: 'float' }, + { key: 'feature_x', value: 'true', valueType: 'bool' }, + { key: 'welcome_text', value: 'hi', valueType: 'string' }, + { key: 'shop_layout', value: '{"cols":3}', valueType: 'json' }, + ]; + for (const rc of remoteConfigs) { + await api.post(`${projPath}/remote-configs${q}`, { + projectId: project.id, + environment: authoringEnv, + ...rc, + }); + } + log(`created ${remoteConfigs.length} remote configs`); + + // 6. Leaderboard. + await api.post(`${projPath}/leaderboards${q}`, { + projectId: project.id, + environment: authoringEnv, + name: 'Weekly Score', + slug: 'weekly-score', + metric: 'score', + resetPeriod: 'weekly', + sortingOrder: 'desc', + maxEntries: 100, + }); + + // 7. Store with a free offer and a paid (currency) offer with a purchase limit. + // The runtime resolves a store's slug from data.slug (liveops-game shop cache), and + // the CreateStore API has no slug field — so we set it via data. + const storeSlug = 'starter-shop'; + const store = await api.post<{ id: string; offers: Array<{ id: string; name: string }> }>( + `${projPath}/stores${q}`, + { + projectId: project.id, + environment: authoringEnv, + name: 'Starter Shop', + description: 'E2E shop', + data: { slug: storeSlug }, + offers: [ + { name: 'Welcome Gift', contents: [{ itemId: item.id, amount: 10 }], price: { currency: 'soft', amount: 0 } }, + { + name: 'Gold Pack', + contents: [{ itemId: item.id, amount: 50 }], + price: { currency: 'soft', amount: 100 }, + maxPurchases: 2, + }, + ], + }, + ); + log(`store ${storeSlug} (${store.id}) with ${store.offers?.length ?? 0} offers`); + const paidOffer = store.offers?.find((o) => o.name === 'Gold Pack'); + if (!paidOffer?.id) { + throw new Error('seeded paid offer did not return an id'); + } + + // 8. Global quest: buying the paid offer completes it; claim grants extra item reward. + const questName = 'Buy Gold Pack Quest'; + const questObjectiveId = 'buy-gold-pack'; + const questTarget = 1; + const questMetric = `purchase.offer:${paidOffer.id}`; + const questRewardAmount = 7; + const quest = await api.post(`${projPath}/quests${q}`, { + projectId: project.id, + environment: authoringEnv, + name: questName, + objectives: [{ id: questObjectiveId, metric: questMetric, target: questTarget }], + rewards: [{ itemId: item.id, amount: questRewardAmount }], + }); + log(`quest ${quest.id} staged for metric ${questMetric}`); + + // 9. Scenario (must be live now: startAt <= now <= endAt). + const startAt = new Date(ts - 3600_000).toISOString(); + const endAt = new Date(ts + 365 * 24 * 3600_000).toISOString(); + const scenario = await api.post<{ id: string }>(`${projPath}/scenarios${q}`, { + projectId: project.id, + environment: authoringEnv, + name: 'Onboarding', + description: 'e2e onboarding', + startAt, + endAt, + }); + + // 10. Scenario flow (trigger -> notification -> rc override -> store -> boundary -> notification). + await api.put(`${projPath}/scenarios/${scenario.id}`, { + projectId: project.id, + scenarioId: scenario.id, + flow: buildFlow(storeSlug), + }); + log(`scenario ${scenario.id} flow set`); + + // 11. Promote staging content to prod (the runtime serves prod/latest.json). + const release = await api.post(`${projPath}/releases/promote`); + await pollRelease(api, projPath, runtimeEnv, release.id, log); + + const prodStores = await api.get(`${projPath}/stores${prodQ}`); + const prodStore = prodStores.find((s) => s.data?.slug === storeSlug || s.name === 'Starter Shop'); + if (!prodStore) { + throw new Error(`promoted store ${storeSlug} was not found in ${runtimeEnv}`); + } + const prodPaidOffer = prodStore.offers?.find((o) => o.name === 'Gold Pack'); + if (!prodPaidOffer?.id) { + throw new Error('promoted paid offer did not return an id'); + } + const prodRewardItemId = prodPaidOffer.contents?.[0]?.itemId; + if (!prodRewardItemId) { + throw new Error('promoted paid offer did not return a reward item id'); + } + + const prodQuests = await api.get(`${projPath}/quests${prodQ}`); + const prodQuest = prodQuests.find((q) => q.name === questName); + if (!prodQuest?.id) { + throw new Error(`promoted quest ${questName} was not found in ${runtimeEnv}`); + } + const prodQuestMetric = prodQuest.objectives?.find((o) => o.id === questObjectiveId)?.metric; + if (!prodQuestMetric) { + throw new Error(`promoted quest ${questName} did not return metric ${questObjectiveId}`); + } + const prodQuestRewardItemId = prodQuest.rewards?.[0]?.itemId; + if (!prodQuestRewardItemId) { + throw new Error(`promoted quest ${questName} did not return reward item id`); + } + + // 12. Settle: let the game service hot-reload caches from the new snapshot. + await sleep(3000); + + return { + baseUrl, + projectId: project.id, + projectKey: project.key, + adminToken: auth.accessToken, + adminEmail, + environment: runtimeEnv, + seededAt: new Date(ts).toISOString(), + releaseId: release.id, + store: { + name: 'Starter Shop', + slug: storeSlug, + freeOfferName: 'Welcome Gift', + paidOfferName: 'Gold Pack', + paidOfferId: prodPaidOffer.id, + paidPrice: { currency: 'soft', amount: 100 }, + paidMaxPurchases: 2, + grantedItemName: 'Gold Coin', + paidGrantAmount: 50, + }, + quest: { + id: prodQuest.id, + name: questName, + objectiveId: questObjectiveId, + metric: prodQuestMetric, + target: questTarget, + rewardItemId: prodQuestRewardItemId, + rewardAmount: questRewardAmount, + }, + leaderboard: { slug: 'weekly-score', metric: 'score' }, + item: { id: prodRewardItemId, name: item.name }, + remoteConfigs: { + maxEnergy: 100, + spawnRate: 1.5, + featureX: true, + welcomeText: 'hi', + shopLayout: { cols: 3 }, + spawnRateOverride: 3.0, + }, + scenario: { id: scenario.id, event: 'session_start' }, + }; +} + +export async function deleteProject(artifact: SeedArtifact): Promise { + const api = createAdminClient(artifact.baseUrl); + api.setToken(artifact.adminToken); + await api.del(`/platform/v1/projects/${artifact.projectId}`); +} diff --git a/test/e2e-prod/_setup/types.ts b/test/e2e-prod/_setup/types.ts new file mode 100644 index 0000000..5bfaef6 --- /dev/null +++ b/test/e2e-prod/_setup/types.ts @@ -0,0 +1,46 @@ +// Shared types for the production e2e harness. + +/** Persisted seed output, written to .e2e-prod.local.json and read by tests. */ +export interface SeedArtifact { + baseUrl: string; + projectId: string; + projectKey: string; + /** Operator (platform) bearer token — used for admin calls (wallet grant, player details). */ + adminToken: string; + adminEmail: string; + environment: string; + seededAt: string; + releaseId: string; + store: { + name: string; + slug: string; + freeOfferName: string; + paidOfferName: string; + paidOfferId: string; + paidPrice: { currency: string; amount: number }; + paidMaxPurchases: number; + grantedItemName: string; + paidGrantAmount: number; + }; + quest: { + id: string; + name: string; + objectiveId: string; + metric: string; + target: number; + rewardItemId: string; + rewardAmount: number; + }; + leaderboard: { slug: string; metric: string }; + item: { id: string; name: string }; + remoteConfigs: { + maxEnergy: number; + spawnRate: number; + featureX: boolean; + welcomeText: string; + shopLayout: Record; + /** spawn_rate value the scenario's remote_config_override applies. */ + spawnRateOverride: number; + }; + scenario: { id: string; event: string }; +} diff --git a/test/e2e-prod/health.e2e.test.ts b/test/e2e-prod/health.e2e.test.ts new file mode 100644 index 0000000..5feffb1 --- /dev/null +++ b/test/e2e-prod/health.e2e.test.ts @@ -0,0 +1,26 @@ +import { describe, it, expect, beforeAll } from 'vitest'; +import { loadArtifact } from './_setup/harness.js'; + +// Smoke gate: if egress to prod is blocked or the gateway is down, fail fast here. +describe('e2e-prod: health', () => { + let baseUrl: string; + beforeAll(() => { + baseUrl = loadArtifact().baseUrl; + }); + + it('GET /health is ok', async () => { + const res = await fetch(`${baseUrl}/health`); + expect(res.ok).toBe(true); + const body = (await res.json()) as { status?: string }; + expect(body.status).toBe('ok'); + }); + + it('content + game gRPC backends are serving', async () => { + // /readyz may be 503 if analytics is not_serving (out of scope here); assert the + // backends this suite actually uses are serving. + const res = await fetch(`${baseUrl}/readyz`); + const body = (await res.json()) as { grpc?: Record }; + expect(body.grpc?.content).toBe('serving'); + expect(body.grpc?.game).toBe('serving'); + }); +}); diff --git a/test/e2e-prod/leaderboards.e2e.test.ts b/test/e2e-prod/leaderboards.e2e.test.ts new file mode 100644 index 0000000..2a0c65f --- /dev/null +++ b/test/e2e-prod/leaderboards.e2e.test.ts @@ -0,0 +1,30 @@ +import { describe, it, expect, beforeAll } from 'vitest'; +import { freshPlayer, loadArtifact, withReadRetry } from './_setup/harness.js'; +import type { SeedArtifact } from './_setup/types.js'; + +describe('e2e-prod: leaderboards', () => { + let artifact: SeedArtifact; + beforeAll(() => { + artifact = loadArtifact(); + }); + + it('submits a score and sees it in the ranking', async () => { + const client = await freshPlayer(artifact); + const me = (await client.player.reload()).player?.id; + expect(me).toBeTruthy(); + + const lb = client.leaderboards.findBySlug(artifact.leaderboard.slug); + const score = 4242; + await lb.submit(score); + + const entries = await withReadRetry( + () => lb.list(50), + (list) => list.some((e) => e.playerId === me), + ); + const mine = entries.find((e) => e.playerId === me); + expect(mine).toBeDefined(); + // int64 fields (score, rank) serialize as JSON strings via protojson — coerce to number. + expect(Number(mine?.score)).toBe(score); + expect(Number(mine?.rank ?? 0)).toBeGreaterThanOrEqual(1); + }); +}); diff --git a/test/e2e-prod/player.e2e.test.ts b/test/e2e-prod/player.e2e.test.ts new file mode 100644 index 0000000..8a80a1a --- /dev/null +++ b/test/e2e-prod/player.e2e.test.ts @@ -0,0 +1,11 @@ +import { describe, it, expect } from 'vitest'; +import { freshPlayer } from './_setup/harness.js'; + +describe('e2e-prod: player', () => { + it('device login yields a profile with a player id and wallets array', async () => { + const client = await freshPlayer(); + const profile = await client.player.reload(); + expect(profile.player?.id).toBeTruthy(); + expect(Array.isArray(profile.wallets)).toBe(true); + }); +}); diff --git a/test/e2e-prod/purchase.e2e.test.ts b/test/e2e-prod/purchase.e2e.test.ts new file mode 100644 index 0000000..4953248 --- /dev/null +++ b/test/e2e-prod/purchase.e2e.test.ts @@ -0,0 +1,98 @@ +import { describe, it, expect, beforeAll } from 'vitest'; +import { adminApi, freshPlayer, loadArtifact, withReadRetry } from './_setup/harness.js'; +import type { SeedArtifact } from './_setup/types.js'; +import type { RudderClient } from '../../src/client/RudderClient.js'; +import type { OfferHandle } from '../../src/state/shops.js'; + +// Exercises the (newly implemented) wallet + inventory + purchase backend end-to-end: +// admin grant -> debit on buy -> inventory credit -> idempotency -> max_purchases -> insufficient funds. +interface AdminWalletDetails { + currencyCode: string; + balance: number; +} +interface AdminInventoryItem { + itemId: string; + amount: number; +} +interface AdminPlayerDetails { + wallets?: AdminWalletDetails[]; + inventory?: AdminInventoryItem[]; +} + +describe('e2e-prod: purchase (wallet/inventory/limits)', () => { + let artifact: SeedArtifact; + beforeAll(() => { + artifact = loadArtifact(); + }); + + async function resolvePaidOffer(client: RudderClient): Promise { + const stores = await withReadRetry( + () => client.stores.reload(), + (s) => s.some((store) => store.name === artifact.store.name && store.offers.length > 0), + ); + const store = stores.find((s) => s.name === artifact.store.name)!; + return store.offers.find((o) => o.name === artifact.store.paidOfferName)!; + } + + async function balance(client: RudderClient, currency: string): Promise { + const profile = await client.player.reload(); + // int64 fields serialize as JSON strings via protojson — coerce to number. + return Number(profile.wallets?.find((w) => w.currency === currency)?.balance ?? 0); + } + + it('grants currency, debits on buy, credits inventory, is idempotent, and enforces the purchase limit', async () => { + const client = await freshPlayer(artifact); + const playerId = (await client.player.reload()).player!.id!; + const api = adminApi(artifact); + const currency = artifact.store.paidPrice.currency; + const price = artifact.store.paidPrice.amount; + const offer = await resolvePaidOffer(client); + + // 1. Grant currency via the new admin endpoint. + await api.post(`/game/v1/projects/${artifact.projectId}/players/${playerId}/wallet/adjust`, { + currencyCode: currency, + amount: 1000, + reason: 'e2e grant', + }); + const granted = await withReadRetry( + () => balance(client, currency), + (b) => b === 1000, + ); + expect(granted).toBe(1000); + + // 2. First paid buy: success + wallet debited. + const buy1 = await offer.buy(); + expect(buy1.success).toBe(true); + expect(buy1.purchaseId).toBeTruthy(); + expect(await balance(client, currency)).toBe(1000 - price); + + // 3. Inventory credited (verified via admin player details). + const details = await api.get( + `/game/v1/projects/${artifact.projectId}/players/${playerId}`, + ); + const inv = details.inventory?.find((i) => i.itemId === artifact.item.id); + expect(Number(inv?.amount)).toBe(artifact.store.paidGrantAmount); + + // 4. Idempotency: the same key twice charges once. + const key = crypto.randomUUID(); + const a = await offer.buy({ idempotencyKey: key }); + const b = await offer.buy({ idempotencyKey: key }); + expect(a.success).toBe(true); + expect(b.success).toBe(true); + expect(await balance(client, currency)).toBe(1000 - price * 2); + + // 5. max_purchases (2) reached -> a new distinct buy fails (no charge). + const overLimit = await offer.buy(); + expect(overLimit.success).toBe(false); + expect(overLimit.error ?? '').toContain('limit'); + expect(await balance(client, currency)).toBe(1000 - price * 2); + }); + + it('rejects a paid purchase with insufficient funds', async () => { + const poor = await freshPlayer(artifact); + const offer = await resolvePaidOffer(poor); + const res = await offer.buy(); + expect(res.success).toBe(false); + expect(res.error ?? '').toContain('insufficient'); + }); +}); diff --git a/test/e2e-prod/remoteConfig.e2e.test.ts b/test/e2e-prod/remoteConfig.e2e.test.ts new file mode 100644 index 0000000..a15df5b --- /dev/null +++ b/test/e2e-prod/remoteConfig.e2e.test.ts @@ -0,0 +1,33 @@ +import { describe, it, expect, beforeAll } from 'vitest'; +import { freshPlayer, loadArtifact, withReadRetry } from './_setup/harness.js'; +import type { SeedArtifact } from './_setup/types.js'; + +interface GameRemoteConfig extends Record { + max_energy: number; + spawn_rate: number; + feature_x: boolean; + welcome_text: string; + shop_layout: Record; +} + +describe('e2e-prod: remoteConfig', () => { + let artifact: SeedArtifact; + beforeAll(() => { + artifact = loadArtifact(); + }); + + it('loads typed configs matching the seeded values', async () => { + const rc = artifact.remoteConfigs; + const client = await withReadRetry( + () => freshPlayer(artifact), + (candidate) => candidate.remoteConfig.get('max_energy', 0) === rc.maxEnergy, + ); + expect(client.remoteConfig.status).toBe('ready'); + + expect(client.remoteConfig.get('max_energy', 0)).toBe(rc.maxEnergy); + expect(client.remoteConfig.get('spawn_rate', 0)).toBeCloseTo(rc.spawnRate); + expect(client.remoteConfig.get('feature_x', false)).toBe(rc.featureX); + expect(client.remoteConfig.get('welcome_text', '')).toBe(rc.welcomeText); + expect(client.remoteConfig.get('shop_layout', {} as Record)).toEqual(rc.shopLayout); + }); +}); diff --git a/test/e2e-prod/scenarios.e2e.test.ts b/test/e2e-prod/scenarios.e2e.test.ts new file mode 100644 index 0000000..82f5909 --- /dev/null +++ b/test/e2e-prod/scenarios.e2e.test.ts @@ -0,0 +1,52 @@ +import { describe, it, expect, beforeAll, vi } from 'vitest'; +import { loadArtifact, makeProdClient } from './_setup/harness.js'; +import type { SeedArtifact } from './_setup/types.js'; +import type { NotificationEffect, StoreOfferEffect } from '../../src/index.js'; +import { getScenarioRuntime } from '../../src/client/RudderClient.js'; + +describe('e2e-prod: scenarios', () => { + let artifact: SeedArtifact; + beforeAll(() => { + artifact = loadArtifact(); + }); + + it('runs onboarding: notification -> rc override -> store -> boundary callback -> notification', async () => { + const notifications: NotificationEffect[] = []; + let storeOffer: StoreOfferEffect | undefined; + let runCompleted = false; + + const client = makeProdClient(artifact, { + planStateStore: null, + loginEvent: artifact.scenario.event, + }); + + client.effects.onNotification((effect) => { notifications.push(effect); }); + client.effects.onStoreOffer((effect) => { + storeOffer = effect; + }); + const runtime = await getScenarioRuntime(client); + runtime.onRunCompleted = () => { + runCompleted = true; + }; + await client.auth.loginWithDevice({ region: 'en', language: 'en' }); + + // First notification dispatched. + await vi.waitFor(() => expect(notifications.length).toBe(1), { timeout: 10_000 }); + await notifications[0].done(); + + // remote_config_override applies, then the store offer surfaces. + await vi.waitFor(() => expect(storeOffer).toBeDefined(), { timeout: 10_000 }); + expect(client.remoteConfig.get('spawn_rate', 0)).toBeCloseTo( + artifact.remoteConfigs.spawnRateOverride, + ); + + // Buy crosses the server boundary (POST /sdk/v1/scenarios/callback) -> second notification. + const offer = storeOffer!.offers.find((o) => o.name === artifact.store.paidOfferName)!; + await storeOffer!.buy(offer); + await vi.waitFor(() => expect(notifications.length).toBe(2), { timeout: 15_000 }); + await notifications[1].done(); + + await vi.waitFor(() => expect(runtime.isRunning).toBe(false), { timeout: 10_000 }); + expect(runCompleted).toBe(true); + }); +}); diff --git a/test/e2e-prod/storage.e2e.test.ts b/test/e2e-prod/storage.e2e.test.ts new file mode 100644 index 0000000..48cacd2 --- /dev/null +++ b/test/e2e-prod/storage.e2e.test.ts @@ -0,0 +1,22 @@ +import { describe, it, expect } from 'vitest'; +import { freshPlayer } from './_setup/harness.js'; + +describe('e2e-prod: storage', () => { + it('save -> get -> delete round-trip', async () => { + const client = await freshPlayer(); + const payload = JSON.stringify({ level: 7 }); + const item = { id: 'slot_1', type: 'savegame', data: payload }; + + await client.storage.save([item]); + + // The server may assign its own item id, so match on type + data, not the client id. + // v0.2 fetches the whole storage; filtering by type is client-side. + const got = await client.storage.reload(); + expect(got.items?.some((i) => i.type === 'savegame' && i.data === payload)).toBe(true); + + await client.storage.delete('savegame'); + + const after = await client.storage.reload(); + expect(after.items?.some((i) => i.data === payload)).toBeFalsy(); + }); +}); diff --git a/test/e2e-prod/stores.e2e.test.ts b/test/e2e-prod/stores.e2e.test.ts new file mode 100644 index 0000000..48ffa17 --- /dev/null +++ b/test/e2e-prod/stores.e2e.test.ts @@ -0,0 +1,37 @@ +import { describe, it, expect, beforeAll } from 'vitest'; +import { freshPlayer, loadArtifact, withReadRetry } from './_setup/harness.js'; +import type { SeedArtifact } from './_setup/types.js'; + +describe('e2e-prod: stores', () => { + let artifact: SeedArtifact; + beforeAll(() => { + artifact = loadArtifact(); + }); + + it('lists the seeded store with its free + paid offers', async () => { + const client = await freshPlayer(artifact); + const stores = await withReadRetry( + () => client.stores.reload(), + (r) => r.length > 0, + ); + const store = stores.find((s) => s.name === artifact.store.name); + expect(store).toBeDefined(); + + const offerNames = store!.offers.map((o) => o.name); + expect(offerNames).toContain(artifact.store.freeOfferName); + expect(offerNames).toContain(artifact.store.paidOfferName); + }); + + it('buys the free offer successfully', async () => { + const client = await freshPlayer(artifact); + const stores = await withReadRetry( + () => client.stores.reload(), + (s) => s.some((store) => store.name === artifact.store.name && store.offers.length > 0), + ); + const store = stores.find((s) => s.name === artifact.store.name)!; + const free = store.offers.find((o) => o.name === artifact.store.freeOfferName)!; + const res = await free.buy(); + expect(res.success).toBe(true); + expect(res.purchaseId).toBeTruthy(); + }); +}); diff --git a/test/e2e/boundary.e2e.test.ts b/test/e2e/boundary.e2e.test.ts new file mode 100644 index 0000000..8b005dd --- /dev/null +++ b/test/e2e/boundary.e2e.test.ts @@ -0,0 +1,159 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { getScenarioRuntime, RudderClient } from '../../src/client/RudderClient.js'; +import { createFakeTokenStore } from '../helpers/FakeTokenStore.js'; +import { createFakeGateway, type FakeGateway } from '../helpers/fakeGateway.js'; +import { stubDeterministicUuid } from '../helpers/memoryPlanStore.js'; +import { plan } from '../helpers/plan.js'; +import type { StoreOfferEffect } from '../../src/index.js'; +import type { PlanRun, ScenarioRunFailedEvent } from '../../src/scenario/engine/types.js'; + +function makeClient(): RudderClient { + return new RudderClient({ + baseUrl: 'https://api.test.rudder.build', + projectKey: 'test-project', + tokenStore: createFakeTokenStore(), + runtime: { planStateStore: null }, + }); +} + +/** + * An offer whose `onPurchase` continuation lives server-side (boundary node): + * the client doesn't hold the post-purchase DAG — it must call back so the + * server can validate the transaction and decide what happens next. + * + * `onDecline`, by contrast, is a plain local edge handled entirely on-device. + */ +function offerWithBoundary() { + return plan('offer_flow') + .runId('offer_flow-run') + .node('offer', 'store', { storeSlug: 'starter', message: 'Buy the starter pack!' }) + .node('consolation', 'notification', { message: 'Maybe next time!' }) + .edge('offer', 'onDecline', 'consolation') + .boundary('offer', 'onPurchase') // server owns what comes after a purchase + .build(); +} + +/** What the server returns from the callback — a fresh plan = a new run. */ +function rewardContinuation() { + return plan('reward_flow') + .runId('reward_flow-run') + .node('reward', 'notification', { message: 'Reward granted: 500 gems!' }) + .build(); +} + +describe('E2E: boundary nodes (server-side continuation)', () => { + let gateway: FakeGateway; + let client: RudderClient; + + beforeEach(() => { + stubDeterministicUuid(); + gateway = createFakeGateway({ + stores: [{ + slug: 'starter', + name: 'Starter', + offers: [{ id: 'pack_1', name: 'Starter Pack' }], + }], + }); + gateway.install(); + client = makeClient(); + }); + + afterEach(() => vi.unstubAllGlobals()); + + it('purchase crosses the boundary → callback fires and continues the scenario', async () => { + gateway.onEvent('player_login', offerWithBoundary()); + gateway.onCallback('offer', 'onPurchase', rewardContinuation()); + + const messages: string[] = []; + const completed: PlanRun[] = []; + let offer: StoreOfferEffect | undefined; + const runtime = await getScenarioRuntime(client); + client.effects.onStoreOffer((effect) => { offer = effect; }); + client.effects.onNotification((effect) => { messages.push(effect.message); }); + runtime.onRunCompleted = (r) => completed.push(r); + + const token = (await client.auth.loginWithDevice()).accessToken; + await vi.waitFor(() => expect(offer).toBeDefined()); + + await offer!.buy(offer!.offers[0]); + + // The callback hit the gateway with the boundary's source node/handle + token. + const callback = gateway.recorded.find((r) => r.path === '/sdk/v1/scenarios/callback'); + expect(callback?.body).toEqual({ scenarioId: 'offer_flow', nodeId: 'offer', handle: 'onPurchase', runId: 'offer_flow-run' }); + expect(callback?.authToken).toBe(token); + + // The server-supplied continuation ran (as a new run), not the local edge. + expect(messages).toEqual(['Reward granted: 500 gems!']); + expect(messages).not.toContain('Maybe next time!'); + + // The original run finished; the continuation ('reward_flow') is now active. + expect(completed.map((r) => r.scenarioId)).toContain('offer_flow'); + expect(runtime.activeRuns.map((r) => r.scenarioId)).toEqual(['reward_flow']); + }); + + it('decline stays local → no callback, local edge is followed', async () => { + gateway.onEvent('player_login', offerWithBoundary()); + + const messages: string[] = []; + let offer: StoreOfferEffect | undefined; + client.effects.onStoreOffer((effect) => { offer = effect; }); + client.effects.onNotification((effect) => { messages.push(effect.message); }); + + await client.auth.loginWithDevice(); + await vi.waitFor(() => expect(offer).toBeDefined()); + await offer!.dismiss(); + + expect(gateway.recorded.some((r) => r.path === '/sdk/v1/scenarios/callback')).toBe(false); + expect(messages).toEqual(['Maybe next time!']); + }); + + it('a boundary takes precedence over a local edge on the SAME handle', async () => { + // `offer` has BOTH a boundary AND a local edge on onPurchase. + const conflicting = plan('offer_flow') + .runId('offer_flow-conflict') + .node('offer', 'store', { storeSlug: 'starter', message: 'Buy!' }) + .node('local_next', 'notification', { message: 'LOCAL branch' }) + .edge('offer', 'onPurchase', 'local_next') + .boundary('offer', 'onPurchase') + .build(); + gateway.onEvent('player_login', conflicting); + gateway.onCallback('offer', 'onPurchase', rewardContinuation()); + + const messages: string[] = []; + let offer: StoreOfferEffect | undefined; + client.effects.onStoreOffer((effect) => { offer = effect; }); + client.effects.onNotification((effect) => { messages.push(effect.message); }); + + await client.auth.loginWithDevice(); + await vi.waitFor(() => expect(offer).toBeDefined()); + await offer!.buy(offer!.offers[0]); + + // Only the server continuation ran; the local edge was suppressed. + expect(messages).toEqual(['Reward granted: 500 gems!']); + expect(messages).not.toContain('LOCAL branch'); + }); + + it('a failing callback does not fail or crash the run', async () => { + gateway.onEvent('player_login', offerWithBoundary()); + gateway.onCallbackError('offer', 'onPurchase', 500); + + const failures: ScenarioRunFailedEvent[] = []; + const completed: PlanRun[] = []; + let offer: StoreOfferEffect | undefined; + const runtime = await getScenarioRuntime(client); + client.effects.onStoreOffer((effect) => { offer = effect; }); + runtime.onRunFailed = (e) => failures.push(e); + runtime.onRunCompleted = (r) => completed.push(r); + + await client.auth.loginWithDevice(); + await vi.waitFor(() => expect(offer).toBeDefined()); + + // Must not throw despite the 500 from the callback endpoint. + await expect(offer!.buy(offer!.offers[0])).resolves.toMatchObject({ success: true }); + + expect(failures).toHaveLength(0); // 500 is transient — no terminal failure + // The run stays pending because the boundary call failed transiently. + // On reconnection the consumer can retry the purchase completion. + expect(runtime.isRunning).toBe(true); + }); +}); diff --git a/test/e2e/offerJourney.e2e.test.ts b/test/e2e/offerJourney.e2e.test.ts new file mode 100644 index 0000000..5aecaef --- /dev/null +++ b/test/e2e/offerJourney.e2e.test.ts @@ -0,0 +1,157 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { RudderClient } from '../../src/client/RudderClient.js'; +import { createFakeTokenStore } from '../helpers/FakeTokenStore.js'; +import { createFakeGateway, type FakeGateway } from '../helpers/fakeGateway.js'; +import { stubDeterministicUuid } from '../helpers/memoryPlanStore.js'; +import { plan } from '../helpers/plan.js'; +import type { NotificationEffect, StoreOfferEffect } from '../../src/index.js'; + +const MINUTE = 60_000; + +function makeClient(): RudderClient { + return new RudderClient({ + baseUrl: 'https://api.test.rudder.build', + projectKey: 'test-project', + tokenStore: createFakeTokenStore(), + runtime: { planStateStore: null }, // exercised separately in the persistence test + }); +} + +/** + * The offer scenario both branches share: + * + * login → wait 1m → offer ──onDecline──→ wait 1m → "still available" + * └─onPurchase─→ "thanks for your purchase" + */ +function offerScenario() { + return plan('offer_flow') + .node('wait_intro', 'wait', { duration: 1, unit: 'minutes' }) + .node('offer', 'store', { storeSlug: 'starter', offerId: 'pack_1', message: 'Limited starter pack!' }) + .node('wait_reminder', 'wait', { duration: 1, unit: 'minutes' }) + .node('reminder', 'notification', { message: 'Your offer is still available!' }) + .node('thanks', 'notification', { message: 'Thanks for your purchase!' }) + .edge('wait_intro', 'onComplete', 'offer') + .edge('offer', 'onDecline', 'wait_reminder') + .edge('wait_reminder', 'onComplete', 'reminder') + .edge('offer', 'onPurchase', 'thanks') + .build(); +} + +describe('E2E: player offer journey', () => { + let gateway: FakeGateway; + let client: RudderClient; + + beforeEach(() => { + stubDeterministicUuid(); + vi.useFakeTimers(); + gateway = createFakeGateway({ + stores: [{ + slug: 'starter', + name: 'Starter', + offers: [{ id: 'pack_1', name: 'Starter Pack' }], + }], + }); + gateway.install(); + client = makeClient(); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + }); + + it('authenticates the device and persists tokens for later calls', async () => { + const res = await client.auth.loginWithDevice({ region: 'eu', language: 'en' }); + + expect(res.accessToken).toBeTruthy(); + expect(client.options.tokenStore.getAccessToken()).toBe(res.accessToken); + + // The login request carried the project key + a device id. + const login = gateway.recorded.find((r) => r.path === '/sdk/v1/authorization/device'); + expect(login?.body).toMatchObject({ key: 'test-project', region: 'eu', language: 'en' }); + expect((login?.body as { deviceId?: string }).deviceId).toBeTruthy(); + + // The post-login runtime trigger carries the bearer token. + const trigger = gateway.recorded.find((r) => r.path === '/sdk/v1/scenarios/trigger'); + expect(trigger?.authToken).toBe(res.accessToken); + }); + + it('offer appears after 1 min, player declines, reminder fires 1 min later', async () => { + gateway.onEvent('player_login', offerScenario()); + + const notifications: NotificationEffect[] = []; + let offer: StoreOfferEffect | undefined; + client.effects.onStoreOffer((effect) => { offer = effect; }); + client.effects.onNotification((effect) => { notifications.push(effect); }); + + await client.auth.loginWithDevice(); + + // Immediately after login the player is just waiting — no offer yet. + expect(offer).toBeUndefined(); + + // The offer must NOT appear before the full minute has elapsed... + await vi.advanceTimersByTimeAsync(MINUTE - 1_000); + expect(offer).toBeUndefined(); + + // ...and surfaces exactly when the minute is up. + await vi.advanceTimersByTimeAsync(1_000); + await vi.waitFor(() => expect(offer).toBeDefined()); + expect(offer!.message).toBe('Limited starter pack!'); + + // Player declines → the DAG moves to the reminder wait, no notification yet. + await offer!.dismiss(); + expect(notifications).toHaveLength(0); + + // The reminder also honours the full minute, not a moment sooner. + await vi.advanceTimersByTimeAsync(MINUTE - 1_000); + expect(notifications).toHaveLength(0); + await vi.advanceTimersByTimeAsync(1_000); + expect(notifications).toHaveLength(1); + expect(notifications[0].message).toBe('Your offer is still available!'); + }); + + it('offer appears, player buys → "thanks" branch, and the purchase transacts', async () => { + gateway.onEvent('player_login', offerScenario()); + + let offer: StoreOfferEffect | undefined; + const notifications: NotificationEffect[] = []; + client.effects.onStoreOffer((effect) => { offer = effect; }); + client.effects.onNotification((effect) => { notifications.push(effect); }); + + const accessToken = (await client.auth.loginWithDevice()).accessToken; + await vi.advanceTimersByTimeAsync(MINUTE); + await vi.waitFor(() => expect(offer).toBeDefined()); + + // The game buys a selected offer; the session advances only after success. + const selectedOffer = offer!.offers[0]; + const purchase = await offer!.buy(selectedOffer, { idempotencyKey: 'idem-key-123' }); + expect(purchase.success).toBe(true); + + // The purchase hit the gateway with idempotency key + bearer token. + expect(gateway.purchases).toEqual([ + { storeSlug: 'starter', offerId: 'pack_1', idempotencyKey: 'idem-key-123', authToken: accessToken }, + ]); + + // The scenario took the onPurchase branch → "thanks", and the run finished. + expect(notifications).toHaveLength(1); + expect(notifications[0].message).toBe('Thanks for your purchase!'); + }); + + it('declining does NOT take the purchase branch (handles are isolated)', async () => { + gateway.onEvent('player_login', offerScenario()); + const messages: string[] = []; + let offer: StoreOfferEffect | undefined; + client.effects.onStoreOffer((effect) => { offer = effect; }); + client.effects.onNotification((effect) => { messages.push(effect.message); }); + + await client.auth.loginWithDevice(); + await vi.advanceTimersByTimeAsync(MINUTE); + await vi.waitFor(() => expect(offer).toBeDefined()); + await offer!.dismiss(); + await vi.advanceTimersByTimeAsync(MINUTE); + + expect(messages).toEqual(['Your offer is still available!']); + expect(messages).not.toContain('Thanks for your purchase!'); + expect(gateway.purchases).toHaveLength(0); + }); +}); diff --git a/test/e2e/persistence.e2e.test.ts b/test/e2e/persistence.e2e.test.ts new file mode 100644 index 0000000..2644103 --- /dev/null +++ b/test/e2e/persistence.e2e.test.ts @@ -0,0 +1,75 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { getScenarioRuntime, RudderClient } from '../../src/client/RudderClient.js'; +import { createFakeTokenStore } from '../helpers/FakeTokenStore.js'; +import { createFakeGateway, type FakeGateway } from '../helpers/fakeGateway.js'; +import { createMemoryPlanStore, stubDeterministicUuid } from '../helpers/memoryPlanStore.js'; +import { plan } from '../helpers/plan.js'; +import type { StoreSession, WaitSession } from '../../src/scenario/engine/sessions.js'; + +const MINUTE = 60_000; + +describe('E2E: scenario state survives a reload', () => { + let gateway: FakeGateway; + // Shared backing store mimics IndexedDB persisting across page loads. + const backing = { value: null as string | null }; + + function clientWithSharedStore(): RudderClient { + return new RudderClient({ + baseUrl: 'https://api.test.rudder.build', + projectKey: 'test-project', + tokenStore: createFakeTokenStore(), + runtime: { planStateStore: createMemoryPlanStore(backing), loginEvent: null }, + }); + } + + beforeEach(() => { + stubDeterministicUuid(); + vi.useFakeTimers(); + backing.value = null; + gateway = createFakeGateway(); + gateway.onEvent( + 'session_start', + plan('offer_flow') + .node('wait_intro', 'wait', { duration: 1, unit: 'minutes' }) + .node('offer', 'store', { message: 'Limited starter pack!' }) + .edge('wait_intro', 'onComplete', 'offer') + .build(), + ); + gateway.install(); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + }); + + it('a wait started before reload resumes and fires the offer after reload', async () => { + // --- Session 1: trigger the scenario, then "close the tab" mid-wait --- + const client1 = clientWithSharedStore(); + await client1.auth.loginWithDevice(); + const runtime1 = await getScenarioRuntime(client1); + await runtime1.send('session_start'); + expect(runtime1.isRunning).toBe(true); + expect(backing.value).toBeTruthy(); // run was persisted + + // --- Session 2: fresh client, same persisted state (page reload) --- + const client2 = clientWithSharedStore(); + const runtime2 = await getScenarioRuntime(client2); + const resumedWaits: WaitSession[] = []; + let offer: StoreSession | undefined; + runtime2.onWait = (s) => resumedWaits.push(s); + runtime2.onStore = (s) => { offer = s; }; + + await client2.auth.loginWithDevice(); + + // The wait node was rehydrated and re-dispatched. + expect(runtime2.isRunning).toBe(true); + expect(resumedWaits).toHaveLength(1); + expect(offer).toBeUndefined(); + + // The remaining wait time still elapses → the offer surfaces on the new client. + await vi.advanceTimersByTimeAsync(MINUTE); + expect(offer).toBeDefined(); + expect(offer!.get('message', '')).toBe('Limited starter pack!'); + }); +}); diff --git a/test/e2e/remoteConfig.e2e.test.ts b/test/e2e/remoteConfig.e2e.test.ts new file mode 100644 index 0000000..e616f58 --- /dev/null +++ b/test/e2e/remoteConfig.e2e.test.ts @@ -0,0 +1,101 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { RudderClient } from '../../src/client/RudderClient.js'; +import { createFakeTokenStore } from '../helpers/FakeTokenStore.js'; +import { createFakeGateway, type FakeGateway } from '../helpers/fakeGateway.js'; +import { stubDeterministicUuid } from '../helpers/memoryPlanStore.js'; +import { plan } from '../helpers/plan.js'; +import type { RemoteConfig } from '../../src/generated/remote-config.js'; + +interface GameRemoteConfig extends Record { + max_energy: number; + drop_rate: number; + pvp_enabled: boolean; + welcome_text: string; + boss: { hp: number; name: string }; + legacy: string; + missing_declared: string; +} + +function cfg(key: string, value: string, valueType: string, active = true): RemoteConfig { + return { key, value, valueType, active }; +} + +function makeClient(): RudderClient { + return new RudderClient({ + baseUrl: 'https://api.test.rudder.build', + projectKey: 'test-project', + tokenStore: createFakeTokenStore(), + runtime: { planStateStore: null, loginEvent: null }, + }); +} + +describe('E2E: remote config', () => { + let gateway: FakeGateway; + let client: RudderClient; + + beforeEach(() => { + stubDeterministicUuid(); + gateway = createFakeGateway({ + remoteConfigs: { + max_energy: cfg('max_energy', '50', 'int'), + drop_rate: cfg('drop_rate', '0.25', 'float'), + pvp_enabled: cfg('pvp_enabled', 'true', 'bool'), + welcome_text: cfg('welcome_text', 'Hello!', 'string'), + boss: cfg('boss', '{"hp":1000,"name":"Drake"}', 'json'), + legacy: cfg('legacy', 'old', 'string', false), // inactive — must be excluded + }, + }); + gateway.install(); + client = makeClient(); + }); + + afterEach(() => vi.unstubAllGlobals()); + + it('loads configs and reads them back with type coercion', async () => { + await client.auth.loginWithDevice(); + + expect(client.remoteConfig.status).toBe('ready'); + expect(client.remoteConfig.get('max_energy', 0)).toBe(50); + expect(client.remoteConfig.get('drop_rate', 0)).toBeCloseTo(0.25); + expect(client.remoteConfig.get('pvp_enabled', false)).toBe(true); + expect(client.remoteConfig.get('welcome_text', '')).toBe('Hello!'); + expect(client.remoteConfig.get('boss', { hp: 0, name: '' })).toEqual({ + hp: 1000, + name: 'Drake', + }); + }); + + it('inactive configs are not exposed; missing keys fall back to default', async () => { + await client.auth.loginWithDevice(); + + expect(client.remoteConfig.get('legacy', 'DEFAULT')).toBe('DEFAULT'); + expect(client.remoteConfig.get('missing_declared', 'fallback')).toBe('fallback'); + }); + + it('get() before login returns the default (no throw)', () => { + expect(client.remoteConfig.status).toBe('idle'); + expect(client.remoteConfig.get('max_energy', 99)).toBe(99); + }); + + it('a remote_config_override scenario node patches the live cache', async () => { + client = new RudderClient({ + baseUrl: 'https://api.test.rudder.build', + projectKey: 'test-project', + tokenStore: createFakeTokenStore(), + runtime: { planStateStore: null }, + }); + gateway.onEvent( + 'player_login', + plan('boost') + .node('override', 'remote_config_override', { + patches: [{ path: 'drop_rate', valueType: 'float', value: '0.9' }], + }) + .build(), + ); + + await client.auth.loginWithDevice(); + + // The override is reflected immediately, without a reload. + expect(client.remoteConfig.get('drop_rate', 0)).toBeCloseTo(0.9); + }); +}); diff --git a/test/e2e/storage.e2e.test.ts b/test/e2e/storage.e2e.test.ts new file mode 100644 index 0000000..3ceb6d3 --- /dev/null +++ b/test/e2e/storage.e2e.test.ts @@ -0,0 +1,80 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { RudderClient } from '../../src/client/RudderClient.js'; +import { createFakeTokenStore } from '../helpers/FakeTokenStore.js'; +import { createFakeGateway, type FakeGateway } from '../helpers/fakeGateway.js'; + +function makeClient(): RudderClient { + return new RudderClient({ + baseUrl: 'https://api.test.rudder.build', + projectKey: 'test-project', + tokenStore: createFakeTokenStore(), + runtime: { planStateStore: null, loginEvent: null }, + }); +} + +describe('E2E: player storage', () => { + let gateway: FakeGateway; + let client: RudderClient; + + beforeEach(() => { + gateway = createFakeGateway(); + gateway.install(); + client = makeClient(); + }); + + afterEach(() => vi.unstubAllGlobals()); + + it('saves items then reads them back (round-trip through the gateway)', async () => { + await client.auth.loginWithDevice(); + + await client.storage.save([ + { id: 'save_1', type: 'progress', data: JSON.stringify({ level: 7, gold: 1200 }) }, + { id: 'settings', type: 'prefs', data: JSON.stringify({ sound: false }) }, + ]); + + const all = await client.storage.reload(); + expect(all.items).toHaveLength(2); + + const progress = all.items!.find((i) => i.id === 'save_1'); + expect(JSON.parse(progress!.data!)).toEqual({ level: 7, gold: 1200 }); + }); + + it('filters by type', async () => { + await client.auth.loginWithDevice(); + await client.storage.save([ + { id: 'a', type: 'progress', data: '{}' }, + { id: 'b', type: 'prefs', data: '{}' }, + ]); + + // v0.2 fetches the whole storage; filtering by type is client-side. + const all = await client.storage.reload(); + const prefs = (all.items ?? []).filter((i) => i.type === 'prefs'); + expect(prefs).toHaveLength(1); + expect(prefs[0].id).toBe('b'); + + // The read went out as a storage fetch with limit as a query param, not a path segment. + const getReq = gateway.recorded.findLast((r) => r.method === 'GET' && r.path === '/sdk/v1/storage'); + expect(getReq?.query.get('limit')).toBe('100'); + }); + + it('deletes a type and the items are gone', async () => { + await client.auth.loginWithDevice(); + await client.storage.save([ + { id: 'a', type: 'progress', data: '{}' }, + { id: 'b', type: 'prefs', data: '{}' }, + ]); + + await client.storage.delete('progress'); + + const remaining = await client.storage.reload(); + expect(remaining.items?.map((i) => i.id)).toEqual(['b']); + }); + + it('storage writes carry the auth token', async () => { + const token = (await client.auth.loginWithDevice()).accessToken; + await client.storage.save([{ id: 'a', type: 't', data: '{}' }]); + + const put = gateway.recorded.find((r) => r.method === 'PUT' && r.path === '/sdk/v1/storage'); + expect(put?.authToken).toBe(token); + }); +}); diff --git a/test/helpers/FakeTokenStore.ts b/test/helpers/FakeTokenStore.ts new file mode 100644 index 0000000..783780b --- /dev/null +++ b/test/helpers/FakeTokenStore.ts @@ -0,0 +1,20 @@ +import type { TokenStore } from '../../src/token/TokenStore.js'; + +/** In-memory token store for testing. */ +export function createFakeTokenStore(): TokenStore { + let access: string | null = null; + let refresh: string | null = null; + + return { + getAccessToken: () => access, + getRefreshToken: () => refresh, + saveTokens: (a, r) => { + access = a; + refresh = r; + }, + clear: () => { + access = null; + refresh = null; + }, + }; +} diff --git a/test/helpers/createClient.ts b/test/helpers/createClient.ts new file mode 100644 index 0000000..1d617b3 --- /dev/null +++ b/test/helpers/createClient.ts @@ -0,0 +1,13 @@ +import { RudderClient } from '../../src/client/RudderClient.js'; +import type { RudderClientOptions } from '../../src/client/RudderClientOptions.js'; +import { createFakeTokenStore } from './FakeTokenStore.js'; + +/** Creates a RudderClient with test-friendly defaults and a fake token store. */ +export function createTestClient(overrides?: Partial): RudderClient { + return new RudderClient({ + baseUrl: 'https://api.test.rudder.build', + projectKey: 'test-project-key', + tokenStore: createFakeTokenStore(), + ...overrides, + }); +} diff --git a/test/helpers/fakeGateway.ts b/test/helpers/fakeGateway.ts new file mode 100644 index 0000000..08fa685 --- /dev/null +++ b/test/helpers/fakeGateway.ts @@ -0,0 +1,241 @@ +import { vi } from 'vitest'; +import type { ExecutionPlan } from '../../src/generated/common.js'; +import type { RemoteConfig } from '../../src/generated/remote-config.js'; +import type { Store } from '../../src/generated/stores.js'; +import type { StorageItem } from '../../src/generated/storage.js'; +import type { + ClaimQuestResponse, + ListQuestsResponse, +} from '../../src/generated/quests.js'; + +/** + * In-process fake of the LiveOps API gateway. + * + * Stubs the global `fetch` and routes requests the same way the real gateway + * does — so tests exercise the *real* SDK transport, auth-header injection, + * scenario engine and DAG traversal, just against deterministic in-memory state + * instead of a live backend. + * + * Seed state up front (scenarios per event, remote configs, stores), then drive + * the SDK through a journey and assert on `recorded` requests / server state. + */ + +export interface RecordedRequest { + method: string; + path: string; + query: URLSearchParams; + body: unknown; + authToken: string | null; +} + +export interface FakeGatewayState { + /** Plans returned by POST /scenarios/trigger, keyed by event name. */ + scenarios: Map; + /** Plans returned by POST /scenarios/callback, keyed by `nodeId:handle`. */ + callbacks: Map; + /** HTTP status to fail a callback with, keyed by `nodeId:handle`. */ + callbackErrors: Map; + /** Optional response for POST /scenarios/counter. */ + counterPlan?: ExecutionPlan; + remoteConfigs: Record; + stores: Store[]; + quests: ListQuestsResponse; + questClaims: Map; + /** Player KV storage, keyed by item id. */ + storage: Map; +} + +export interface FakeGateway { + state: FakeGatewayState; + recorded: RecordedRequest[]; + /** Purchases received, in order. */ + purchases: Array<{ storeSlug: string; offerId: string; idempotencyKey: string; authToken: string | null }>; + /** Quest claim requests received, in order. */ + questClaims: Array<{ questId: string; authToken: string | null }>; + install(): void; + /** Convenience: register the plans returned for a trigger event. */ + onEvent(event: string, ...plans: ExecutionPlan[]): void; + /** Convenience: register the plan returned when a boundary handle calls back. */ + onCallback(nodeId: string, handle: string, plan: ExecutionPlan): void; + /** Convenience: make a boundary callback fail with an HTTP status (default 500). */ + onCallbackError(nodeId: string, handle: string, status?: number): void; +} + +function json(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, + }); +} + +const noContent = (): Response => new Response(null, { status: 204 }); + +export function createFakeGateway(seed?: Partial): FakeGateway { + const state: FakeGatewayState = { + scenarios: seed?.scenarios ?? new Map(), + callbacks: seed?.callbacks ?? new Map(), + callbackErrors: seed?.callbackErrors ?? new Map(), + counterPlan: seed?.counterPlan, + remoteConfigs: seed?.remoteConfigs ?? {}, + stores: seed?.stores ?? [], + quests: seed?.quests ?? { quests: [] }, + questClaims: seed?.questClaims ?? new Map(), + storage: seed?.storage ?? new Map(), + }; + + const recorded: RecordedRequest[] = []; + const purchases: FakeGateway['purchases'] = []; + const questClaims: FakeGateway['questClaims'] = []; + + async function handle(req: RecordedRequest): Promise { + const { method, path, query, body } = req; + + // --- Auth --- + if (method === 'POST' && path === '/sdk/v1/authorization/device') { + const b = body as { deviceId?: string }; + return json({ + accessToken: `access-${b.deviceId ?? 'dev'}`, + refreshToken: `refresh-${b.deviceId ?? 'dev'}`, + }); + } + + // --- Player --- + if (method === 'GET' && path === '/sdk/v1/player/information') { + return json({ player: null, wallets: [] }); + } + + // --- Sync (baseline revision poll after login) --- + if (method === 'GET' && path === '/sdk/v1/sync') { + return json({}); + } + + // --- Catalog --- + if (method === 'GET' && path === '/sdk/v1/catalog') { + return json({ items: [] }); + } + + // --- Scenarios --- + if (method === 'POST' && path === '/sdk/v1/scenarios/trigger') { + const event = (body as { event?: string }).event ?? ''; + return json({ plans: state.scenarios.get(event) ?? [] }); + } + if (method === 'POST' && path === '/sdk/v1/scenarios/callback') { + const b = body as { nodeId?: string; handle?: string }; + const key = `${b.nodeId}:${b.handle}`; + const errStatus = state.callbackErrors.get(key); + if (errStatus) return json({ error: 'callback failed' }, errStatus); + const plan = state.callbacks.get(key); + return json(plan ? { plan } : {}); + } + if (method === 'POST' && path === '/sdk/v1/scenarios/counter') { + return json(state.counterPlan ? { plan: state.counterPlan, completed: false } : { completed: false }); + } + + // --- Remote config --- + if (method === 'GET' && path === '/sdk/v1/remote-configs') { + return json({ configs: state.remoteConfigs }); + } + if (method === 'GET' && path.startsWith('/sdk/v1/remote-configs/')) { + const key = decodeURIComponent(path.slice('/sdk/v1/remote-configs/'.length)); + const cfg = state.remoteConfigs[key]; + return cfg ? json(cfg) : json({ error: 'not found' }, 404); + } + + // --- Stores --- + if (method === 'GET' && path === '/sdk/v1/stores') { + return json({ stores: state.stores, total: state.stores.length }); + } + const purchaseMatch = path.match(/^\/sdk\/v1\/stores\/([^/]+)\/offers\/([^/]+)\/purchase$/); + if (method === 'POST' && purchaseMatch) { + const b = body as { idempotencyKey?: string }; + purchases.push({ + storeSlug: decodeURIComponent(purchaseMatch[1]), + offerId: decodeURIComponent(purchaseMatch[2]), + idempotencyKey: b.idempotencyKey ?? '', + authToken: req.authToken, + }); + return json({ success: true, purchaseId: `purchase-${purchases.length}` }); + } + const storeMatch = path.match(/^\/sdk\/v1\/stores\/([^/]+)$/); + if (method === 'GET' && storeMatch) { + const slug = decodeURIComponent(storeMatch[1]); + const store = state.stores.find((s) => s.slug === slug); + return store ? json(store) : json({ error: 'not found' }, 404); + } + + // --- Quests --- + if (method === 'POST' && path === '/sdk/v1/quests/list') { + return json(state.quests); + } + if (method === 'POST' && path === '/sdk/v1/quests/claim') { + const b = body as { questId?: string }; + const questId = b.questId ?? ''; + questClaims.push({ questId, authToken: req.authToken }); + return json(state.questClaims.get(questId) ?? { success: false, error: 'not found' }); + } + + // --- Storage --- + if (path === '/sdk/v1/storage') { + if (method === 'GET') { + const typeFilter = query.get('types'); + const items = [...state.storage.values()].filter( + (it) => !typeFilter || it.type === typeFilter, + ); + return json({ items, nextCursor: '' }); + } + if (method === 'PUT') { + const items = (body as { items?: StorageItem[] }).items ?? []; + for (const item of items) { + if (item.id) state.storage.set(item.id, item); + } + return noContent(); + } + if (method === 'DELETE') { + const type = query.get('type'); + for (const [id, item] of state.storage) { + if (!type || item.type === type) state.storage.delete(id); + } + return noContent(); + } + } + + return json({ error: `unhandled route: ${method} ${path}` }, 500); + } + + function install(): void { + vi.stubGlobal( + 'fetch', + vi.fn(async (input: string | URL, init?: RequestInit): Promise => { + const url = new URL(typeof input === 'string' ? input : input.toString()); + const headers = new Headers(init?.headers); + const auth = headers.get('Authorization'); + const req: RecordedRequest = { + method: (init?.method ?? 'GET').toUpperCase(), + path: url.pathname, + query: url.searchParams, + body: init?.body ? JSON.parse(init.body as string) : undefined, + authToken: auth ? auth.replace(/^Bearer\s+/, '') : null, + }; + recorded.push(req); + return handle(req); + }), + ); + } + + return { + state, + recorded, + purchases, + questClaims, + install, + onEvent(event, ...plans) { + state.scenarios.set(event, plans); + }, + onCallback(nodeId, handle, plan) { + state.callbacks.set(`${nodeId}:${handle}`, plan); + }, + onCallbackError(nodeId, handle, status = 500) { + state.callbackErrors.set(`${nodeId}:${handle}`, status); + }, + }; +} diff --git a/test/helpers/memoryPlanStore.ts b/test/helpers/memoryPlanStore.ts new file mode 100644 index 0000000..78f2654 --- /dev/null +++ b/test/helpers/memoryPlanStore.ts @@ -0,0 +1,41 @@ +import type { PlanStateStore } from '../../src/scenario/engine/IndexedDbPlanStore.js'; + +/** + * In-memory PlanStateStore that survives across RudderClient instances — lets a + * test simulate a page reload: drive scenario A on one client, construct a fresh + * client sharing the same `backing`, call `scenarios.restore()`, and assert the + * run resumed mid-DAG. + */ +export function createMemoryPlanStore(backing: { value: string | null } = { value: null }): PlanStateStore { + return { + get state() { + return backing.value; + }, + set state(v: string | null) { + backing.value = v; + }, + async load() { + /* state already in `backing` */ + }, + async save(s: string | null) { + backing.value = s; + }, + }; +} + +/** Deterministic, collision-free crypto.randomUUID() stub for scenario run IDs. */ +export function stubDeterministicUuid(): void { + let n = 0; + const existing = (globalThis as { crypto?: Crypto }).crypto ?? ({} as Crypto); + Object.defineProperty(globalThis, 'crypto', { + configurable: true, + value: { + ...existing, + randomUUID: () => { + n += 1; + const hex = n.toString(16).padStart(12, '0'); + return `00000000-0000-4000-a000-${hex}` as `${string}-${string}-${string}-${string}-${string}`; + }, + }, + }); +} diff --git a/test/helpers/plan.ts b/test/helpers/plan.ts new file mode 100644 index 0000000..566051d --- /dev/null +++ b/test/helpers/plan.ts @@ -0,0 +1,72 @@ +import type { ExecutionPlan, ExecutionPlanNode, PlanEdge, BoundaryNode } from '../../src/generated/common.js'; + +/** + * Small fluent builder for ExecutionPlans, so scenario journeys read like the + * DAGs they model rather than walls of object literals. + * + * plan('offer_flow') + * .node('wait1', 'wait', { duration: 1, unit: 'minutes' }) + * .node('offer', 'store', { storeSlug: 'starter', offerId: 'pack_1' }) + * .edge('wait1', 'onComplete', 'offer') + * .build(); + * + * The first node added becomes the start node unless `.start(id)` is called. + */ +export class PlanBuilder { + private readonly nodes: ExecutionPlanNode[] = []; + private readonly edges: PlanEdge[] = []; + private readonly boundaryNodes: BoundaryNode[] = []; + private startNodeId?: string; + private runIdValue?: string; + + constructor( + private readonly scenarioId: string, + private readonly opts: { planId?: string; userId?: string } = {}, + ) {} + + /** Sets a specific run ID (default: auto-derived from scenarioId). */ + runId(id: string): this { + this.runIdValue = id; + return this; + } + + node(id: string, type: string, data?: Record): this { + this.nodes.push({ id, type, data: data as ExecutionPlanNode['data'] }); + if (!this.startNodeId) this.startNodeId = id; + return this; + } + + edge(source: string, sourceHandle: string, target: string): this { + this.edges.push({ id: `e-${source}-${sourceHandle}-${target}`, source, sourceHandle, target }); + return this; + } + + /** Registers a server-side boundary node (handle that calls back to the gateway). */ + boundary(sourceNodeId: string, sourceHandle: string, nodeId = `b-${sourceNodeId}-${sourceHandle}`): this { + this.boundaryNodes.push({ sourceNodeId, sourceHandle, nodeId }); + return this; + } + + start(id: string): this { + this.startNodeId = id; + return this; + } + + build(): ExecutionPlan { + return { + planId: this.opts.planId ?? `${this.scenarioId}-plan`, + scenarioId: this.scenarioId, + userId: this.opts.userId ?? 'player-1', + startNodeId: this.startNodeId, + runId: this.runIdValue ?? `${this.scenarioId}-run`, + nodes: this.nodes, + edges: this.edges, + boundaryNodes: this.boundaryNodes, + context: undefined, + }; + } +} + +export function plan(scenarioId: string, opts?: { planId?: string; userId?: string }): PlanBuilder { + return new PlanBuilder(scenarioId, opts); +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..eb5f9aa --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "isolatedModules": true, + "declaration": true, + "declarationDir": "./dist", + "outDir": "./dist", + "rootDir": "./src", + "lib": ["ES2022", "DOM", "DOM.Iterable"] + }, + "include": ["src"], + "exclude": ["node_modules", "dist"] +} diff --git a/tsconfig.test.json b/tsconfig.test.json new file mode 100644 index 0000000..7aa6ac9 --- /dev/null +++ b/tsconfig.test.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": true, + "rootDir": ".", + "types": ["node"], + "lib": ["ES2023", "DOM", "DOM.Iterable"] + }, + "include": ["src", "test"] +} diff --git a/tsup.config.ts b/tsup.config.ts new file mode 100644 index 0000000..ad4cc80 --- /dev/null +++ b/tsup.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from 'tsup'; + +export default defineConfig({ + entry: ['src/index.ts'], + format: ['esm', 'cjs'], + target: 'es2022', + dts: true, + clean: true, + // Code-split the lazily-imported scenario engine out of the initial chunk. + splitting: true, + treeshake: true, + platform: 'browser', + outDir: 'dist', +}); diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..ce9cacd --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + environment: 'jsdom', + globals: true, + include: ['test/**/*.test.ts'], + // The prod e2e suite has its own config (vitest.e2e-prod.config.ts) and hits a real API. + exclude: ['**/node_modules/**', '**/dist/**', 'test/e2e-prod/**'], + setupFiles: [], + }, +}); diff --git a/vitest.e2e-prod.config.ts b/vitest.e2e-prod.config.ts new file mode 100644 index 0000000..df61e8e --- /dev/null +++ b/vitest.e2e-prod.config.ts @@ -0,0 +1,17 @@ +import { defineConfig } from 'vitest/config'; + +// Production e2e: drives the real SDK against a seeded project on api.rudder.build. +// Runs in Node (real fetch, no jsdom). globalSetup seeds + tears down the project. +export default defineConfig({ + test: { + environment: 'node', + globals: true, + include: ['test/e2e-prod/**/*.e2e.test.ts'], + globalSetup: ['test/e2e-prod/_setup/globalSetup.ts'], + testTimeout: 60_000, + hookTimeout: 120_000, + retry: 1, + fileParallelism: false, + pool: 'forks', + }, +});