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
+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,
* 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);
}
+5 -4
View File
@@ -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;
}
+16
View File
@@ -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
View File
@@ -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>;
}
+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.
*
* 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 {
+52 -4
View File
@@ -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;
}
}
}
+2 -1
View File
@@ -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> {
-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 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();
-47
View File
@@ -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,
+2 -2
View File
@@ -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;
+2 -1
View File
@@ -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;
-2
View File
@@ -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';
+7
View File
@@ -15,3 +15,10 @@ export interface ModuleErrorResponse {
"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 {
"counters"?: { [key: string]: number };
"identities"?: PlayerProfileIdentity[];
"player"?: Player;
"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
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
View File
@@ -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
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.
*/
@@ -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));
}