feat!: @rudder/sdk 0.1.0: createClient, idempotent modules.call retries, storage save(type, data), drop resources and projectStorage

This commit is contained in:
edmand46
2026-09-17 15:26:42 +03:00
parent c25b1c2c61
commit 1e4e0d928d
35 changed files with 474 additions and 467 deletions
+4 -4
View File
@@ -1,12 +1,12 @@
{ {
"name": "@rudder/js-sdk", "name": "@rudder/sdk",
"version": "2.0.0", "version": "0.1.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@rudder/js-sdk", "name": "@rudder/sdk",
"version": "2.0.0", "version": "0.1.0",
"devDependencies": { "devDependencies": {
"@types/node": "^26.1.1", "@types/node": "^26.1.1",
"jsdom": "^29.1.1", "jsdom": "^29.1.1",
+3 -3
View File
@@ -1,10 +1,10 @@
{ {
"name": "@rudder/js-sdk", "name": "@rudder/sdk",
"version": "2.0.0", "version": "0.1.0",
"publishConfig": { "publishConfig": {
"registry": "https://hub.rudder.build/api/packages/rudder/npm/" "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", "type": "module",
"main": "./dist/index.cjs", "main": "./dist/index.cjs",
"module": "./dist/index.js", "module": "./dist/index.js",
+28 -29
View File
@@ -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, * Composes the kernel domains (auth, player, catalog, storage, modules) and the HTTP transport every service uses to reach the API.
* resources) and the HTTP transport every service uses to reach the API.
* *
* The client builds a {@link RudderContext} first and injects it into every * 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 * domain, so they depend on that narrow surface rather than on the client
@@ -15,16 +15,16 @@ import type {
} from './RudderClientOptions.js'; } from './RudderClientOptions.js';
import { RudderError, SDK_ERROR_INVALID_OPTIONS } from './RudderError.js'; import { RudderError, SDK_ERROR_INVALID_OPTIONS } from './RudderError.js';
import type { RudderContext } from '../core/context.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 { createDefaultTokenStore } from '../token/TokenStore.js';
import { AuthService } from '../auth/AuthService.js'; import { AuthService } from '../auth/AuthService.js';
import { SyncEngine } from '../state/SyncEngine.js'; import { SyncEngine } from '../state/SyncEngine.js';
import { PlayerDomain } from '../domains/PlayerDomain.js'; import { PlayerDomain } from '../domains/PlayerDomain.js';
import { CatalogDomain } from '../domains/CatalogDomain.js'; import { CatalogDomain } from '../domains/CatalogDomain.js';
import { StorageDomain } from '../domains/StorageDomain.js'; import { StorageDomain } from '../domains/StorageDomain.js';
import { ProjectStorageDomain } from '../domains/ProjectStorageDomain.js';
import { ModulesDomain } from '../domains/ModulesDomain.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 }; type ManagedDomain = { invalidate(): void; reset(): void };
@@ -36,9 +36,7 @@ export class RudderClient {
public readonly player: PlayerDomain; public readonly player: PlayerDomain;
public readonly catalog: CatalogDomain; public readonly catalog: CatalogDomain;
public readonly storage: StorageDomain; public readonly storage: StorageDomain;
public readonly projectStorage: ProjectStorageDomain;
public readonly modules: ModulesDomain; public readonly modules: ModulesDomain;
private readonly resourcesDomain: ResourcesDomain;
private readonly syncEngine: SyncEngine; private readonly syncEngine: SyncEngine;
private readonly ctx: RudderContext; private readonly ctx: RudderContext;
@@ -46,18 +44,17 @@ export class RudderClient {
private runtimeStarted = false; private runtimeStarted = false;
constructor(options: RudderClientOptions) { constructor(options: RudderClientOptions) {
if (!options.baseUrl) {
throw new RudderError('RudderClient: baseUrl is required', {
code: SDK_ERROR_INVALID_OPTIONS,
});
}
if (!options.projectKey) { if (!options.projectKey) {
throw new RudderError('RudderClient: projectKey is required', { throw new RudderError('RudderClient: projectKey is required', {
code: SDK_ERROR_INVALID_OPTIONS, 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 = { this.transportContext = {
baseUrl: this.options.baseUrl, baseUrl: this.options.baseUrl,
@@ -73,24 +70,25 @@ export class RudderClient {
this.ctx = { this.ctx = {
options: this.options, options: this.options,
request: <TResponse>(method: string, path: string, body?: unknown) => request: <TResponse>(
request<TResponse>(method, path, body, this.transportContext), method: string,
path: string,
body?: unknown,
options?: RequestOptions,
) => request<TResponse>(method, path, body, this.transportContext, options),
}; };
const ctx = this.ctx; const ctx = this.ctx;
this.player = new PlayerDomain(ctx); this.player = new PlayerDomain(ctx);
this.catalog = new CatalogDomain(ctx); this.catalog = new CatalogDomain(ctx);
this.storage = new StorageDomain(ctx); this.storage = new StorageDomain(ctx);
this.projectStorage = new ProjectStorageDomain(ctx);
this.modules = new ModulesDomain(ctx); this.modules = new ModulesDomain(ctx);
this.resourcesDomain = new ResourcesDomain(ctx);
this.syncEngine = new SyncEngine(ctx, [ this.syncEngine = new SyncEngine(
this.player, ctx,
this.catalog, [this.player, this.catalog, this.storage],
this.storage, this.options.syncIntervalMs,
this.projectStorage, );
]);
this.auth = new AuthService( this.auth = new AuthService(
ctx, ctx,
() => this.startRuntimeAfterLogin(), () => this.startRuntimeAfterLogin(),
@@ -104,10 +102,6 @@ export class RudderClient {
this.runtimeStarted = false; this.runtimeStarted = false;
} }
resources(slug: string): ResourceHandle {
return this.resourcesDomain.of(slug);
}
/** @internal */ /** @internal */
async startRuntimeAfterLogin(): Promise<void> { async startRuntimeAfterLogin(): Promise<void> {
if (this.runtimeStarted) { if (this.runtimeStarted) {
@@ -126,7 +120,7 @@ export class RudderClient {
} }
private get managedDomains(): ManagedDomain[] { private get managedDomains(): ManagedDomain[] {
return [this.player, this.catalog, this.storage, this.projectStorage]; return [this.player, this.catalog, this.storage];
} }
private invalidateAll(): void { private invalidateAll(): void {
@@ -137,3 +131,8 @@ export class RudderClient {
for (const domain of this.managedDomains) domain.reset(); 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);
}
+5 -4
View File
@@ -1,10 +1,7 @@
import type { TokenStore } from '../token/TokenStore.js'; import type { TokenStore } from '../token/TokenStore.js';
export interface RudderClientOptions { export interface RudderClientOptions {
/** Base URL of the LiveOps API gateway (e.g., "https://api.rudder.build"). */ /** Project SDK key; it also selects the environment (staging or prod). */
baseUrl: string;
/** Project key that identifies the game project. */
projectKey: string; projectKey: string;
/** /**
@@ -23,11 +20,15 @@ export interface RudderClientOptions {
/** Per-request timeout in milliseconds (default: 10000). */ /** Per-request timeout in milliseconds (default: 10000). */
requestTimeoutMs?: number; requestTimeoutMs?: number;
/** API base URL (default: "https://api.rudder.build"). */
baseUrl?: string;
/** Revision sync poll interval in milliseconds (default: 30000, ±20% jitter). */ /** Revision sync poll interval in milliseconds (default: 30000, ±20% jitter). */
syncIntervalMs?: number; syncIntervalMs?: number;
} }
/** Client options with all defaults resolved — what `client.options` exposes. */ /** Client options with all defaults resolved — what `client.options` exposes. */
export interface ResolvedRudderClientOptions extends RudderClientOptions { export interface ResolvedRudderClientOptions extends RudderClientOptions {
baseUrl: string;
tokenStore: TokenStore; tokenStore: TokenStore;
} }
+16
View File
@@ -53,3 +53,19 @@ export class RudderAuthError extends RudderHttpError {
this.name = 'RudderAuthError'; 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<C extends string = string> 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;
}
}
+7 -1
View File
@@ -1,4 +1,5 @@
import type { ResolvedRudderClientOptions } from '../client/RudderClientOptions.js'; import type { ResolvedRudderClientOptions } from '../client/RudderClientOptions.js';
import type { RequestOptions } from '../transport/request.js';
/** /**
* RudderContext — the low-level surface every feature service depends on: * RudderContext — the low-level surface every feature service depends on:
@@ -10,5 +11,10 @@ import type { ResolvedRudderClientOptions } from '../client/RudderClientOptions.
*/ */
export interface RudderContext { export interface RudderContext {
readonly options: ResolvedRudderClientOptions; readonly options: ResolvedRudderClientOptions;
request<TResponse>(method: string, path: string, body?: unknown): Promise<TResponse>; request<TResponse>(
method: string,
path: string,
body?: unknown,
options?: RequestOptions,
): Promise<TResponse>;
} }
+9
View File
@@ -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)}`;
}
+4 -3
View File
@@ -1,10 +1,11 @@
/** /**
* Device ID provider — generates and persists a unique device identifier. * Device ID provider — generates and persists a unique device identifier.
* *
* Uses `crypto.randomUUID()` (available in all evergreen browsers) and * Generates a UUID v4 and stores the generated UUID in localStorage under `rudder_device_id`.
* stores the generated UUID in localStorage under `rudder_device_id`.
*/ */
import { uuidv4 } from '../core/uuid.js';
const STORAGE_KEY = 'rudder_device_id'; const STORAGE_KEY = 'rudder_device_id';
/** /**
@@ -19,7 +20,7 @@ export function getOrCreateDeviceId(): string {
// localStorage unavailable — generate ephemeral ID. // localStorage unavailable — generate ephemeral ID.
} }
const id = crypto.randomUUID(); const id = uuidv4();
try { try {
localStorage.setItem(STORAGE_KEY, id); localStorage.setItem(STORAGE_KEY, id);
} catch { } catch {
+52 -4
View File
@@ -1,11 +1,59 @@
import type { RudderContext } from '../core/context.js'; import type { RudderContext } from '../core/context.js';
import { api } from '../generated/api.js'; import { uuidv4 } from '../core/uuid.js';
import type { CallModuleFunctionClientResponse } from '../generated/modules.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: <module>@<version>`. */
moduleVersion?: string;
}
/**
* ModulesDomain — calls module functions. Usually reached through a client
* generated by `rudder client generate`, not directly.
*/
export class ModulesDomain { export class ModulesDomain {
constructor(private readonly ctx: RudderContext) {} constructor(private readonly ctx: RudderContext) {}
call(module: string, fn: string, args?: unknown): Promise<CallModuleFunctionClientResponse> { /**
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<T = unknown>(
module: string,
fn: string,
args?: unknown,
options?: ModuleCallOptions,
): Promise<T> {
const headers: Record<string, string> = { '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<CallModuleFunctionClientResponse>(
'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<ModuleErrorResponse> = {};
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;
}
} }
} }
+2 -1
View File
@@ -4,7 +4,8 @@ import { api } from '../generated/api.js';
import type { PlayerProfile } from '../generated/player.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`. * Synced under revision key `profile`.
*/ */
export class PlayerDomain extends SyncedState<PlayerProfile> { export class PlayerDomain extends SyncedState<PlayerProfile> {
-26
View File
@@ -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<GetProjectStorageResponse> {
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<void> {
await api.updateProjectStorage(this.ctx, { items });
this.invalidate();
}
}
-43
View File
@@ -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<ListResourceRowsResponse> {
return api.listResourceRows(this.ctx, { slug: this.slug });
}
get(id: string): Promise<ResourceRow> {
return api.getResourceRow(this.ctx, { slug: this.slug, id });
}
create(body: CreateResourceRowRequest): Promise<ResourceRow> {
return api.createResourceRow(this.ctx, { slug: this.slug }, body);
}
update(id: string, body: UpdateResourceRowRequest): Promise<ResourceRow> {
return api.updateResourceRow(this.ctx, { slug: this.slug, id }, body);
}
delete(id: string): Promise<void> {
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);
}
}
+7 -7
View File
@@ -1,11 +1,11 @@
import { SyncedState } from '../state/SyncedState.js'; import { SyncedState } from '../state/SyncedState.js';
import type { RudderContext } from '../core/context.js'; import type { RudderContext } from '../core/context.js';
import { api } from '../generated/api.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 * StorageDomain — player storage (one item per type) as an observable entity,
* mutations. Synced under revision key `storage`; mutations invalidate it so * plus its mutations. Synced under revision key `storage`; mutations invalidate it so
* subscribers observe fresh data. * subscribers observe fresh data.
*/ */
export class StorageDomain extends SyncedState<GetStorageResponse> { export class StorageDomain extends SyncedState<GetStorageResponse> {
@@ -15,13 +15,13 @@ export class StorageDomain extends SyncedState<GetStorageResponse> {
super(() => api.getStorage(ctx, { limit: 100 })); super(() => api.getStorage(ctx, { limit: 100 }));
} }
/** Saves player storage items, then invalidates. */ /** Upserts the item of `type` with raw string `data`, then invalidates. */
async save(items: StorageItem[]): Promise<void> { async save(type: string, data: string): Promise<void> {
await api.updateStorage(this.ctx, { items }); await api.updateStorage(this.ctx, { items: [{ type, data }] });
this.invalidate(); 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<void> { async delete(type: string): Promise<void> {
await api.deleteStorage(this.ctx, { type }); await api.deleteStorage(this.ctx, { type });
this.invalidate(); this.invalidate();
-47
View File
@@ -4,8 +4,6 @@ import type { LinkViaAppleRequest, LinkViaCustomRequest, LinkViaGoogleRequest, L
import type { ListCatalogCurrenciesResponse } from './catalog.js'; import type { ListCatalogCurrenciesResponse } from './catalog.js';
import type { CallModuleFunctionClientResponse, CallModuleFunctionRequest } from './modules.js'; import type { CallModuleFunctionClientResponse, CallModuleFunctionRequest } from './modules.js';
import type { PlayerProfile } from './player.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'; import type { GetStorageResponse, UpdateStorageRequest } from './storage.js';
/** The minimal transport contract the generated API functions call through. */ /** The minimal transport contract the generated API functions call through. */
@@ -38,19 +36,6 @@ export const api = {
): Promise<CallModuleFunctionClientResponse> => ): Promise<CallModuleFunctionClientResponse> =>
t.request<CallModuleFunctionClientResponse>('POST', `/sdk/v1/modules/${enc(path.module)}/${enc(path.fn)}`, body), t.request<CallModuleFunctionClientResponse>('POST', `/sdk/v1/modules/${enc(path.module)}/${enc(path.fn)}`, body),
createResourceRow: (
t: Transport,
path: { slug: string },
body: CreateResourceRowRequest,
): Promise<ResourceRow> =>
t.request<ResourceRow>('POST', `/sdk/v1/resources/${enc(path.slug)}`, body),
deleteResourceRow: (
t: Transport,
path: { slug: string; id: string },
): Promise<void> =>
t.request<void>('DELETE', `/sdk/v1/resources/${enc(path.slug)}/${enc(path.id)}`),
deleteStorage: ( deleteStorage: (
t: Transport, t: Transport,
query?: { type?: string }, query?: { type?: string },
@@ -62,18 +47,6 @@ export const api = {
): Promise<PlayerProfile> => ): Promise<PlayerProfile> =>
t.request<PlayerProfile>('GET', '/sdk/v1/player/information'), t.request<PlayerProfile>('GET', '/sdk/v1/player/information'),
getProjectStorage: (
t: Transport,
query?: { types?: string; limit?: number; cursor?: string },
): Promise<GetProjectStorageResponse> =>
t.request<GetProjectStorageResponse>('GET', `/sdk/v1/project-storage${qs([['types', query?.types], ['limit', query?.limit], ['cursor', query?.cursor]])}`),
getResourceRow: (
t: Transport,
path: { slug: string; id: string },
): Promise<ResourceRow> =>
t.request<ResourceRow>('GET', `/sdk/v1/resources/${enc(path.slug)}/${enc(path.id)}`),
getRevisions: ( getRevisions: (
t: Transport, t: Transport,
): Promise<{ [key: string]: number }> => ): Promise<{ [key: string]: number }> =>
@@ -108,13 +81,6 @@ export const api = {
): Promise<ListCatalogCurrenciesResponse> => ): Promise<ListCatalogCurrenciesResponse> =>
t.request<ListCatalogCurrenciesResponse>('GET', '/sdk/v1/catalog'), t.request<ListCatalogCurrenciesResponse>('GET', '/sdk/v1/catalog'),
listResourceRows: (
t: Transport,
path: { slug: string },
query?: { _q?: string; _sort?: string; _order?: string; _start?: number; _end?: number },
): Promise<ListResourceRowsResponse> =>
t.request<ListResourceRowsResponse>('GET', `/sdk/v1/resources/${enc(path.slug)}${qs([['_q', query?._q], ['_sort', query?._sort], ['_order', query?._order], ['_start', query?._start], ['_end', query?._end]])}`),
loginViaApple: ( loginViaApple: (
t: Transport, t: Transport,
body: LoginViaAppleRequest, body: LoginViaAppleRequest,
@@ -151,19 +117,6 @@ export const api = {
): Promise<void> => ): Promise<void> =>
t.request<void>('POST', '/sdk/v1/authorization/unlink', body), t.request<void>('POST', '/sdk/v1/authorization/unlink', body),
updateProjectStorage: (
t: Transport,
body: UpdateProjectStorageRequest,
): Promise<void> =>
t.request<void>('PUT', '/sdk/v1/project-storage', body),
updateResourceRow: (
t: Transport,
path: { slug: string; id: string },
body: UpdateResourceRowRequest,
): Promise<ResourceRow> =>
t.request<ResourceRow>('PATCH', `/sdk/v1/resources/${enc(path.slug)}/${enc(path.id)}`, body),
updateStorage: ( updateStorage: (
t: Transport, t: Transport,
body: UpdateStorageRequest, body: UpdateStorageRequest,
+2 -2
View File
@@ -1,9 +1,9 @@
// Code generated by apigen. DO NOT EDIT. // Code generated by apigen. DO NOT EDIT.
import type { ResourceRowUsage } from './resources.js'; import type { ResourceRowUsage } from './modules.js';
export interface ErrorResponse { export interface ErrorResponse {
"code"?: "forbidden" | "row_referenced"; "code"?: "forbidden" | "idempotency_key_mismatch" | "row_referenced";
"error"?: string; "error"?: string;
"index"?: number; "index"?: number;
"path"?: string; "path"?: string;
+2 -1
View File
@@ -1,8 +1,9 @@
// Code generated by apigen. DO NOT EDIT. // 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 = { export const RudderErrorCodes = {
forbidden: "forbidden", forbidden: "forbidden",
idempotencyKeyMismatch: "idempotency_key_mismatch",
rowReferenced: "row_referenced", rowReferenced: "row_referenced",
} as const; } as const;
-2
View File
@@ -5,8 +5,6 @@ export * from './catalog.js';
export * from './common.js'; export * from './common.js';
export * from './modules.js'; export * from './modules.js';
export * from './player.js'; export * from './player.js';
export * from './project-storage.js';
export * from './resources.js';
export * from './storage.js'; export * from './storage.js';
export * from './api.js'; export * from './api.js';
export * from './errors.js'; export * from './errors.js';
+7
View File
@@ -15,3 +15,10 @@ export interface ModuleErrorResponse {
"requestId"?: string; "requestId"?: string;
} }
export interface ResourceRowUsage {
"definitionSlug"?: string;
"field"?: string;
"moduleSlug"?: string;
"rowId"?: string;
}
+1
View File
@@ -11,6 +11,7 @@ export interface Player {
} }
export interface PlayerProfile { export interface PlayerProfile {
"counters"?: { [key: string]: number };
"identities"?: PlayerProfileIdentity[]; "identities"?: PlayerProfileIdentity[];
"player"?: Player; "player"?: Player;
"wallets"?: Wallet[]; "wallets"?: Wallet[];
-29
View File
@@ -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[];
}
-34
View File
@@ -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;
}
+6 -21
View File
@@ -1,13 +1,14 @@
// @rudder/web-sdk — LiveOps Web SDK for browser games // @rudder/sdk — Rudder client SDK for web games
// Client // Client
export { RudderClient } from './client/RudderClient.js'; export { RudderClient, createClient } from './client/RudderClient.js';
export type { RudderClientOptions } from './client/RudderClientOptions.js'; export type { RudderClientOptions } from './client/RudderClientOptions.js';
export { export {
RudderError, RudderError,
RudderNetworkError, RudderNetworkError,
RudderHttpError, RudderHttpError,
RudderAuthError, RudderAuthError,
RudderModuleError,
SDK_ERROR_INVALID_OPTIONS, SDK_ERROR_INVALID_OPTIONS,
} from './client/RudderError.js'; } from './client/RudderError.js';
export type { RudderErrorCodeLike } from './client/RudderError.js'; export type { RudderErrorCodeLike } from './client/RudderError.js';
@@ -37,31 +38,15 @@ export type {
} from './state/SyncedState.js'; } from './state/SyncedState.js';
// Domains — types only; instances live on the client // 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 { PlayerDomain } from './domains/PlayerDomain.js';
export type { CatalogDomain } from './domains/CatalogDomain.js'; export type { CatalogDomain } from './domains/CatalogDomain.js';
export type { ModulesDomain } from './domains/ModulesDomain.js'; export type { ModulesDomain, ModuleCallOptions } from './domains/ModulesDomain.js';
export type { ResourcesDomain, ResourceHandle } from './domains/ResourcesDomain.js';
export type { StorageDomain } from './domains/StorageDomain.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 // Generated wire types — the shapes returned by the observable state and
// handles, so consumers can name them instead of re-declaring mirrors. // handles, so consumers can name them instead of re-declaring mirrors.
export type { Player, PlayerProfile, PlayerProfileIdentity, Wallet } from './generated/player.js'; export type { Player, PlayerProfile, PlayerProfileIdentity, Wallet } from './generated/player.js';
export type { CatalogCurrency } from './generated/catalog.js'; export type { CatalogCurrency } from './generated/catalog.js';
export type { StorageItem, GetStorageResponse } from './generated/storage.js'; export type { StorageItem, GetStorageResponse } from './generated/storage.js';
export type { export type { ModuleErrorResponse } from './generated/modules.js';
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';
+11 -11
View File
@@ -1,7 +1,7 @@
/** /**
* SyncedState — an observable cell for a single server-synced entity. * 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 * listeners on every data/status change. `onChange` calls the callback
* immediately with the current snapshot, which makes syncing into external * immediately with the current snapshot, which makes syncing into external
* stores (React/zustand) trivial. * stores (React/zustand) trivial.
@@ -11,7 +11,7 @@ export type SyncedStatus = 'idle' | 'loading' | 'ready' | 'error';
export interface SyncedSnapshot<T> { export interface SyncedSnapshot<T> {
status: SyncedStatus; status: SyncedStatus;
data?: T; value?: T;
error?: Error; error?: Error;
} }
@@ -27,8 +27,8 @@ export class SyncedState<T> {
constructor(private readonly loader: () => Promise<T>) {} constructor(private readonly loader: () => Promise<T>) {}
/** Current data, or undefined when not loaded yet. */ /** Current data, or undefined when not loaded yet. */
get data(): T | undefined { get value(): T | undefined {
return this.snapshot.data; return this.snapshot.value;
} }
get status(): SyncedStatus { get status(): SyncedStatus {
@@ -54,7 +54,7 @@ export class SyncedState<T> {
/** Loads the entity if needed; deduplicates parallel loads. */ /** Loads the entity if needed; deduplicates parallel loads. */
load(): Promise<T> { load(): Promise<T> {
if (this.snapshot.status === 'ready') { 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); return this.startLoad(false);
} }
@@ -100,18 +100,18 @@ export class SyncedState<T> {
private startLoad(force: boolean): Promise<T> { private startLoad(force: boolean): Promise<T> {
if (this.pending && !force) return this.pending; if (this.pending && !force) return this.pending;
const generation = ++this.generation; const generation = ++this.generation;
this.setSnapshot({ status: 'loading', data: this.snapshot.data }); this.setSnapshot({ status: 'loading', value: this.snapshot.value });
this.pending = this.loader() this.pending = this.loader()
.then((data) => { .then((value) => {
if (generation === this.generation) { if (generation === this.generation) {
this.setSnapshot({ status: 'ready', data }); this.setSnapshot({ status: 'ready', value });
} }
return data; return value;
}) })
.catch((err) => { .catch((err) => {
const error = err instanceof Error ? err : new Error(String(err)); const error = err instanceof Error ? err : new Error(String(err));
if (generation === this.generation) { if (generation === this.generation) {
this.setSnapshot({ status: 'error', error, data: this.snapshot.data }); this.setSnapshot({ status: 'error', error, value: this.snapshot.value });
} }
throw error; throw error;
}) })
@@ -127,7 +127,7 @@ export class SyncedState<T> {
const previous = this.snapshot; const previous = this.snapshot;
if ( if (
previous.status === next.status && previous.status === next.status &&
previous.data === next.data && previous.value === next.value &&
previous.error === next.error previous.error === next.error
) { ) {
return; return;
+41 -17
View File
@@ -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. * single-flight token refresh, and typed error mapping.
*/ */
@@ -12,9 +12,20 @@ import type {
export const DEFAULT_REQUEST_TIMEOUT_MS = 10_000; export const DEFAULT_REQUEST_TIMEOUT_MS = 10_000;
const MAX_GET_RETRIES = 2; const MAX_RETRIES = 2;
const RETRY_BASE_DELAY_MS = 300; 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<string, string>;
/**
* 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 { export interface RequestContext {
baseUrl: string; baseUrl: string;
tokenStore: TokenStore; tokenStore: TokenStore;
@@ -34,7 +45,8 @@ export interface RequestContext {
* - Injects `Authorization: Bearer <token>` header (if token is available) * - Injects `Authorization: Bearer <token>` header (if token is available)
* - Serializes body as JSON, deserializes response as JSON * - Serializes body as JSON, deserializes response as JSON
* - Aborts the request after `requestTimeoutMs` (default 10s) * - 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; * - On 401 runs a single-flight token refresh and retries the request once;
* when the refresh fails, clears tokens and throws RudderAuthError * when the refresh fails, clears tokens and throws RudderAuthError
* - Maps HTTP errors to typed exceptions (RudderNetworkError, RudderHttpError, RudderAuthError) * - Maps HTTP errors to typed exceptions (RudderNetworkError, RudderHttpError, RudderAuthError)
@@ -45,13 +57,14 @@ export async function request<TResponse>(
path: string, path: string,
body: unknown | undefined, body: unknown | undefined,
ctx: RequestContext, ctx: RequestContext,
options: RequestOptions = {},
): Promise<TResponse> { ): Promise<TResponse> {
let response = await requestWithRetry(method, path, body, ctx); let response = await requestWithRetry(method, path, body, ctx, options);
if (response.status === 401) { if (response.status === 401) {
const refreshed = await refreshTokens(ctx); const refreshed = await refreshTokens(ctx);
if (refreshed) { if (refreshed) {
response = await requestWithRetry(method, path, body, ctx); response = await requestWithRetry(method, path, body, ctx, options);
} else { } else {
ctx.tokenStore.clear(); ctx.tokenStore.clear();
ctx.onAuthFailure?.(); ctx.onAuthFailure?.();
@@ -88,30 +101,37 @@ export async function request<TResponse>(
return response.json() as Promise<TResponse>; return response.json() as Promise<TResponse>;
} }
/** 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( async function requestWithRetry(
method: string, method: string,
path: string, path: string,
body: unknown | undefined, body: unknown | undefined,
ctx: RequestContext, ctx: RequestContext,
options: RequestOptions,
): Promise<Response> { ): Promise<Response> {
const retryable = method === 'GET'; const retryable = method === 'GET' || options.retry === true;
for (let attempt = 0; ; attempt++) { for (let attempt = 0; ; attempt++) {
const canRetry = retryable && attempt < MAX_RETRIES;
let response: Response;
try { try {
const response = await fetchOnce(method, path, body, ctx); response = await fetchOnce(method, path, body, ctx, options.headers);
if (retryable && response.status >= 500 && attempt < MAX_GET_RETRIES) { } catch (err) {
await sleep(RETRY_BASE_DELAY_MS * 2 ** attempt); if (!canRetry) throw new RudderNetworkError(err);
await backoff(attempt);
continue;
}
if (canRetry && (response.status >= 500 || (await isConflict(response)))) {
await backoff(attempt);
continue; continue;
} }
return response; 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 isConflict(response: Response): Promise<boolean> {
if (response.status !== 409) return false;
const parsed = await response.clone().json().catch(() => undefined);
return parsed?.code === 'conflict';
} }
async function fetchOnce( async function fetchOnce(
@@ -119,11 +139,13 @@ async function fetchOnce(
path: string, path: string,
body: unknown | undefined, body: unknown | undefined,
ctx: RequestContext, ctx: RequestContext,
extraHeaders?: Record<string, string>,
): Promise<Response> { ): Promise<Response> {
const url = `${ctx.baseUrl.replace(/\/+$/, '')}${path}`; const url = `${ctx.baseUrl.replace(/\/+$/, '')}${path}`;
const headers: Record<string, string> = { const headers: Record<string, string> = {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
...extraHeaders,
}; };
const token = ctx.tokenStore.getAccessToken(); const token = ctx.tokenStore.getAccessToken();
@@ -189,6 +211,8 @@ async function doRefreshTokens(ctx: RequestContext): Promise<boolean> {
} }
} }
function sleep(ms: number): Promise<void> { /** 300ms × 2^attempt, scaled by a random factor in [0.5, 1.5). */
function backoff(attempt: number): Promise<void> {
const ms = RETRY_BASE_DELAY_MS * 2 ** attempt * (0.5 + Math.random());
return new Promise((resolve) => setTimeout(resolve, ms)); return new Promise((resolve) => setTimeout(resolve, ms));
} }
+4 -4
View File
@@ -19,9 +19,7 @@ describe('public API surface', () => {
'PlayerDomain', 'PlayerDomain',
'CatalogDomain', 'CatalogDomain',
'StorageDomain', 'StorageDomain',
'ProjectStorageDomain',
'ModulesDomain', 'ModulesDomain',
'ResourcesDomain',
]) { ]) {
expect(name in sdk).toBe(false); expect(name in sdk).toBe(false);
} }
@@ -40,6 +38,8 @@ describe('public API surface', () => {
it('exposes the expected value exports', () => { it('exposes the expected value exports', () => {
for (const name of [ for (const name of [
'RudderClient', 'RudderClient',
'createClient',
'RudderModuleError',
'RudderError', 'RudderError',
'RudderNetworkError', 'RudderNetworkError',
'RudderHttpError', 'RudderHttpError',
@@ -67,10 +67,10 @@ describe('public API surface', () => {
expect(client.player).toBeDefined(); expect(client.player).toBeDefined();
expect(client.catalog).toBeDefined(); expect(client.catalog).toBeDefined();
expect(client.storage).toBeDefined(); expect(client.storage).toBeDefined();
expect(client.projectStorage).toBeDefined();
expect(client.modules).toBeDefined(); expect(client.modules).toBeDefined();
expect(typeof client.modules.call).toBe('function'); 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('battlePass' in client).toBe(false);
expect('quests' in client).toBe(false); expect('quests' in client).toBe(false);
+92 -59
View File
@@ -1,10 +1,11 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; 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 { import {
RudderError, RudderError,
RudderNetworkError, RudderNetworkError,
RudderHttpError, RudderHttpError,
RudderAuthError, RudderAuthError,
RudderModuleError,
SDK_ERROR_INVALID_OPTIONS, SDK_ERROR_INVALID_OPTIONS,
} from '../src/client/RudderError.js'; } from '../src/client/RudderError.js';
import { createFakeTokenStore } from './helpers/FakeTokenStore.js'; import { createFakeTokenStore } from './helpers/FakeTokenStore.js';
@@ -23,22 +24,16 @@ describe('RudderClient', () => {
expect(client.auth).toBeDefined(); expect(client.auth).toBeDefined();
}); });
it('throws RudderError with sdk/invalid-options if baseUrl is missing', () => { it('defaults baseUrl and exposes createClient', () => {
let caught: unknown; const client = createClient({ projectKey: 'proj_123', tokenStore: createFakeTokenStore() });
try { expect(client).toBeInstanceOf(RudderClient);
new RudderClient({ baseUrl: '', projectKey: 'proj_123' }); expect(client.options.baseUrl).toBe('https://api.rudder.build');
} 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', () => { it('throws RudderError with sdk/invalid-options if projectKey is missing', () => {
let caught: unknown; let caught: unknown;
try { try {
new RudderClient({ baseUrl: 'https://api.example.com', projectKey: '' }); new RudderClient({ projectKey: '' });
} catch (error) { } catch (error) {
caught = error; caught = error;
} }
@@ -122,7 +117,7 @@ describe('transport error mapping', () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue( vi.stubGlobal('fetch', vi.fn().mockResolvedValue(
new Response(null, { status: 204, statusText: 'No Content' }), new Response(null, { status: 204, statusText: 'No Content' }),
)); ));
const result = await client.storage.save([]); const result = await client.storage.save('t', '{}');
expect(result).toBeUndefined(); 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(() => { afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals(); vi.unstubAllGlobals();
}); });
it('modules.call posts to /sdk/v1/modules/:module/:fn', async () => { const respond = (status: number, body: unknown) =>
const client = new RudderClient({ new Response(JSON.stringify(body), { status });
baseUrl: 'https://api.example.com',
projectKey: 'proj_123', function headersOf(fetchMock: ReturnType<typeof vi.fn>, n: number): Headers {
tokenStore: createFakeTokenStore(), return new Headers(fetchMock.mock.calls[n][1].headers);
}); }
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ result: { ok: true }, log: [] }), { status: 200 }), 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); 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]; const [url, init] = fetchMock.mock.calls[0];
expect(new URL(String(url)).pathname).toBe('/sdk/v1/modules/quests/list'); expect(new URL(String(url)).pathname).toBe('/sdk/v1/modules/quests/list');
expect(JSON.parse(String(init.body))).toEqual({ args: { limit: 10 } }); 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 () => { it('sends {} when args are omitted and no version header without moduleVersion', async () => {
const client = new RudderClient({ const fetchMock = vi.fn().mockResolvedValue(respond(200, { result: 1 }));
baseUrl: 'https://api.example.com', vi.stubGlobal('fetch', fetchMock);
projectKey: 'proj_123', await client.modules.call('m', 'f');
tokenStore: createFakeTokenStore(), expect(JSON.parse(String(fetchMock.mock.calls[0][1].body))).toEqual({ args: {} });
}); expect(headersOf(fetchMock, 0).has('X-Rudder-Module-Version')).toBe(false);
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('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); vi.stubGlobal('fetch', fetchMock);
const handle = client.resources('items'); const pending = client.modules.call('m', 'f', {});
expect(await handle.list()).toEqual({ items: [row], total: 1 }); await vi.runAllTimersAsync();
expect(await handle.get('row-1')).toEqual(row); expect(await pending).toBe('done');
expect(await handle.create({ data: { name: 'Sword' } })).toEqual(row);
expect(await handle.update('row-1', { data: { name: 'Sword' }, version: 1 })).toEqual({ expect(fetchMock).toHaveBeenCalledTimes(3);
...row, const key = headersOf(fetchMock, 0).get('Idempotency-Key');
version: 2, 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();
}); });
}); });
+2 -2
View File
@@ -1,6 +1,6 @@
// Test-side helpers: load the seed artifact and build SDK clients against prod. // 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 type { TokenStore } from '../../../src/token/TokenStore.js';
import { readArtifact } from './artifact.js'; import { readArtifact } from './artifact.js';
import type { SeedArtifact } from './types.js'; import type { SeedArtifact } from './types.js';
@@ -41,7 +41,7 @@ export function createInMemoryTokenStore(): TokenStore {
export function makeProdClient( export function makeProdClient(
artifact: SeedArtifact = loadArtifact(), artifact: SeedArtifact = loadArtifact(),
): RudderClient { ): RudderClient {
return new RudderClient({ return createClient({
baseUrl: artifact.baseUrl, baseUrl: artifact.baseUrl,
projectKey: artifact.projectKey, projectKey: artifact.projectKey,
tokenStore: createInMemoryTokenStore(), tokenStore: createInMemoryTokenStore(),
+2 -1
View File
@@ -2,10 +2,11 @@ import { describe, it, expect } from 'vitest';
import { freshPlayer } from './_setup/harness.js'; import { freshPlayer } from './_setup/harness.js';
describe('e2e-prod: player', () => { 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 client = await freshPlayer();
const profile = await client.player.reload(); const profile = await client.player.reload();
expect(profile.player?.id).toBeTruthy(); expect(profile.player?.id).toBeTruthy();
expect(Array.isArray(profile.wallets)).toBe(true); expect(Array.isArray(profile.wallets)).toBe(true);
expect(profile).toHaveProperty('counters');
}); });
}); });
+2 -5
View File
@@ -5,18 +5,15 @@ describe('e2e-prod: storage', () => {
it('save -> get -> delete round-trip', async () => { it('save -> get -> delete round-trip', async () => {
const client = await freshPlayer(); const client = await freshPlayer();
const payload = JSON.stringify({ level: 7 }); 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(); const got = await client.storage.reload();
expect(got.items?.some((i) => i.type === 'savegame' && i.data === payload)).toBe(true); expect(got.items?.some((i) => i.type === 'savegame' && i.data === payload)).toBe(true);
await client.storage.delete('savegame'); await client.storage.delete('savegame');
const after = await client.storage.reload(); const after = await client.storage.reload();
expect(after.items?.some((i) => i.data === payload)).toBeFalsy(); expect(after.items?.some((i) => i.type === 'savegame')).toBeFalsy();
}); });
}); });
+48
View File
@@ -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<LeaderboardsSubmitError>('', '', {
code: 'leaderboard_not_found',
});
const code: LeaderboardsSubmitError = error.code;
expect(code).toBe('leaderboard_not_found');
});
});
+20 -34
View File
@@ -1,16 +1,8 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { RudderClient } from '../../src/client/RudderClient.js'; 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'; 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', () => { describe('E2E: player storage', () => {
let gateway: FakeGateway; let gateway: FakeGateway;
let client: RudderClient; let client: RudderClient;
@@ -18,7 +10,7 @@ describe('E2E: player storage', () => {
beforeEach(() => { beforeEach(() => {
gateway = createFakeGateway(); gateway = createFakeGateway();
gateway.install(); gateway.install();
client = makeClient(); client = createTestClient();
}); });
afterEach(() => vi.unstubAllGlobals()); afterEach(() => vi.unstubAllGlobals());
@@ -26,50 +18,44 @@ describe('E2E: player storage', () => {
it('saves items then reads them back (round-trip through the gateway)', async () => { it('saves items then reads them back (round-trip through the gateway)', async () => {
await client.auth.loginWithDevice(); await client.auth.loginWithDevice();
await client.storage.save([ await client.storage.save('progress', JSON.stringify({ level: 7, gold: 1200 }));
{ id: 'save_1', type: 'progress', data: JSON.stringify({ level: 7, gold: 1200 }) }, await client.storage.save('prefs', JSON.stringify({ sound: false }));
{ id: 'settings', type: 'prefs', data: JSON.stringify({ sound: false }) },
]);
const all = await client.storage.reload(); const all = await client.storage.reload();
expect(all.items).toHaveLength(2); 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 }); expect(JSON.parse(progress!.data!)).toEqual({ level: 7, gold: 1200 });
});
it('filters by type', async () => { const put = gateway.recorded.find((r) => r.method === 'PUT' && r.path === '/sdk/v1/storage');
await client.auth.loginWithDevice(); expect(put?.body).toEqual({ items: [{ type: 'progress', data: '{"level":7,"gold":1200}' }] });
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 getReq = gateway.recorded.findLast((r) => r.method === 'GET' && r.path === '/sdk/v1/storage'); const getReq = gateway.recorded.findLast((r) => r.method === 'GET' && r.path === '/sdk/v1/storage');
expect(getReq?.query.get('limit')).toBe('100'); 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.auth.loginWithDevice();
await client.storage.save([ await client.storage.save('progress', '1');
{ id: 'a', type: 'progress', data: '{}' }, await client.storage.save('progress', '2');
{ id: 'b', type: 'prefs', data: '{}' }, 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'); await client.storage.delete('progress');
const remaining = await client.storage.reload(); 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 () => { it('storage writes carry the auth token', async () => {
const token = (await client.auth.loginWithDevice()).accessToken; 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'); const put = gateway.recorded.find((r) => r.method === 'PUT' && r.path === '/sdk/v1/storage');
expect(put?.authToken).toBe(token); expect(put?.authToken).toBe(token);
+6
View File
@@ -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"}}}
+77
View File
@@ -0,0 +1,77 @@
// Code generated by rudder client generate. DO NOT EDIT.
//
// Modules:
// leaderboards@5.0.0
export interface RudderModuleCaller {
modules: {
call<T>(module: string, fn: string, args: unknown, options?: { moduleVersion?: string }): Promise<T>
}
}
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<LeaderboardsTopResultEntriesItem>
}
export type LeaderboardsTopError =
| RudderKernelError
export function modules(client: RudderModuleCaller) {
return {
leaderboards: {
submit: (args: LeaderboardsSubmitArgs) =>
client.modules.call<LeaderboardsSubmitResult>("leaderboards", "submit", args, { moduleVersion: "5.0.0" }),
top: (args: LeaderboardsTopArgs) =>
client.modules.call<LeaderboardsTopResult>("leaderboards", "top", args, { moduleVersion: "5.0.0" }),
},
}
}
+2 -2
View File
@@ -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 type { RudderClientOptions } from '../../src/client/RudderClientOptions.js';
import { createFakeTokenStore } from './FakeTokenStore.js'; import { createFakeTokenStore } from './FakeTokenStore.js';
/** Creates a RudderClient with test-friendly defaults and a fake token store. */ /** Creates a RudderClient with test-friendly defaults and a fake token store. */
export function createTestClient(overrides?: Partial<RudderClientOptions>): RudderClient { export function createTestClient(overrides?: Partial<RudderClientOptions>): RudderClient {
return new RudderClient({ return createClient({
baseUrl: 'https://api.test.rudder.build', baseUrl: 'https://api.test.rudder.build',
projectKey: 'test-project-key', projectKey: 'test-project-key',
tokenStore: createFakeTokenStore(), tokenStore: createFakeTokenStore(),
+9 -68
View File
@@ -1,9 +1,7 @@
import { vi } from 'vitest'; import { vi } from 'vitest';
import type { StorageItem } from '../../src/generated/storage.js'; 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 { CallModuleFunctionClientResponse } from '../../src/generated/modules.js';
import type { CatalogCurrency } from '../../src/generated/catalog.js'; import type { CatalogCurrency } from '../../src/generated/catalog.js';
import type { ProjectStorageItem } from '../../src/generated/project-storage.js';
export interface RecordedRequest { export interface RecordedRequest {
method: string; method: string;
@@ -11,14 +9,14 @@ export interface RecordedRequest {
query: URLSearchParams; query: URLSearchParams;
body: unknown; body: unknown;
authToken: string | null; authToken: string | null;
headers: Headers;
} }
export interface FakeGatewayState { export interface FakeGatewayState {
storage: Map<string, StorageItem>; storage: Map<string, StorageItem>;
projectStorage: Map<string, ProjectStorageItem>;
currencies: CatalogCurrency[]; currencies: CatalogCurrency[];
resources: Map<string, Map<string, ResourceRow>>; /** Response per `module/fn`; falls back to `{ result: {} }`. */
moduleCalls: CallModuleFunctionClientResponse; moduleCalls: Map<string, CallModuleFunctionClientResponse>;
} }
export interface FakeGateway { export interface FakeGateway {
@@ -39,10 +37,8 @@ const noContent = (): Response => new Response(null, { status: 204 });
export function createFakeGateway(seed?: Partial<FakeGatewayState>): FakeGateway { export function createFakeGateway(seed?: Partial<FakeGatewayState>): FakeGateway {
const state: FakeGatewayState = { const state: FakeGatewayState = {
storage: seed?.storage ?? new Map(), storage: seed?.storage ?? new Map(),
projectStorage: seed?.projectStorage ?? new Map(),
currencies: seed?.currencies ?? [], currencies: seed?.currencies ?? [],
resources: seed?.resources ?? new Map(), moduleCalls: seed?.moduleCalls ?? new Map(),
moduleCalls: seed?.moduleCalls ?? { result: {} },
}; };
const recorded: RecordedRequest[] = []; const recorded: RecordedRequest[] = [];
@@ -72,49 +68,7 @@ export function createFakeGateway(seed?: Partial<FakeGatewayState>): FakeGateway
const moduleMatch = path.match(/^\/sdk\/v1\/modules\/([^/]+)\/([^/]+)$/); const moduleMatch = path.match(/^\/sdk\/v1\/modules\/([^/]+)\/([^/]+)$/);
if (method === 'POST' && moduleMatch) { if (method === 'POST' && moduleMatch) {
return json(state.moduleCalls); return json(state.moduleCalls.get(`${moduleMatch[1]}/${moduleMatch[2]}`) ?? { result: {} });
}
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);
}
} }
if (path === '/sdk/v1/storage') { if (path === '/sdk/v1/storage') {
@@ -128,28 +82,14 @@ export function createFakeGateway(seed?: Partial<FakeGatewayState>): FakeGateway
if (method === 'PUT') { if (method === 'PUT') {
const items = (body as { items?: StorageItem[] }).items ?? []; const items = (body as { items?: StorageItem[] }).items ?? [];
for (const item of 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(); return noContent();
} }
if (method === 'DELETE') { if (method === 'DELETE') {
const type = query.get('type'); const type = query.get('type');
for (const [id, item] of state.storage) { if (type) state.storage.delete(type);
if (!type || item.type === type) state.storage.delete(id); else state.storage.clear();
}
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);
}
return noContent(); return noContent();
} }
} }
@@ -170,6 +110,7 @@ export function createFakeGateway(seed?: Partial<FakeGatewayState>): FakeGateway
query: url.searchParams, query: url.searchParams,
body: init?.body ? JSON.parse(init.body as string) : undefined, body: init?.body ? JSON.parse(init.body as string) : undefined,
authToken: auth ? auth.replace(/^Bearer\s+/, '') : null, authToken: auth ? auth.replace(/^Bearer\s+/, '') : null,
headers,
}; };
recorded.push(req); recorded.push(req);
return handle(req); return handle(req);