From 1e4e0d928d32813d240edd059f95194f3b341c72 Mon Sep 17 00:00:00 2001 From: edmand46 Date: Thu, 17 Sep 2026 15:26:42 +0300 Subject: [PATCH] feat!: @rudder/sdk 0.1.0: createClient, idempotent modules.call retries, storage save(type, data), drop resources and projectStorage --- package-lock.json | 8 +- package.json | 6 +- src/client/RudderClient.ts | 57 +++++---- src/client/RudderClientOptions.ts | 9 +- src/client/RudderError.ts | 16 +++ src/core/context.ts | 8 +- src/core/uuid.ts | 9 ++ src/device/DeviceId.ts | 7 +- src/domains/ModulesDomain.ts | 56 ++++++++- src/domains/PlayerDomain.ts | 3 +- src/domains/ProjectStorageDomain.ts | 26 ---- src/domains/ResourcesDomain.ts | 43 ------- src/domains/StorageDomain.ts | 14 +-- src/generated/api.ts | 47 ------- src/generated/common.ts | 4 +- src/generated/errors.ts | 3 +- src/generated/index.ts | 2 - src/generated/modules.ts | 7 ++ src/generated/player.ts | 1 + src/generated/project-storage.ts | 29 ----- src/generated/resources.ts | 34 ----- src/index.ts | 27 +--- src/state/SyncedState.ts | 22 ++-- src/transport/request.ts | 62 ++++++--- test/PublicApi.test.ts | 8 +- test/RudderClient.test.ts | 153 ++++++++++++++--------- test/e2e-prod/_setup/harness.ts | 4 +- test/e2e-prod/player.e2e.test.ts | 3 +- test/e2e-prod/storage.e2e.test.ts | 7 +- test/e2e/modules.e2e.test.ts | 48 +++++++ test/e2e/storage.e2e.test.ts | 54 +++----- test/fixtures/leaderboards.manifest.json | 6 + test/fixtures/rudder.modules.ts | 77 ++++++++++++ test/helpers/createClient.ts | 4 +- test/helpers/fakeGateway.ts | 77 ++---------- 35 files changed, 474 insertions(+), 467 deletions(-) create mode 100644 src/core/uuid.ts delete mode 100644 src/domains/ProjectStorageDomain.ts delete mode 100644 src/domains/ResourcesDomain.ts delete mode 100644 src/generated/project-storage.ts delete mode 100644 src/generated/resources.ts create mode 100644 test/e2e/modules.e2e.test.ts create mode 100644 test/fixtures/leaderboards.manifest.json create mode 100644 test/fixtures/rudder.modules.ts diff --git a/package-lock.json b/package-lock.json index 911eb24..64fc7d6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { - "name": "@rudder/js-sdk", - "version": "2.0.0", + "name": "@rudder/sdk", + "version": "0.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "@rudder/js-sdk", - "version": "2.0.0", + "name": "@rudder/sdk", + "version": "0.1.0", "devDependencies": { "@types/node": "^26.1.1", "jsdom": "^29.1.1", diff --git a/package.json b/package.json index 0eb3900..27a2a49 100644 --- a/package.json +++ b/package.json @@ -1,10 +1,10 @@ { - "name": "@rudder/js-sdk", - "version": "2.0.0", + "name": "@rudder/sdk", + "version": "0.1.0", "publishConfig": { "registry": "https://hub.rudder.build/api/packages/rudder/npm/" }, - "description": "LiveOps Web SDK for browser games — player authentication, catalog currencies, storage, generic resources, and module function calls", + "description": "Rudder client SDK for web games: auth, player state, catalog, storage and module calls", "type": "module", "main": "./dist/index.cjs", "module": "./dist/index.js", diff --git a/src/client/RudderClient.ts b/src/client/RudderClient.ts index a92d7a7..4408ee1 100644 --- a/src/client/RudderClient.ts +++ b/src/client/RudderClient.ts @@ -1,8 +1,8 @@ /** - * RudderClient — the main entry point for the LiveOps Web SDK. + * RudderClient — the main entry point of `@rudder/sdk`; create it with + * {@link createClient}. * - * Composes the kernel domains (auth, player, catalog, storage, modules, - * resources) and the HTTP transport every service uses to reach the API. + * Composes the kernel domains (auth, player, catalog, storage, modules) and the HTTP transport every service uses to reach the API. * * The client builds a {@link RudderContext} first and injects it into every * domain, so they depend on that narrow surface rather than on the client @@ -15,16 +15,16 @@ import type { } 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 { request, type RequestContext, type RequestOptions } from '../transport/request.js'; import { createDefaultTokenStore } from '../token/TokenStore.js'; import { AuthService } from '../auth/AuthService.js'; import { SyncEngine } from '../state/SyncEngine.js'; import { PlayerDomain } from '../domains/PlayerDomain.js'; import { CatalogDomain } from '../domains/CatalogDomain.js'; import { StorageDomain } from '../domains/StorageDomain.js'; -import { ProjectStorageDomain } from '../domains/ProjectStorageDomain.js'; import { ModulesDomain } from '../domains/ModulesDomain.js'; -import { ResourcesDomain, type ResourceHandle } from '../domains/ResourcesDomain.js'; + +export const DEFAULT_BASE_URL = 'https://api.rudder.build'; type ManagedDomain = { invalidate(): void; reset(): void }; @@ -36,9 +36,7 @@ export class RudderClient { public readonly player: PlayerDomain; public readonly catalog: CatalogDomain; public readonly storage: StorageDomain; - public readonly projectStorage: ProjectStorageDomain; public readonly modules: ModulesDomain; - private readonly resourcesDomain: ResourcesDomain; private readonly syncEngine: SyncEngine; private readonly ctx: RudderContext; @@ -46,18 +44,17 @@ export class RudderClient { 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.options = { + ...options, + baseUrl: options.baseUrl || DEFAULT_BASE_URL, + tokenStore: options.tokenStore ?? createDefaultTokenStore(), + }; this.transportContext = { baseUrl: this.options.baseUrl, @@ -73,24 +70,25 @@ export class RudderClient { this.ctx = { options: this.options, - request: (method: string, path: string, body?: unknown) => - request(method, path, body, this.transportContext), + request: ( + method: string, + path: string, + body?: unknown, + options?: RequestOptions, + ) => request(method, path, body, this.transportContext, options), }; const ctx = this.ctx; this.player = new PlayerDomain(ctx); this.catalog = new CatalogDomain(ctx); this.storage = new StorageDomain(ctx); - this.projectStorage = new ProjectStorageDomain(ctx); this.modules = new ModulesDomain(ctx); - this.resourcesDomain = new ResourcesDomain(ctx); - this.syncEngine = new SyncEngine(ctx, [ - this.player, - this.catalog, - this.storage, - this.projectStorage, - ]); + this.syncEngine = new SyncEngine( + ctx, + [this.player, this.catalog, this.storage], + this.options.syncIntervalMs, + ); this.auth = new AuthService( ctx, () => this.startRuntimeAfterLogin(), @@ -104,10 +102,6 @@ export class RudderClient { this.runtimeStarted = false; } - resources(slug: string): ResourceHandle { - return this.resourcesDomain.of(slug); - } - /** @internal */ async startRuntimeAfterLogin(): Promise { if (this.runtimeStarted) { @@ -126,7 +120,7 @@ export class RudderClient { } private get managedDomains(): ManagedDomain[] { - return [this.player, this.catalog, this.storage, this.projectStorage]; + return [this.player, this.catalog, this.storage]; } private invalidateAll(): void { @@ -137,3 +131,8 @@ export class RudderClient { for (const domain of this.managedDomains) domain.reset(); } } + +/** Creates a client. Only `projectKey` is required. */ +export function createClient(options: RudderClientOptions): RudderClient { + return new RudderClient(options); +} diff --git a/src/client/RudderClientOptions.ts b/src/client/RudderClientOptions.ts index 8ad7278..45dda67 100644 --- a/src/client/RudderClientOptions.ts +++ b/src/client/RudderClientOptions.ts @@ -1,10 +1,7 @@ import type { TokenStore } from '../token/TokenStore.js'; 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. */ + /** Project SDK key; it also selects the environment (staging or prod). */ projectKey: string; /** @@ -23,11 +20,15 @@ export interface RudderClientOptions { /** Per-request timeout in milliseconds (default: 10000). */ requestTimeoutMs?: number; + /** API base URL (default: "https://api.rudder.build"). */ + baseUrl?: string; + /** Revision sync poll interval in milliseconds (default: 30000, ±20% jitter). */ syncIntervalMs?: number; } /** Client options with all defaults resolved — what `client.options` exposes. */ export interface ResolvedRudderClientOptions extends RudderClientOptions { + baseUrl: string; tokenStore: TokenStore; } diff --git a/src/client/RudderError.ts b/src/client/RudderError.ts index bf02d1d..cb8e204 100644 --- a/src/client/RudderError.ts +++ b/src/client/RudderError.ts @@ -53,3 +53,19 @@ export class RudderAuthError extends RudderHttpError { this.name = 'RudderAuthError'; } } + +/** + * Thrown by `modules.call` when a module function fails with HTTP 422 + * `{error, code, requestId}`. `C` narrows `code` to a generated error union. + */ +export class RudderModuleError extends RudderHttpError { + declare public readonly code: C; + public readonly requestId?: string; + + constructor(statusText: string, body: string, payload: { error?: string; code: C; requestId?: string }) { + super(422, statusText, body, payload.code); + this.name = 'RudderModuleError'; + this.message = payload.error || payload.code; + this.requestId = payload.requestId; + } +} diff --git a/src/core/context.ts b/src/core/context.ts index 218f708..2846432 100644 --- a/src/core/context.ts +++ b/src/core/context.ts @@ -1,4 +1,5 @@ import type { ResolvedRudderClientOptions } from '../client/RudderClientOptions.js'; +import type { RequestOptions } from '../transport/request.js'; /** * RudderContext — the low-level surface every feature service depends on: @@ -10,5 +11,10 @@ import type { ResolvedRudderClientOptions } from '../client/RudderClientOptions. */ export interface RudderContext { readonly options: ResolvedRudderClientOptions; - request(method: string, path: string, body?: unknown): Promise; + request( + method: string, + path: string, + body?: unknown, + options?: RequestOptions, + ): Promise; } diff --git a/src/core/uuid.ts b/src/core/uuid.ts new file mode 100644 index 0000000..e78b356 --- /dev/null +++ b/src/core/uuid.ts @@ -0,0 +1,9 @@ +/** UUID v4. Falls back to getRandomValues where randomUUID is missing (non-secure contexts, old runtimes). */ +export function uuidv4(): string { + if (typeof crypto.randomUUID === 'function') return crypto.randomUUID(); + const b = crypto.getRandomValues(new Uint8Array(16)); + b[6] = (b[6] & 0x0f) | 0x40; + b[8] = (b[8] & 0x3f) | 0x80; + const h = Array.from(b, (x) => x.toString(16).padStart(2, '0')).join(''); + return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20)}`; +} diff --git a/src/device/DeviceId.ts b/src/device/DeviceId.ts index 43cdb2e..f31b7a0 100644 --- a/src/device/DeviceId.ts +++ b/src/device/DeviceId.ts @@ -1,10 +1,11 @@ /** * 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`. + * Generates a UUID v4 and stores the generated UUID in localStorage under `rudder_device_id`. */ +import { uuidv4 } from '../core/uuid.js'; + const STORAGE_KEY = 'rudder_device_id'; /** @@ -19,7 +20,7 @@ export function getOrCreateDeviceId(): string { // localStorage unavailable — generate ephemeral ID. } - const id = crypto.randomUUID(); + const id = uuidv4(); try { localStorage.setItem(STORAGE_KEY, id); } catch { diff --git a/src/domains/ModulesDomain.ts b/src/domains/ModulesDomain.ts index 2b92692..9a975da 100644 --- a/src/domains/ModulesDomain.ts +++ b/src/domains/ModulesDomain.ts @@ -1,11 +1,59 @@ import type { RudderContext } from '../core/context.js'; -import { api } from '../generated/api.js'; -import type { CallModuleFunctionClientResponse } from '../generated/modules.js'; +import { uuidv4 } from '../core/uuid.js'; +import { RudderHttpError, RudderModuleError } from '../client/RudderError.js'; +import type { + CallModuleFunctionClientResponse, + CallModuleFunctionRequest, + ModuleErrorResponse, +} from '../generated/modules.js'; +export interface ModuleCallOptions { + /** Version the caller was built against; sent as `X-Rudder-Module-Version: @`. */ + moduleVersion?: string; +} + +/** + * ModulesDomain — calls module functions. Usually reached through a client + * generated by `rudder client generate`, not directly. + */ export class ModulesDomain { constructor(private readonly ctx: RudderContext) {} - call(module: string, fn: string, args?: unknown): Promise { - return api.callModuleFunction(this.ctx, { module, fn }, { args }); + /** + * Calls `module.fn` and returns its `result`. Each call gets one + * Idempotency-Key reused across retries (network errors, 5xx, 409 `conflict`). + * Rejects with {@link RudderModuleError} on 422. + */ + async call( + module: string, + fn: string, + args?: unknown, + options?: ModuleCallOptions, + ): Promise { + const headers: Record = { 'Idempotency-Key': uuidv4() }; + if (options?.moduleVersion) { + headers['X-Rudder-Module-Version'] = `${module}@${options.moduleVersion}`; + } + const path = `/sdk/v1/modules/${encodeURIComponent(module)}/${encodeURIComponent(fn)}`; + try { + const response = await this.ctx.request( + 'POST', + path, + { args: args ?? {} } satisfies CallModuleFunctionRequest, + { headers, retry: true }, + ); + return response?.result as T; + } catch (err) { + if (err instanceof RudderHttpError && err.status === 422 && err.code) { + let payload: Partial = {}; + try { + payload = JSON.parse(err.body); + } catch { + // err.code came from this body, so it is JSON; keep the fallback anyway. + } + throw new RudderModuleError(err.statusText, err.body, { ...payload, code: err.code }); + } + throw err; + } } } diff --git a/src/domains/PlayerDomain.ts b/src/domains/PlayerDomain.ts index b23a607..d78d77e 100644 --- a/src/domains/PlayerDomain.ts +++ b/src/domains/PlayerDomain.ts @@ -4,7 +4,8 @@ import { api } from '../generated/api.js'; import type { PlayerProfile } from '../generated/player.js'; /** - * PlayerDomain — the observable player profile (identity + wallets). + * PlayerDomain — the observable player profile (player, identities, + * wallets, counters). * Synced under revision key `profile`. */ export class PlayerDomain extends SyncedState { diff --git a/src/domains/ProjectStorageDomain.ts b/src/domains/ProjectStorageDomain.ts deleted file mode 100644 index 903c038..0000000 --- a/src/domains/ProjectStorageDomain.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { SyncedState } from '../state/SyncedState.js'; -import type { RudderContext } from '../core/context.js'; -import { api } from '../generated/api.js'; -import type { - GetProjectStorageResponse, - ProjectStorageUpdateItem, -} from '../generated/project-storage.js'; - -/** - * ProjectStorageDomain — project-wide key/value storage as an observable - * entity, plus its mutations. Synced under revision key `projectStorage`; - * mutations invalidate it so subscribers observe fresh data. - */ -export class ProjectStorageDomain extends SyncedState { - readonly syncKey = 'projectStorage'; - - constructor(private readonly ctx: RudderContext) { - super(() => api.getProjectStorage(ctx, { limit: 100 })); - } - - /** Saves project storage items, then invalidates. */ - async save(items: ProjectStorageUpdateItem[]): Promise { - await api.updateProjectStorage(this.ctx, { items }); - this.invalidate(); - } -} diff --git a/src/domains/ResourcesDomain.ts b/src/domains/ResourcesDomain.ts deleted file mode 100644 index 03270e9..0000000 --- a/src/domains/ResourcesDomain.ts +++ /dev/null @@ -1,43 +0,0 @@ -import type { RudderContext } from '../core/context.js'; -import { api } from '../generated/api.js'; -import type { - CreateResourceRowRequest, - ListResourceRowsResponse, - ResourceRow, - UpdateResourceRowRequest, -} from '../generated/resources.js'; - -export class ResourceHandle { - constructor( - private readonly ctx: RudderContext, - private readonly slug: string, - ) {} - - list(): Promise { - return api.listResourceRows(this.ctx, { slug: this.slug }); - } - - get(id: string): Promise { - return api.getResourceRow(this.ctx, { slug: this.slug, id }); - } - - create(body: CreateResourceRowRequest): Promise { - return api.createResourceRow(this.ctx, { slug: this.slug }, body); - } - - update(id: string, body: UpdateResourceRowRequest): Promise { - return api.updateResourceRow(this.ctx, { slug: this.slug, id }, body); - } - - delete(id: string): Promise { - return api.deleteResourceRow(this.ctx, { slug: this.slug, id }); - } -} - -export class ResourcesDomain { - constructor(private readonly ctx: RudderContext) {} - - of(slug: string): ResourceHandle { - return new ResourceHandle(this.ctx, slug); - } -} diff --git a/src/domains/StorageDomain.ts b/src/domains/StorageDomain.ts index 3dc9202..14a5903 100644 --- a/src/domains/StorageDomain.ts +++ b/src/domains/StorageDomain.ts @@ -1,11 +1,11 @@ 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'; +import type { GetStorageResponse } 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 + * StorageDomain — player storage (one item per type) 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 { @@ -15,13 +15,13 @@ export class StorageDomain extends SyncedState { super(() => api.getStorage(ctx, { limit: 100 })); } - /** Saves player storage items, then invalidates. */ - async save(items: StorageItem[]): Promise { - await api.updateStorage(this.ctx, { items }); + /** Upserts the item of `type` with raw string `data`, then invalidates. */ + async save(type: string, data: string): Promise { + await api.updateStorage(this.ctx, { items: [{ type, data }] }); this.invalidate(); } - /** Deletes player storage items of the given type, then invalidates. */ + /** Deletes the player storage item of `type`, then invalidates. */ async delete(type: string): Promise { await api.deleteStorage(this.ctx, { type }); this.invalidate(); diff --git a/src/generated/api.ts b/src/generated/api.ts index f8254d6..277df93 100644 --- a/src/generated/api.ts +++ b/src/generated/api.ts @@ -4,8 +4,6 @@ import type { LinkViaAppleRequest, LinkViaCustomRequest, LinkViaGoogleRequest, L import type { ListCatalogCurrenciesResponse } from './catalog.js'; import type { CallModuleFunctionClientResponse, CallModuleFunctionRequest } from './modules.js'; import type { PlayerProfile } from './player.js'; -import type { GetProjectStorageResponse, UpdateProjectStorageRequest } from './project-storage.js'; -import type { CreateResourceRowRequest, ListResourceRowsResponse, ResourceRow, UpdateResourceRowRequest } from './resources.js'; import type { GetStorageResponse, UpdateStorageRequest } from './storage.js'; /** The minimal transport contract the generated API functions call through. */ @@ -38,19 +36,6 @@ export const api = { ): Promise => t.request('POST', `/sdk/v1/modules/${enc(path.module)}/${enc(path.fn)}`, body), - createResourceRow: ( - t: Transport, - path: { slug: string }, - body: CreateResourceRowRequest, - ): Promise => - t.request('POST', `/sdk/v1/resources/${enc(path.slug)}`, body), - - deleteResourceRow: ( - t: Transport, - path: { slug: string; id: string }, - ): Promise => - t.request('DELETE', `/sdk/v1/resources/${enc(path.slug)}/${enc(path.id)}`), - deleteStorage: ( t: Transport, query?: { type?: string }, @@ -62,18 +47,6 @@ export const api = { ): Promise => t.request('GET', '/sdk/v1/player/information'), - getProjectStorage: ( - t: Transport, - query?: { types?: string; limit?: number; cursor?: string }, - ): Promise => - t.request('GET', `/sdk/v1/project-storage${qs([['types', query?.types], ['limit', query?.limit], ['cursor', query?.cursor]])}`), - - getResourceRow: ( - t: Transport, - path: { slug: string; id: string }, - ): Promise => - t.request('GET', `/sdk/v1/resources/${enc(path.slug)}/${enc(path.id)}`), - getRevisions: ( t: Transport, ): Promise<{ [key: string]: number }> => @@ -108,13 +81,6 @@ export const api = { ): Promise => t.request('GET', '/sdk/v1/catalog'), - listResourceRows: ( - t: Transport, - path: { slug: string }, - query?: { _q?: string; _sort?: string; _order?: string; _start?: number; _end?: number }, - ): Promise => - t.request('GET', `/sdk/v1/resources/${enc(path.slug)}${qs([['_q', query?._q], ['_sort', query?._sort], ['_order', query?._order], ['_start', query?._start], ['_end', query?._end]])}`), - loginViaApple: ( t: Transport, body: LoginViaAppleRequest, @@ -151,19 +117,6 @@ export const api = { ): Promise => t.request('POST', '/sdk/v1/authorization/unlink', body), - updateProjectStorage: ( - t: Transport, - body: UpdateProjectStorageRequest, - ): Promise => - t.request('PUT', '/sdk/v1/project-storage', body), - - updateResourceRow: ( - t: Transport, - path: { slug: string; id: string }, - body: UpdateResourceRowRequest, - ): Promise => - t.request('PATCH', `/sdk/v1/resources/${enc(path.slug)}/${enc(path.id)}`, body), - updateStorage: ( t: Transport, body: UpdateStorageRequest, diff --git a/src/generated/common.ts b/src/generated/common.ts index f856633..1f2583a 100644 --- a/src/generated/common.ts +++ b/src/generated/common.ts @@ -1,9 +1,9 @@ // Code generated by apigen. DO NOT EDIT. -import type { ResourceRowUsage } from './resources.js'; +import type { ResourceRowUsage } from './modules.js'; export interface ErrorResponse { - "code"?: "forbidden" | "row_referenced"; + "code"?: "forbidden" | "idempotency_key_mismatch" | "row_referenced"; "error"?: string; "index"?: number; "path"?: string; diff --git a/src/generated/errors.ts b/src/generated/errors.ts index 7496507..441ce9f 100644 --- a/src/generated/errors.ts +++ b/src/generated/errors.ts @@ -1,8 +1,9 @@ // Code generated by apigen. DO NOT EDIT. -export type RudderErrorCode = "forbidden" | "row_referenced"; +export type RudderErrorCode = "forbidden" | "idempotency_key_mismatch" | "row_referenced"; export const RudderErrorCodes = { forbidden: "forbidden", + idempotencyKeyMismatch: "idempotency_key_mismatch", rowReferenced: "row_referenced", } as const; diff --git a/src/generated/index.ts b/src/generated/index.ts index ba203cc..d0e83f7 100644 --- a/src/generated/index.ts +++ b/src/generated/index.ts @@ -5,8 +5,6 @@ export * from './catalog.js'; export * from './common.js'; export * from './modules.js'; export * from './player.js'; -export * from './project-storage.js'; -export * from './resources.js'; export * from './storage.js'; export * from './api.js'; export * from './errors.js'; diff --git a/src/generated/modules.ts b/src/generated/modules.ts index b5caad6..917218d 100644 --- a/src/generated/modules.ts +++ b/src/generated/modules.ts @@ -15,3 +15,10 @@ export interface ModuleErrorResponse { "requestId"?: string; } +export interface ResourceRowUsage { + "definitionSlug"?: string; + "field"?: string; + "moduleSlug"?: string; + "rowId"?: string; +} + diff --git a/src/generated/player.ts b/src/generated/player.ts index fc2bbe1..22fcab3 100644 --- a/src/generated/player.ts +++ b/src/generated/player.ts @@ -11,6 +11,7 @@ export interface Player { } export interface PlayerProfile { + "counters"?: { [key: string]: number }; "identities"?: PlayerProfileIdentity[]; "player"?: Player; "wallets"?: Wallet[]; diff --git a/src/generated/project-storage.ts b/src/generated/project-storage.ts deleted file mode 100644 index c9a98d8..0000000 --- a/src/generated/project-storage.ts +++ /dev/null @@ -1,29 +0,0 @@ -// Code generated by apigen. DO NOT EDIT. - -export interface GetProjectStorageResponse { - "items"?: ProjectStorageItem[]; - "nextCursor"?: string; -} - -export interface ProjectStorageItem { - "data"?: string; - "expiresAt"?: string; - "id"?: string; - "readPermission"?: "public" | "serverOnly"; - "size"?: number; - "type"?: string; - "updatedAt"?: string; - "version"?: number; - "writePermission"?: "public" | "serverOnly"; -} - -export interface ProjectStorageUpdateItem { - "data"?: string; - "type"?: string; -} - -export interface UpdateProjectStorageRequest { - "idempotencyKey"?: string; - "items"?: ProjectStorageUpdateItem[]; -} - diff --git a/src/generated/resources.ts b/src/generated/resources.ts deleted file mode 100644 index 6d25887..0000000 --- a/src/generated/resources.ts +++ /dev/null @@ -1,34 +0,0 @@ -// Code generated by apigen. DO NOT EDIT. - -export interface CreateResourceRowRequest { - "data"?: { [key: string]: unknown }; - "id"?: string; -} - -export interface ListResourceRowsResponse { - "items"?: ResourceRow[]; - "total"?: number; -} - -export interface ResourceRow { - "createdAt"?: string; - "data"?: { [key: string]: unknown }; - "id"?: string; - "playerId"?: string; - "rank"?: number; - "updatedAt"?: string; - "version"?: number; -} - -export interface ResourceRowUsage { - "definitionSlug"?: string; - "field"?: string; - "moduleSlug"?: string; - "rowId"?: string; -} - -export interface UpdateResourceRowRequest { - "data"?: { [key: string]: unknown }; - "version"?: number; -} - diff --git a/src/index.ts b/src/index.ts index e546fd5..4937995 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,13 +1,14 @@ -// @rudder/web-sdk — LiveOps Web SDK for browser games +// @rudder/sdk — Rudder client SDK for web games // Client -export { RudderClient } from './client/RudderClient.js'; +export { RudderClient, createClient } from './client/RudderClient.js'; export type { RudderClientOptions } from './client/RudderClientOptions.js'; export { RudderError, RudderNetworkError, RudderHttpError, RudderAuthError, + RudderModuleError, SDK_ERROR_INVALID_OPTIONS, } from './client/RudderError.js'; export type { RudderErrorCodeLike } from './client/RudderError.js'; @@ -37,31 +38,15 @@ export type { } from './state/SyncedState.js'; // Domains — types only; instances live on the client -// (client.player, client.modules, client.resources(slug), …). +// (client.player, client.catalog, client.storage, client.modules). export type { PlayerDomain } from './domains/PlayerDomain.js'; export type { CatalogDomain } from './domains/CatalogDomain.js'; -export type { ModulesDomain } from './domains/ModulesDomain.js'; -export type { ResourcesDomain, ResourceHandle } from './domains/ResourcesDomain.js'; +export type { ModulesDomain, ModuleCallOptions } from './domains/ModulesDomain.js'; export type { StorageDomain } from './domains/StorageDomain.js'; -export type { ProjectStorageDomain } from './domains/ProjectStorageDomain.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, PlayerProfileIdentity, Wallet } from './generated/player.js'; export type { CatalogCurrency } from './generated/catalog.js'; export type { StorageItem, GetStorageResponse } from './generated/storage.js'; -export type { - ProjectStorageItem, - GetProjectStorageResponse, - ProjectStorageUpdateItem, -} from './generated/project-storage.js'; -export type { - CallModuleFunctionRequest, - CallModuleFunctionClientResponse, -} from './generated/modules.js'; -export type { - ResourceRow, - ListResourceRowsResponse, - CreateResourceRowRequest, - UpdateResourceRowRequest, -} from './generated/resources.js'; +export type { ModuleErrorResponse } from './generated/modules.js'; diff --git a/src/state/SyncedState.ts b/src/state/SyncedState.ts index dabadac..46ef703 100644 --- a/src/state/SyncedState.ts +++ b/src/state/SyncedState.ts @@ -1,7 +1,7 @@ /** * SyncedState — an observable cell for a single server-synced entity. * - * Tracks `{ status, data, error }`, deduplicates parallel loads, and notifies + * Tracks `{ status, value, 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. @@ -11,7 +11,7 @@ export type SyncedStatus = 'idle' | 'loading' | 'ready' | 'error'; export interface SyncedSnapshot { status: SyncedStatus; - data?: T; + value?: T; error?: Error; } @@ -27,8 +27,8 @@ export class SyncedState { constructor(private readonly loader: () => Promise) {} /** Current data, or undefined when not loaded yet. */ - get data(): T | undefined { - return this.snapshot.data; + get value(): T | undefined { + return this.snapshot.value; } get status(): SyncedStatus { @@ -54,7 +54,7 @@ export class SyncedState { /** Loads the entity if needed; deduplicates parallel loads. */ load(): Promise { if (this.snapshot.status === 'ready') { - return Promise.resolve(this.snapshot.data as T); + return Promise.resolve(this.snapshot.value as T); } return this.startLoad(false); } @@ -100,18 +100,18 @@ export class SyncedState { 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.setSnapshot({ status: 'loading', value: this.snapshot.value }); this.pending = this.loader() - .then((data) => { + .then((value) => { if (generation === this.generation) { - this.setSnapshot({ status: 'ready', data }); + this.setSnapshot({ status: 'ready', value }); } - return data; + return value; }) .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 }); + this.setSnapshot({ status: 'error', error, value: this.snapshot.value }); } throw error; }) @@ -127,7 +127,7 @@ export class SyncedState { const previous = this.snapshot; if ( previous.status === next.status && - previous.data === next.data && + previous.value === next.value && previous.error === next.error ) { return; diff --git a/src/transport/request.ts b/src/transport/request.ts index 7e8048f..c0b5da0 100644 --- a/src/transport/request.ts +++ b/src/transport/request.ts @@ -1,5 +1,5 @@ /** - * Core HTTP transport — fetch-based with auth injection, timeouts, GET retries, + * Core HTTP transport — fetch-based with auth injection, timeouts, retries, * single-flight token refresh, and typed error mapping. */ @@ -12,9 +12,20 @@ import type { export const DEFAULT_REQUEST_TIMEOUT_MS = 10_000; -const MAX_GET_RETRIES = 2; +const MAX_RETRIES = 2; const RETRY_BASE_DELAY_MS = 300; +/** Per-call transport options. */ +export interface RequestOptions { + /** Extra headers, sent unchanged on every attempt (retries and the post-refresh retry). */ + headers?: Record; + /** + * Retry non-GET requests like GETs (network errors, 5xx) and also on 409 `conflict`. + * Only for calls made safe by an idempotency key. GETs always retry. + */ + retry?: boolean; +} + export interface RequestContext { baseUrl: string; tokenStore: TokenStore; @@ -34,7 +45,8 @@ export interface RequestContext { * - 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 + * - Retries up to 2 times (jittered 300ms × 2^n backoff): GETs on network errors and + * 5xx; `options.retry` requests also on 409 with code `conflict` * - 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) @@ -45,13 +57,14 @@ export async function request( path: string, body: unknown | undefined, ctx: RequestContext, + options: RequestOptions = {}, ): Promise { - let response = await requestWithRetry(method, path, body, ctx); + let response = await requestWithRetry(method, path, body, ctx, options); if (response.status === 401) { const refreshed = await refreshTokens(ctx); if (refreshed) { - response = await requestWithRetry(method, path, body, ctx); + response = await requestWithRetry(method, path, body, ctx, options); } else { ctx.tokenStore.clear(); ctx.onAuthFailure?.(); @@ -88,42 +101,51 @@ export async function request( return response.json() as Promise; } -/** Fetches with per-attempt timeout; retries GET on network errors and 5xx. */ +/** Fetches with per-attempt timeout; retries per {@link RequestOptions.retry}. */ async function requestWithRetry( method: string, path: string, body: unknown | undefined, ctx: RequestContext, + options: RequestOptions, ): Promise { - const retryable = method === 'GET'; + const retryable = method === 'GET' || options.retry === true; for (let attempt = 0; ; attempt++) { + const canRetry = retryable && attempt < MAX_RETRIES; + let response: Response; 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; + response = await fetchOnce(method, path, body, ctx, options.headers); } catch (err) { - if (retryable && attempt < MAX_GET_RETRIES) { - await sleep(RETRY_BASE_DELAY_MS * 2 ** attempt); - continue; - } - throw new RudderNetworkError(err); + if (!canRetry) throw new RudderNetworkError(err); + await backoff(attempt); + continue; } + if (canRetry && (response.status >= 500 || (await isConflict(response)))) { + await backoff(attempt); + continue; + } + return response; } } +async function isConflict(response: Response): Promise { + if (response.status !== 409) return false; + const parsed = await response.clone().json().catch(() => undefined); + return parsed?.code === 'conflict'; +} + async function fetchOnce( method: string, path: string, body: unknown | undefined, ctx: RequestContext, + extraHeaders?: Record, ): Promise { const url = `${ctx.baseUrl.replace(/\/+$/, '')}${path}`; const headers: Record = { 'Content-Type': 'application/json', + ...extraHeaders, }; const token = ctx.tokenStore.getAccessToken(); @@ -189,6 +211,8 @@ async function doRefreshTokens(ctx: RequestContext): Promise { } } -function sleep(ms: number): Promise { +/** 300ms × 2^attempt, scaled by a random factor in [0.5, 1.5). */ +function backoff(attempt: number): Promise { + const ms = RETRY_BASE_DELAY_MS * 2 ** attempt * (0.5 + Math.random()); return new Promise((resolve) => setTimeout(resolve, ms)); } diff --git a/test/PublicApi.test.ts b/test/PublicApi.test.ts index 05617e7..5957915 100644 --- a/test/PublicApi.test.ts +++ b/test/PublicApi.test.ts @@ -19,9 +19,7 @@ describe('public API surface', () => { 'PlayerDomain', 'CatalogDomain', 'StorageDomain', - 'ProjectStorageDomain', 'ModulesDomain', - 'ResourcesDomain', ]) { expect(name in sdk).toBe(false); } @@ -40,6 +38,8 @@ describe('public API surface', () => { it('exposes the expected value exports', () => { for (const name of [ 'RudderClient', + 'createClient', + 'RudderModuleError', 'RudderError', 'RudderNetworkError', 'RudderHttpError', @@ -67,10 +67,10 @@ describe('public API surface', () => { expect(client.player).toBeDefined(); expect(client.catalog).toBeDefined(); expect(client.storage).toBeDefined(); - expect(client.projectStorage).toBeDefined(); expect(client.modules).toBeDefined(); expect(typeof client.modules.call).toBe('function'); - expect(typeof client.resources).toBe('function'); + expect('resources' in client).toBe(false); + expect('projectStorage' in client).toBe(false); expect('battlePass' in client).toBe(false); expect('quests' in client).toBe(false); diff --git a/test/RudderClient.test.ts b/test/RudderClient.test.ts index d75b347..3f68fe3 100644 --- a/test/RudderClient.test.ts +++ b/test/RudderClient.test.ts @@ -1,10 +1,11 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { RudderClient } from '../src/client/RudderClient.js'; +import { RudderClient, createClient } from '../src/client/RudderClient.js'; import { RudderError, RudderNetworkError, RudderHttpError, RudderAuthError, + RudderModuleError, SDK_ERROR_INVALID_OPTIONS, } from '../src/client/RudderError.js'; import { createFakeTokenStore } from './helpers/FakeTokenStore.js'; @@ -23,22 +24,16 @@ describe('RudderClient', () => { 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('defaults baseUrl and exposes createClient', () => { + const client = createClient({ projectKey: 'proj_123', tokenStore: createFakeTokenStore() }); + expect(client).toBeInstanceOf(RudderClient); + expect(client.options.baseUrl).toBe('https://api.rudder.build'); }); it('throws RudderError with sdk/invalid-options if projectKey is missing', () => { let caught: unknown; try { - new RudderClient({ baseUrl: 'https://api.example.com', projectKey: '' }); + new RudderClient({ projectKey: '' }); } catch (error) { caught = error; } @@ -122,7 +117,7 @@ describe('transport error mapping', () => { vi.stubGlobal('fetch', vi.fn().mockResolvedValue( new Response(null, { status: 204, statusText: 'No Content' }), )); - const result = await client.storage.save([]); + const result = await client.storage.save('t', '{}'); expect(result).toBeUndefined(); }); @@ -135,67 +130,105 @@ describe('transport error mapping', () => { }); }); -describe('modules and resources', () => { +describe('modules.call', () => { + let client: RudderClient; + + beforeEach(() => { + vi.useFakeTimers(); + client = createClient({ + baseUrl: 'https://api.example.com', + projectKey: 'proj_123', + tokenStore: createFakeTokenStore(), + }); + }); + afterEach(() => { + vi.useRealTimers(); vi.unstubAllGlobals(); }); - it('modules.call posts to /sdk/v1/modules/:module/:fn', async () => { - const client = new RudderClient({ - baseUrl: 'https://api.example.com', - projectKey: 'proj_123', - tokenStore: createFakeTokenStore(), - }); - const fetchMock = vi.fn().mockResolvedValue( - new Response(JSON.stringify({ result: { ok: true }, log: [] }), { status: 200 }), - ); + const respond = (status: number, body: unknown) => + new Response(JSON.stringify(body), { status }); + + function headersOf(fetchMock: ReturnType, n: number): Headers { + return new Headers(fetchMock.mock.calls[n][1].headers); + } + + it('posts args, sends the version header, and returns result', async () => { + const fetchMock = vi.fn().mockResolvedValue(respond(200, { result: { ok: true } })); vi.stubGlobal('fetch', fetchMock); - const result = await client.modules.call('quests', 'list', { limit: 10 }); + const result = await client.modules.call<{ ok: boolean }>('quests', 'list', { limit: 10 }, { + moduleVersion: '5.0.0', + }); - expect(result).toEqual({ result: { ok: true }, log: [] }); + expect(result).toEqual({ ok: true }); const [url, init] = fetchMock.mock.calls[0]; expect(new URL(String(url)).pathname).toBe('/sdk/v1/modules/quests/list'); expect(JSON.parse(String(init.body))).toEqual({ args: { limit: 10 } }); + expect(headersOf(fetchMock, 0).get('X-Rudder-Module-Version')).toBe('quests@5.0.0'); + expect(headersOf(fetchMock, 0).get('Idempotency-Key')).toMatch(/^[0-9a-f-]{36}$/); }); - it('resources(slug) lists and mutates rows', async () => { - const client = new RudderClient({ - baseUrl: 'https://api.example.com', - projectKey: 'proj_123', - tokenStore: createFakeTokenStore(), - }); - const row = { id: 'row-1', data: { name: 'Sword' }, version: 1 }; - const fetchMock = vi.fn().mockImplementation((url: string, init?: RequestInit) => { - const path = new URL(url).pathname; - const method = (init?.method ?? 'GET').toUpperCase(); - if (method === 'GET' && path === '/sdk/v1/resources/items') { - return Promise.resolve(new Response(JSON.stringify({ items: [row], total: 1 }), { status: 200 })); - } - if (method === 'GET' && path === '/sdk/v1/resources/items/row-1') { - return Promise.resolve(new Response(JSON.stringify(row), { status: 200 })); - } - if (method === 'POST' && path === '/sdk/v1/resources/items') { - return Promise.resolve(new Response(JSON.stringify(row), { status: 200 })); - } - if (method === 'PATCH' && path === '/sdk/v1/resources/items/row-1') { - return Promise.resolve(new Response(JSON.stringify({ ...row, version: 2 }), { status: 200 })); - } - if (method === 'DELETE' && path === '/sdk/v1/resources/items/row-1') { - return Promise.resolve(new Response(null, { status: 204 })); - } - return Promise.resolve(new Response('unhandled', { status: 500 })); - }); + it('sends {} when args are omitted and no version header without moduleVersion', async () => { + const fetchMock = vi.fn().mockResolvedValue(respond(200, { result: 1 })); + vi.stubGlobal('fetch', fetchMock); + await client.modules.call('m', 'f'); + expect(JSON.parse(String(fetchMock.mock.calls[0][1].body))).toEqual({ args: {} }); + expect(headersOf(fetchMock, 0).has('X-Rudder-Module-Version')).toBe(false); + }); + + it('retries network errors, 5xx and 409 conflict with the same Idempotency-Key', async () => { + const fetchMock = vi + .fn() + .mockRejectedValueOnce(new Error('offline')) + .mockResolvedValueOnce(respond(409, { code: 'conflict', error: 'busy' })) + .mockResolvedValueOnce(respond(200, { result: 'done' })); vi.stubGlobal('fetch', fetchMock); - const handle = client.resources('items'); - expect(await handle.list()).toEqual({ items: [row], total: 1 }); - expect(await handle.get('row-1')).toEqual(row); - expect(await handle.create({ data: { name: 'Sword' } })).toEqual(row); - expect(await handle.update('row-1', { data: { name: 'Sword' }, version: 1 })).toEqual({ - ...row, - version: 2, + const pending = client.modules.call('m', 'f', {}); + await vi.runAllTimersAsync(); + expect(await pending).toBe('done'); + + expect(fetchMock).toHaveBeenCalledTimes(3); + const key = headersOf(fetchMock, 0).get('Idempotency-Key'); + expect(headersOf(fetchMock, 1).get('Idempotency-Key')).toBe(key); + expect(headersOf(fetchMock, 2).get('Idempotency-Key')).toBe(key); + + fetchMock.mockResolvedValueOnce(respond(200, { result: 'again' })); + await client.modules.call('m', 'f', {}); + expect(headersOf(fetchMock, 3).get('Idempotency-Key')).not.toBe(key); + }); + + it('gives up after 2 retries on 5xx', async () => { + const fetchMock = vi.fn().mockImplementation(async () => respond(503, { error: 'down' })); + vi.stubGlobal('fetch', fetchMock); + const pending = client.modules.call('m', 'f').catch((e) => e); + await vi.runAllTimersAsync(); + const error = await pending; + expect(error).toBeInstanceOf(RudderHttpError); + expect((error as RudderHttpError).status).toBe(503); + expect(fetchMock).toHaveBeenCalledTimes(3); + }); + + it('does not retry other 4xx, including 409 with another code', async () => { + const fetchMock = vi.fn().mockResolvedValue(respond(409, { code: 'version_conflict' })); + vi.stubGlobal('fetch', fetchMock); + await expect(client.modules.call('m', 'f')).rejects.toBeInstanceOf(RudderHttpError); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('maps 422 to RudderModuleError', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue( + respond(422, { error: 'not enough gold', code: 'insufficient_funds', requestId: 'req-1' }), + )); + const error = await client.modules.call('store', 'buy').catch((e) => e); + expect(error).toBeInstanceOf(RudderModuleError); + expect(error).toMatchObject({ + status: 422, + code: 'insufficient_funds', + message: 'not enough gold', + requestId: 'req-1', }); - expect(await handle.delete('row-1')).toBeUndefined(); }); }); diff --git a/test/e2e-prod/_setup/harness.ts b/test/e2e-prod/_setup/harness.ts index 1e3ba4a..1e330ad 100644 --- a/test/e2e-prod/_setup/harness.ts +++ b/test/e2e-prod/_setup/harness.ts @@ -1,6 +1,6 @@ // Test-side helpers: load the seed artifact and build SDK clients against prod. -import { RudderClient } from '../../../src/client/RudderClient.js'; +import { RudderClient, createClient } from '../../../src/client/RudderClient.js'; import type { TokenStore } from '../../../src/token/TokenStore.js'; import { readArtifact } from './artifact.js'; import type { SeedArtifact } from './types.js'; @@ -41,7 +41,7 @@ export function createInMemoryTokenStore(): TokenStore { export function makeProdClient( artifact: SeedArtifact = loadArtifact(), ): RudderClient { - return new RudderClient({ + return createClient({ baseUrl: artifact.baseUrl, projectKey: artifact.projectKey, tokenStore: createInMemoryTokenStore(), diff --git a/test/e2e-prod/player.e2e.test.ts b/test/e2e-prod/player.e2e.test.ts index 8a80a1a..7538a75 100644 --- a/test/e2e-prod/player.e2e.test.ts +++ b/test/e2e-prod/player.e2e.test.ts @@ -2,10 +2,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 () => { + it('device login yields a profile with a player id wallets array and counters', async () => { const client = await freshPlayer(); const profile = await client.player.reload(); expect(profile.player?.id).toBeTruthy(); expect(Array.isArray(profile.wallets)).toBe(true); + expect(profile).toHaveProperty('counters'); }); }); diff --git a/test/e2e-prod/storage.e2e.test.ts b/test/e2e-prod/storage.e2e.test.ts index 48cacd2..8a20b5c 100644 --- a/test/e2e-prod/storage.e2e.test.ts +++ b/test/e2e-prod/storage.e2e.test.ts @@ -5,18 +5,15 @@ 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]); + await client.storage.save('savegame', payload); - // 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(); + expect(after.items?.some((i) => i.type === 'savegame')).toBeFalsy(); }); }); diff --git a/test/e2e/modules.e2e.test.ts b/test/e2e/modules.e2e.test.ts new file mode 100644 index 0000000..f8918a7 --- /dev/null +++ b/test/e2e/modules.e2e.test.ts @@ -0,0 +1,48 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { createTestClient } from '../helpers/createClient.js'; +import { createFakeGateway, type FakeGateway } from '../helpers/fakeGateway.js'; +// Checked-in output of `rudder client generate --lang ts` for +// test/fixtures/leaderboards.manifest.json; `npm run typecheck` proves the +// generated factory accepts RudderClient. +import { modules, type LeaderboardsSubmitError } from '../fixtures/rudder.modules.js'; +import { RudderModuleError } from '../../src/client/RudderError.js'; + +describe('E2E: generated module client', () => { + let gateway: FakeGateway; + + beforeEach(() => { + gateway = createFakeGateway({ + moduleCalls: new Map([ + ['leaderboards/top', { result: { entries: [{ playerId: 'p1', score: 120 }] } }], + ]), + }); + gateway.install(); + }); + + afterEach(() => vi.unstubAllGlobals()); + + it('calls through the generated factory with typed results', async () => { + const client = createTestClient(); + await client.auth.loginWithDevice(); + const m = modules(client); + + const top = await m.leaderboards.top({ slug: 'weekly', limit: 10 }); + const score: number = top.entries[0].score; + expect(score).toBe(120); + + // @ts-expect-error score is required by the generated args type + await m.leaderboards.submit({ slug: 'weekly' }); + + const call = gateway.recorded.find((r) => r.path === '/sdk/v1/modules/leaderboards/top'); + expect(call?.body).toEqual({ args: { slug: 'weekly', limit: 10 } }); + expect(call?.headers.get('X-Rudder-Module-Version')).toBe('leaderboards@5.0.0'); + }); + + it('narrows module errors to the generated code union', () => { + const error = new RudderModuleError('', '', { + code: 'leaderboard_not_found', + }); + const code: LeaderboardsSubmitError = error.code; + expect(code).toBe('leaderboard_not_found'); + }); +}); diff --git a/test/e2e/storage.e2e.test.ts b/test/e2e/storage.e2e.test.ts index 14cd830..2594b66 100644 --- a/test/e2e/storage.e2e.test.ts +++ b/test/e2e/storage.e2e.test.ts @@ -1,16 +1,8 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { RudderClient } from '../../src/client/RudderClient.js'; -import { createFakeTokenStore } from '../helpers/FakeTokenStore.js'; +import { createTestClient } from '../helpers/createClient.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(), - }); -} - describe('E2E: player storage', () => { let gateway: FakeGateway; let client: RudderClient; @@ -18,7 +10,7 @@ describe('E2E: player storage', () => { beforeEach(() => { gateway = createFakeGateway(); gateway.install(); - client = makeClient(); + client = createTestClient(); }); afterEach(() => vi.unstubAllGlobals()); @@ -26,50 +18,44 @@ describe('E2E: player storage', () => { 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 }) }, - ]); + await client.storage.save('progress', JSON.stringify({ level: 7, gold: 1200 })); + await client.storage.save('prefs', 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'); + const progress = all.items!.find((i) => i.type === 'progress'); 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: '{}' }, - ]); - - 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'); + const put = gateway.recorded.find((r) => r.method === 'PUT' && r.path === '/sdk/v1/storage'); + expect(put?.body).toEqual({ items: [{ type: 'progress', data: '{"level":7,"gold":1200}' }] }); 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 () => { + it('save overwrites the item of the same type', async () => { await client.auth.loginWithDevice(); - await client.storage.save([ - { id: 'a', type: 'progress', data: '{}' }, - { id: 'b', type: 'prefs', data: '{}' }, - ]); + await client.storage.save('progress', '1'); + await client.storage.save('progress', '2'); + const all = await client.storage.reload(); + expect(all.items?.map((i) => i.data)).toEqual(['2']); + }); + + it('deletes a type and the item is gone', async () => { + await client.auth.loginWithDevice(); + await client.storage.save('progress', '{}'); + await client.storage.save('prefs', '{}'); await client.storage.delete('progress'); const remaining = await client.storage.reload(); - expect(remaining.items?.map((i) => i.id)).toEqual(['b']); + expect(remaining.items?.map((i) => i.type)).toEqual(['prefs']); }); it('storage writes carry the auth token', async () => { const token = (await client.auth.loginWithDevice()).accessToken; - await client.storage.save([{ id: 'a', type: 't', data: '{}' }]); + await client.storage.save('t', '{}'); const put = gateway.recorded.find((r) => r.method === 'PUT' && r.path === '/sdk/v1/storage'); expect(put?.authToken).toBe(token); diff --git a/test/fixtures/leaderboards.manifest.json b/test/fixtures/leaderboards.manifest.json new file mode 100644 index 0000000..00dd6eb --- /dev/null +++ b/test/fixtures/leaderboards.manifest.json @@ -0,0 +1,6 @@ +{"slug":"leaderboards","version":"5.0.0","functions":{ + "submit":{"trigger":"client","input":{"type":"object","required":["slug","score"],"properties":{"slug":{"type":"string"},"score":{"type":"integer"},"mode":{"type":"string","enum":["best","last"]}}}, + "output":{"type":"object","required":["rank"],"properties":{"rank":{"type":"integer"}}},"errors":["leaderboard_not_found"]}, + "top":{"trigger":"client","input":{"type":"object","required":["slug"],"properties":{"slug":{"type":"string"},"limit":{"type":"integer"}}}, + "output":{"type":"object","required":["entries"],"properties":{"entries":{"type":"array","items":{"type":"object","required":["playerId","score"],"properties":{"playerId":{"type":"string"},"score":{"type":"number"}}}}}}}, + "reset":{"trigger":"schedule"}}} diff --git a/test/fixtures/rudder.modules.ts b/test/fixtures/rudder.modules.ts new file mode 100644 index 0000000..2e3e25a --- /dev/null +++ b/test/fixtures/rudder.modules.ts @@ -0,0 +1,77 @@ +// Code generated by rudder client generate. DO NOT EDIT. +// +// Modules: +// leaderboards@5.0.0 + +export interface RudderModuleCaller { + modules: { + call(module: string, fn: string, args: unknown, options?: { moduleVersion?: string }): Promise + } +} + +export type RudderKernelError = + | "call_depth_exceeded" + | "conflict" + | "counter_only_increment" + | "dependency_not_declared" + | "forbidden" + | "function_not_callable" + | "function_not_found" + | "insufficient_funds" + | "internal" + | "invalid_parameters" + | "module_not_installed" + | "player_not_found" + | "player_required" + | "reserved_event" + | "resource_invalid_field" + | "resource_invalid_ref" + | "resource_not_found" + | "row_not_found" + | "row_referenced" + | "storage_key_limit_exceeded" + | "unique_violation" + | "unknown_operation" + | "version_conflict" + +export interface LeaderboardsSubmitArgs { + mode?: "best" | "last" + score: number + slug: string +} + +export interface LeaderboardsSubmitResult { + rank: number +} + +export type LeaderboardsSubmitError = + | RudderKernelError + | "leaderboard_not_found" + +export interface LeaderboardsTopArgs { + limit?: number + slug: string +} + +export interface LeaderboardsTopResultEntriesItem { + playerId: string + score: number +} + +export interface LeaderboardsTopResult { + entries: Array +} + +export type LeaderboardsTopError = + | RudderKernelError + +export function modules(client: RudderModuleCaller) { + return { + leaderboards: { + submit: (args: LeaderboardsSubmitArgs) => + client.modules.call("leaderboards", "submit", args, { moduleVersion: "5.0.0" }), + top: (args: LeaderboardsTopArgs) => + client.modules.call("leaderboards", "top", args, { moduleVersion: "5.0.0" }), + }, + } +} diff --git a/test/helpers/createClient.ts b/test/helpers/createClient.ts index 1d617b3..5d40680 100644 --- a/test/helpers/createClient.ts +++ b/test/helpers/createClient.ts @@ -1,10 +1,10 @@ -import { RudderClient } from '../../src/client/RudderClient.js'; +import { RudderClient, createClient } 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({ + return createClient({ baseUrl: 'https://api.test.rudder.build', projectKey: 'test-project-key', tokenStore: createFakeTokenStore(), diff --git a/test/helpers/fakeGateway.ts b/test/helpers/fakeGateway.ts index 439e39f..1a9573b 100644 --- a/test/helpers/fakeGateway.ts +++ b/test/helpers/fakeGateway.ts @@ -1,9 +1,7 @@ import { vi } from 'vitest'; import type { StorageItem } from '../../src/generated/storage.js'; -import type { ResourceRow } from '../../src/generated/resources.js'; import type { CallModuleFunctionClientResponse } from '../../src/generated/modules.js'; import type { CatalogCurrency } from '../../src/generated/catalog.js'; -import type { ProjectStorageItem } from '../../src/generated/project-storage.js'; export interface RecordedRequest { method: string; @@ -11,14 +9,14 @@ export interface RecordedRequest { query: URLSearchParams; body: unknown; authToken: string | null; + headers: Headers; } export interface FakeGatewayState { storage: Map; - projectStorage: Map; currencies: CatalogCurrency[]; - resources: Map>; - moduleCalls: CallModuleFunctionClientResponse; + /** Response per `module/fn`; falls back to `{ result: {} }`. */ + moduleCalls: Map; } export interface FakeGateway { @@ -39,10 +37,8 @@ const noContent = (): Response => new Response(null, { status: 204 }); export function createFakeGateway(seed?: Partial): FakeGateway { const state: FakeGatewayState = { storage: seed?.storage ?? new Map(), - projectStorage: seed?.projectStorage ?? new Map(), currencies: seed?.currencies ?? [], - resources: seed?.resources ?? new Map(), - moduleCalls: seed?.moduleCalls ?? { result: {} }, + moduleCalls: seed?.moduleCalls ?? new Map(), }; const recorded: RecordedRequest[] = []; @@ -72,49 +68,7 @@ export function createFakeGateway(seed?: Partial): FakeGateway const moduleMatch = path.match(/^\/sdk\/v1\/modules\/([^/]+)\/([^/]+)$/); if (method === 'POST' && moduleMatch) { - return json(state.moduleCalls); - } - - const resourceItemMatch = path.match(/^\/sdk\/v1\/resources\/([^/]+)\/([^/]+)$/); - const resourceListMatch = path.match(/^\/sdk\/v1\/resources\/([^/]+)$/); - if (resourceItemMatch) { - const slug = decodeURIComponent(resourceItemMatch[1]); - const id = decodeURIComponent(resourceItemMatch[2]); - const rows = state.resources.get(slug) ?? new Map(); - if (method === 'GET') { - const row = rows.get(id); - return row ? json(row) : json({ error: 'not found' }, 404); - } - if (method === 'PATCH') { - const existing = rows.get(id); - if (!existing) return json({ error: 'not found' }, 404); - const next = { ...existing, ...(body as ResourceRow), id }; - rows.set(id, next); - state.resources.set(slug, rows); - return json(next); - } - if (method === 'DELETE') { - rows.delete(id); - state.resources.set(slug, rows); - return noContent(); - } - } - if (resourceListMatch) { - const slug = decodeURIComponent(resourceListMatch[1]); - const rows = state.resources.get(slug) ?? new Map(); - if (method === 'GET') { - return json({ items: [...rows.values()], total: rows.size }); - } - if (method === 'POST') { - const created: ResourceRow = { - id: (body as ResourceRow).id ?? `row-${rows.size + 1}`, - data: (body as ResourceRow).data, - version: 1, - }; - rows.set(created.id!, created); - state.resources.set(slug, rows); - return json(created); - } + return json(state.moduleCalls.get(`${moduleMatch[1]}/${moduleMatch[2]}`) ?? { result: {} }); } if (path === '/sdk/v1/storage') { @@ -128,28 +82,14 @@ export function createFakeGateway(seed?: Partial): FakeGateway if (method === 'PUT') { const items = (body as { items?: StorageItem[] }).items ?? []; for (const item of items) { - if (item.id) state.storage.set(item.id, item); + if (item.type) state.storage.set(item.type, { id: `id-${item.type}`, ...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(); - } - } - - if (path === '/sdk/v1/project-storage') { - if (method === 'GET') { - return json({ items: [...state.projectStorage.values()], nextCursor: '' }); - } - if (method === 'PUT') { - const items = (body as { items?: ProjectStorageItem[] }).items ?? []; - for (const item of items) { - if (item.id) state.projectStorage.set(item.id, item); - } + if (type) state.storage.delete(type); + else state.storage.clear(); return noContent(); } } @@ -170,6 +110,7 @@ export function createFakeGateway(seed?: Partial): FakeGateway query: url.searchParams, body: init?.body ? JSON.parse(init.body as string) : undefined, authToken: auth ? auth.replace(/^Bearer\s+/, '') : null, + headers, }; recorded.push(req); return handle(req);