feat!: @rudder/sdk 0.1.0: createClient, idempotent modules.call retries, storage save(type, data), drop resources and projectStorage
This commit is contained in:
+28
-29
@@ -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: <TResponse>(method: string, path: string, body?: unknown) =>
|
||||
request<TResponse>(method, path, body, this.transportContext),
|
||||
request: <TResponse>(
|
||||
method: string,
|
||||
path: string,
|
||||
body?: unknown,
|
||||
options?: RequestOptions,
|
||||
) => request<TResponse>(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<void> {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<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
@@ -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<TResponse>(method: string, path: string, body?: unknown): Promise<TResponse>;
|
||||
request<TResponse>(
|
||||
method: string,
|
||||
path: string,
|
||||
body?: unknown,
|
||||
options?: RequestOptions,
|
||||
): Promise<TResponse>;
|
||||
}
|
||||
|
||||
@@ -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)}`;
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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: <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<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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<PlayerProfile> {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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<GetStorageResponse> {
|
||||
@@ -15,13 +15,13 @@ export class StorageDomain extends SyncedState<GetStorageResponse> {
|
||||
super(() => api.getStorage(ctx, { limit: 100 }));
|
||||
}
|
||||
|
||||
/** Saves player storage items, then invalidates. */
|
||||
async save(items: StorageItem[]): Promise<void> {
|
||||
await api.updateStorage(this.ctx, { items });
|
||||
/** Upserts the item of `type` with raw string `data`, then invalidates. */
|
||||
async save(type: string, data: string): Promise<void> {
|
||||
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<void> {
|
||||
await api.deleteStorage(this.ctx, { type });
|
||||
this.invalidate();
|
||||
|
||||
@@ -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<CallModuleFunctionClientResponse> =>
|
||||
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: (
|
||||
t: Transport,
|
||||
query?: { type?: string },
|
||||
@@ -62,18 +47,6 @@ export const api = {
|
||||
): Promise<PlayerProfile> =>
|
||||
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: (
|
||||
t: Transport,
|
||||
): Promise<{ [key: string]: number }> =>
|
||||
@@ -108,13 +81,6 @@ export const api = {
|
||||
): Promise<ListCatalogCurrenciesResponse> =>
|
||||
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: (
|
||||
t: Transport,
|
||||
body: LoginViaAppleRequest,
|
||||
@@ -151,19 +117,6 @@ export const api = {
|
||||
): Promise<void> =>
|
||||
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: (
|
||||
t: Transport,
|
||||
body: UpdateStorageRequest,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -15,3 +15,10 @@ export interface ModuleErrorResponse {
|
||||
"requestId"?: string;
|
||||
}
|
||||
|
||||
export interface ResourceRowUsage {
|
||||
"definitionSlug"?: string;
|
||||
"field"?: string;
|
||||
"moduleSlug"?: string;
|
||||
"rowId"?: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ export interface Player {
|
||||
}
|
||||
|
||||
export interface PlayerProfile {
|
||||
"counters"?: { [key: string]: number };
|
||||
"identities"?: PlayerProfileIdentity[];
|
||||
"player"?: Player;
|
||||
"wallets"?: Wallet[];
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
@@ -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
@@ -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';
|
||||
|
||||
+11
-11
@@ -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<T> {
|
||||
status: SyncedStatus;
|
||||
data?: T;
|
||||
value?: T;
|
||||
error?: Error;
|
||||
}
|
||||
|
||||
@@ -27,8 +27,8 @@ export class SyncedState<T> {
|
||||
constructor(private readonly loader: () => Promise<T>) {}
|
||||
|
||||
/** 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<T> {
|
||||
/** Loads the entity if needed; deduplicates parallel loads. */
|
||||
load(): Promise<T> {
|
||||
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<T> {
|
||||
private startLoad(force: boolean): Promise<T> {
|
||||
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<T> {
|
||||
const previous = this.snapshot;
|
||||
if (
|
||||
previous.status === next.status &&
|
||||
previous.data === next.data &&
|
||||
previous.value === next.value &&
|
||||
previous.error === next.error
|
||||
) {
|
||||
return;
|
||||
|
||||
+43
-19
@@ -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<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 {
|
||||
baseUrl: string;
|
||||
tokenStore: TokenStore;
|
||||
@@ -34,7 +45,8 @@ export interface RequestContext {
|
||||
* - Injects `Authorization: Bearer <token>` 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<TResponse>(
|
||||
path: string,
|
||||
body: unknown | undefined,
|
||||
ctx: RequestContext,
|
||||
options: RequestOptions = {},
|
||||
): Promise<TResponse> {
|
||||
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<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(
|
||||
method: string,
|
||||
path: string,
|
||||
body: unknown | undefined,
|
||||
ctx: RequestContext,
|
||||
options: RequestOptions,
|
||||
): Promise<Response> {
|
||||
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<boolean> {
|
||||
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<string, string>,
|
||||
): Promise<Response> {
|
||||
const url = `${ctx.baseUrl.replace(/\/+$/, '')}${path}`;
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
...extraHeaders,
|
||||
};
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user