@frak-labs/core-sdk
Version:
Core SDK of the Frak wallet, low level library to interact directly with the frak ecosystem.
264 lines (220 loc) • 7.9 kB
text/typescript
/**
* SDK config store — reactive singleton for the resolved merchant config.
*
* State lives directly on `window.__frakSdkConfig`.
* Reactivity is handled via the `frak:config` CustomEvent on `window`.
* Resolved configs are cached in localStorage (30 s TTL, stale-while-revalidate).
*
* Backend fetch responses are cached and deduplicated via `withCache`.
* Also owns the `frak-merchant-id` sessionStorage compatibility key.
*/
import type { Language } from "../types/config";
import type {
MerchantConfigResponse,
SdkResolvedConfig,
} from "../types/resolvedConfig";
import { clearAllCache, withCache } from "../utils/cache";
import { getBackendUrl } from "./backendUrl";
const GLOBAL_KEY = "__frakSdkConfig";
const CACHE_TTL = 30_000; // 30 seconds
const DEFAULT_CACHE_KEY = "frak-config-cache";
const MERCHANT_ID_KEY = "frak-merchant-id";
const cacheState = { key: DEFAULT_CACHE_KEY };
const isBrowser = typeof window !== "undefined";
type CacheEntry = { config: SdkResolvedConfig; timestamp: number };
declare global {
interface Window {
[GLOBAL_KEY]?: SdkResolvedConfig;
}
interface WindowEventMap {
"frak:config": CustomEvent<SdkResolvedConfig>;
}
}
function freshEmptyConfig(): SdkResolvedConfig {
return { isResolved: false, merchantId: "" };
}
// ---------------------------------------------------------------------------
// localStorage cache (with in-memory parsed copy)
// ---------------------------------------------------------------------------
let memoryEntry: CacheEntry | null = null;
function loadCacheEntry(): CacheEntry | null {
if (!isBrowser) return null;
try {
const raw = localStorage.getItem(cacheState.key);
if (!raw) return null;
const entry: CacheEntry = JSON.parse(raw);
if (!entry.config?.isResolved) return null;
memoryEntry = entry;
return entry;
} catch {
return null;
}
}
function readCache(): SdkResolvedConfig | undefined {
return (memoryEntry ?? loadCacheEntry())?.config;
}
function isCacheFresh(): boolean {
const entry = memoryEntry ?? loadCacheEntry();
if (!entry) return false;
return Date.now() - entry.timestamp < CACHE_TTL;
}
function writeCache(config: SdkResolvedConfig): void {
if (!isBrowser || !config.isResolved) return;
try {
const entry: CacheEntry = { config, timestamp: Date.now() };
localStorage.setItem(cacheState.key, JSON.stringify(entry));
memoryEntry = entry;
} catch {}
}
function removeCache(): void {
if (!isBrowser) return;
memoryEntry = null;
try {
localStorage.removeItem(cacheState.key);
} catch {}
}
// ---------------------------------------------------------------------------
// Initialise window-backed config (once per bundle boundary)
// ---------------------------------------------------------------------------
function initConfig(): void {
if (!isBrowser) return;
if (window[GLOBAL_KEY]) return;
window[GLOBAL_KEY] = readCache() ?? freshEmptyConfig();
}
initConfig();
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function getConfig(): SdkResolvedConfig {
if (!isBrowser) return freshEmptyConfig();
return window[GLOBAL_KEY] ?? freshEmptyConfig();
}
function dispatch(config: SdkResolvedConfig): void {
if (!isBrowser) return;
window.dispatchEvent(new CustomEvent("frak:config", { detail: config }));
}
function getTargetDomain(domain?: string): string {
return domain ?? (isBrowser ? window.location.hostname : "");
}
// ---------------------------------------------------------------------------
// Merchant config fetching (resolve)
// ---------------------------------------------------------------------------
async function fetchFromBackend(
targetDomain: string,
walletUrl?: string,
lang?: Language
): Promise<MerchantConfigResponse | undefined> {
try {
const backendUrl = getBackendUrl(walletUrl);
const langParam = lang ? `&lang=${encodeURIComponent(lang)}` : "";
const response = await fetch(
`${backendUrl}/user/merchant/resolve?domain=${encodeURIComponent(targetDomain)}${langParam}`
);
if (!response.ok) {
console.warn(
`[Frak SDK] Merchant lookup failed for domain ${targetDomain}: ${response.status}`
);
return undefined;
}
const data = (await response.json()) as MerchantConfigResponse;
// Write compatibility sessionStorage key
if (isBrowser) {
try {
sessionStorage.setItem(MERCHANT_ID_KEY, data.merchantId);
} catch {}
}
return data;
} catch (error) {
console.warn("[Frak SDK] Failed to fetch merchant config:", error);
return undefined;
}
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
export const sdkConfigStore = {
getConfig,
get isResolved(): boolean {
return getConfig().isResolved;
},
get isCacheFresh(): boolean {
return isCacheFresh();
},
setCacheScope(domain: string, lang?: string): void {
const suffix = `${domain}:${lang ?? ""}`;
cacheState.key = `${DEFAULT_CACHE_KEY}:${suffix}`;
memoryEntry = null;
},
setConfig(config: SdkResolvedConfig): void {
if (isBrowser) window[GLOBAL_KEY] = config;
writeCache(config);
dispatch(config);
// Keep sessionStorage merchantId in sync
if (isBrowser && config.merchantId) {
try {
sessionStorage.setItem(MERCHANT_ID_KEY, config.merchantId);
} catch {}
}
},
reset(): void {
const next = readCache() ?? freshEmptyConfig();
if (isBrowser) window[GLOBAL_KEY] = next;
dispatch(next);
},
clearCache(): void {
removeCache();
clearAllCache();
if (isBrowser) {
try {
sessionStorage.removeItem(MERCHANT_ID_KEY);
} catch {}
}
},
resolve(
domain?: string,
walletUrl?: string,
lang?: Language
): Promise<MerchantConfigResponse | undefined> {
const targetDomain = getTargetDomain(domain);
if (!targetDomain) {
return Promise.resolve(undefined);
}
const cacheKey = `sdkConfig:${targetDomain}:${lang ?? ""}`;
return withCache(
async () => {
const result = await fetchFromBackend(
targetDomain,
walletUrl,
lang
);
// Throw on failure so withCache doesn't cache undefined
if (!result) {
throw new Error("Config resolution returned empty");
}
return result;
},
{ cacheKey, cacheTime: Number.POSITIVE_INFINITY }
).catch(() => undefined);
},
getMerchantId(): string | undefined {
const config = getConfig();
if (config.isResolved && config.merchantId) {
return config.merchantId;
}
if (isBrowser) {
try {
return sessionStorage.getItem(MERCHANT_ID_KEY) ?? undefined;
} catch {}
}
return undefined;
},
async resolveMerchantId(
domain?: string,
walletUrl?: string
): Promise<string | undefined> {
const fast = sdkConfigStore.getMerchantId();
if (fast) return fast;
const config = await sdkConfigStore.resolve(domain, walletUrl);
return config?.merchantId;
},
};