UNPKG

@frak-labs/core-sdk

Version:

Core SDK of the Frak wallet, low level library to interact directly with the frak ecosystem.

482 lines (438 loc) 16.9 kB
import { createRpcClient, Deferred, FrakRpcError, type RpcClient, RpcErrorCodes, } from "@frak-labs/frame-connector"; import { OpenPanel } from "@openpanel/web"; import { getClientId } from "../config/clientId"; import { sdkConfigStore } from "../config/sdkConfigStore"; import { BACKUP_KEY } from "../constants"; import type { FrakLifecycleEvent } from "../types"; import type { FrakClient } from "../types/client"; import type { FrakWalletSdkConfig } from "../types/config"; import type { SdkResolvedConfig } from "../types/resolvedConfig"; import type { IFrameRpcSchema } from "../types/rpc"; import { clearAllCache } from "../utils/cache"; import { setupSsoUrlListener } from "./ssoUrlListener"; import { createIFrameLifecycleManager, type IframeLifecycleManager, } from "./transports/iframeLifecycleManager"; type SdkRpcClient = RpcClient<IFrameRpcSchema, FrakLifecycleEvent>; type MerchantConfigResult = Awaited<ReturnType<typeof sdkConfigStore.resolve>>; /** * Create a new iframe Frak client * @param args * @param args.config - The configuration to use for the Frak Wallet SDK. * When `config.domain` is set, it is used to resolve the correct merchant config in tunneled/proxied environments (e.g. Shopify dev with Cloudflare tunnel). * @param args.iframe - The iframe to use for the communication * @returns The created Frak Client * * @example * const frakConfig: FrakWalletSdkConfig = { * metadata: { * name: "My app title", * }, * } * const iframe = await createIframe({ config: frakConfig }); * const client = createIFrameFrakClient({ config: frakConfig, iframe }); */ export function createIFrameFrakClient({ config, iframe, }: { config: FrakWalletSdkConfig; iframe: HTMLIFrameElement; }): FrakClient { const frakWalletUrl = config?.walletUrl ?? "https://wallet.frak.id"; const browserLang = typeof navigator !== "undefined" ? navigator.language?.split("-")[0] : undefined; const detectedLang = config.metadata.lang ?? (browserLang === "en" || browserLang === "fr" ? browserLang : undefined); const targetDomain = config.domain ?? (typeof window !== "undefined" ? window.location.hostname : ""); sdkConfigStore.setCacheScope(targetDomain, detectedLang); sdkConfigStore.reset(); // Skip fetch entirely if cache is fresh, otherwise fetch (SWR) const configPromise = sdkConfigStore.isCacheFresh ? undefined : sdkConfigStore.resolve(config.domain, config.walletUrl, detectedLang); // Create lifecycle manager const lifecycleManager = createIFrameLifecycleManager({ iframe, targetOrigin: frakWalletUrl, }); // Resolved after first resolved-config is sent to iframe (prevents RPC before context exists) const contextSent = new Deferred<void>(); // Handshake timing: measured from client creation until the iframe // lifecycle manager resolves the `isConnected` promise. const handshakeStartedAt = Date.now(); // Validate iframe if (!iframe.contentWindow) { throw new FrakRpcError( RpcErrorCodes.configError, "The iframe does not have a content window" ); } // Create RPC client with middleware and lifecycle handlers const rpcClient = createRpcClient<IFrameRpcSchema, FrakLifecycleEvent>({ emittingTransport: iframe.contentWindow, listeningTransport: window, targetOrigin: frakWalletUrl, middleware: [ // Ensure we are connected and context is sent before sending request { async onRequest(_message, ctx) { const isConnected = await lifecycleManager.isConnected; if (!isConnected) { throw new FrakRpcError( RpcErrorCodes.clientNotConnected, "The iframe provider isn't connected yet" ); } await contextSent.promise; return ctx; }, }, ], // Add lifecycle handlers to process iframe lifecycle events lifecycleHandlers: { iframeLifecycle: (event, _context) => { // Delegate to lifecycle manager (cast for type compatibility) lifecycleManager.handleEvent(event); }, }, }); // Setup heartbeat const stopHeartbeat = setupHeartbeat(rpcClient, lifecycleManager); const destroy = async () => { stopHeartbeat(); rpcClient.cleanup(); iframe.remove(); clearAllCache(); sdkConfigStore.clearCache(); sdkConfigStore.reset(); }; // Init open panel let openPanel: OpenPanel | undefined; if ( process.env.OPEN_PANEL_API_URL && process.env.OPEN_PANEL_SDK_CLIENT_ID ) { console.log("[Frak SDK] Initializing OpenPanel"); openPanel = new OpenPanel({ apiUrl: process.env.OPEN_PANEL_API_URL, clientId: process.env.OPEN_PANEL_SDK_CLIENT_ID, trackScreenViews: true, trackOutgoingLinks: true, trackAttributes: false, // We use a filter to ensure we got the open panel instance initialized // A bit hacky, but this way we are sure that we got everything needed for the first event ever sent filter: ({ type, payload }) => { if (type !== "track") return true; if (!payload?.properties) return true; // Check if we we got the properties once initialized if (!("sdkVersion" in payload.properties)) { payload.properties = { ...payload.properties, sdkVersion: process.env.SDK_VERSION, userAnonymousClientId: getClientId(), }; } return true; }, }); openPanel.setGlobalProperties({ sdkVersion: process.env.SDK_VERSION, userAnonymousClientId: getClientId(), }); openPanel.init(); openPanel.track("sdk_initialized", { sdkVersion: process.env.SDK_VERSION, }); // Race the connection against the heartbeat timeout so we can // distinguish "connected" from "timeout" cleanly without touching // the heartbeat plumbing. 30s matches `HEARTBEAT_TIMEOUT`. let settled = false; const timeoutHandle = setTimeout(() => { if (settled) return; settled = true; openPanel?.track("sdk_iframe_handshake_failed", { reason: "timeout", }); }, 30_000); lifecycleManager.isConnected .then(() => { if (settled) return; settled = true; clearTimeout(timeoutHandle); openPanel?.track("sdk_iframe_connected", { handshake_duration_ms: Date.now() - handshakeStartedAt, }); }) .catch(() => { if (settled) return; settled = true; clearTimeout(timeoutHandle); openPanel?.track("sdk_iframe_handshake_failed", { reason: "unknown", }); }); } // Perform the post connection setup const waitForSetup = postConnectionSetup({ config, rpcClient, lifecycleManager, configPromise, contextSent, openPanel, }) .then(() => {}) .catch((err) => { contextSent.reject(err); throw err; }); return { config, waitForConnection: lifecycleManager.isConnected, waitForSetup, request: rpcClient.request, listenerRequest: rpcClient.listen, destroy, openPanel, }; } /** * Setup the heartbeat * @param rpcClient - RPC client to send lifecycle events * @param lifecycleManager - Lifecycle manager to track connection */ function setupHeartbeat( rpcClient: SdkRpcClient, lifecycleManager: IframeLifecycleManager ) { const HEARTBEAT_INTERVAL = 250; // Fallback discovery ping until we are connected const HEARTBEAT_TIMEOUT = 30_000; // 30 seconds timeout let heartbeatInterval: NodeJS.Timeout; let timeoutId: NodeJS.Timeout; const sendHeartbeat = () => rpcClient.sendLifecycle({ clientLifecycle: "heartbeat", }); // Start sending heartbeats async function startHeartbeat() { sendHeartbeat(); // Send initial heartbeat heartbeatInterval = setInterval(sendHeartbeat, HEARTBEAT_INTERVAL); // Set up timeout timeoutId = setTimeout(() => { stopHeartbeat(); console.log("Heartbeat timeout: connection failed"); }, HEARTBEAT_TIMEOUT); // Once connected, stop it await lifecycleManager.isConnected; // We are now connected, stop the heartbeat stopHeartbeat(); } // Stop sending heartbeats function stopHeartbeat() { if (heartbeatInterval) { clearInterval(heartbeatInterval); } if (timeoutId) { clearTimeout(timeoutId); } } startHeartbeat(); // Return cleanup function return stopHeartbeat; } /** * Perform the post connection setup * @param config - SDK configuration * @param rpcClient - RPC client to send lifecycle events * @param lifecycleManager - Lifecycle manager to track connection */ async function postConnectionSetup({ config, rpcClient, lifecycleManager, configPromise, contextSent, openPanel, }: { config: FrakWalletSdkConfig; rpcClient: SdkRpcClient; lifecycleManager: IframeLifecycleManager; configPromise: Promise<MerchantConfigResult> | undefined; contextSent: Deferred<void>; openPanel: OpenPanel | undefined; }): Promise<void> { await lifecycleManager.isConnected; setupSsoUrlListener(rpcClient, lifecycleManager.isConnected); // Read and consume the pending merge token from URL (SSO identity merge) const url = new URL(window.location.href); const pendingMergeToken = url.searchParams.get("fmt") ?? undefined; if (pendingMergeToken) { url.searchParams.delete("fmt"); window.history.replaceState({}, "", url.toString()); } // Merge a raw backend response with SDK metadata and persist to store const mergeAndSetConfig = (merchantConfig: MerchantConfigResult) => { const merchantId = merchantConfig?.merchantId ?? config.metadata.merchantId ?? ""; const domain = merchantConfig?.domain ?? ""; const allowedDomains = merchantConfig?.allowedDomains ?? []; const raw = merchantConfig?.sdkConfig; // Per-field merge: backend wins over SDK static config. const mergedAttribution = raw?.attribution || config.attribution ? { ...config.attribution, ...raw?.attribution } : undefined; sdkConfigStore.setConfig( raw ? { isResolved: true, merchantId, domain, allowedDomains, hasRawSdkConfig: true, name: raw.name ?? config.metadata.name, logoUrl: raw.logoUrl ?? config.metadata.logoUrl, homepageLink: raw.homepageLink ?? config.metadata.homepageLink, lang: raw.lang ?? config.metadata.lang, currency: raw.currency ?? config.metadata.currency, hidden: raw.hidden, css: raw.css, translations: raw.translations, placements: raw.placements, components: raw.components, attribution: mergedAttribution, } : { isResolved: true, merchantId, domain, allowedDomains, name: config.metadata.name, logoUrl: config.metadata.logoUrl, homepageLink: config.metadata.homepageLink, lang: config.metadata.lang, currency: config.metadata.currency, attribution: mergedAttribution, } ); }; // Send the resolved-config lifecycle event to the iframe. // This is where we also update SDK-side OpenPanel global props with // `merchantId` + `domain` (first time they are known) so every // subsequent SDK event is merchant-attributed. We pass // `sdkAnonymousId` through so the listener can join SDK funnels. let mergeTokenConsumed = false; const sendLifecycleConfig = (resolved: SdkResolvedConfig) => { const token = mergeTokenConsumed ? undefined : pendingMergeToken; mergeTokenConsumed = true; const sdkConfig = resolved.hasRawSdkConfig ? { name: resolved.name, logoUrl: resolved.logoUrl, homepageLink: resolved.homepageLink, lang: resolved.lang, currency: resolved.currency, hidden: resolved.hidden, css: resolved.css, translations: resolved.translations, placements: resolved.placements, attribution: resolved.attribution, } : resolved.attribution ? { attribution: resolved.attribution } : undefined; const sdkAnonymousId = getClientId(); if (openPanel) { const current = openPanel.global ?? {}; openPanel.setGlobalProperties({ ...current, merchantId: resolved.merchantId, domain: resolved.domain ?? "", }); } rpcClient.sendLifecycle({ clientLifecycle: "resolved-config", data: { merchantId: resolved.merchantId, domain: resolved.domain ?? "", allowedDomains: resolved.allowedDomains ?? [], sourceUrl: window.location.href, ...(sdkAnonymousId && { sdkAnonymousId }), ...(token && { pendingMergeToken: token }), ...(sdkConfig && { sdkConfig }), }, }); }; // SWR: if we have cached data, send it to the iframe immediately if (sdkConfigStore.isResolved) { sendLifecycleConfig(sdkConfigStore.getConfig()); contextSent.resolve(); } // If a fetch is running (stale/missing cache), wait for fresh data and update if (configPromise) { const merchantConfig = await configPromise; mergeAndSetConfig(merchantConfig); sendLifecycleConfig(sdkConfigStore.getConfig()); contextSent.resolve(); } // Push raw CSS if needed async function pushCss() { const cssLink = config.customizations?.css; if (!cssLink) return; rpcClient.sendLifecycle({ clientLifecycle: "modal-css" as const, data: { cssLink }, }); } // Push i18n if needed async function pushI18n() { const i18n = config.customizations?.i18n; if (!i18n) return; rpcClient.sendLifecycle({ clientLifecycle: "modal-i18n" as const, data: { i18n }, }); } // Push local backup if needed async function pushBackup() { if (typeof window === "undefined") return; const backup = window.localStorage.getItem(BACKUP_KEY); if (!backup) return; rpcClient.sendLifecycle({ clientLifecycle: "restore-backup" as const, data: { backup }, }); } // Inspect each setup result — a failed CSS/i18n/backup push leaves the // partner UI in a broken-but-connected state (iframe reports // `sdk_iframe_connected`, user sees no modal styles / wrong locale). // Surface it as a distinct handshake reason so dashboards can // distinguish timeout vs. asset-push failures. const results = await Promise.allSettled([ pushCss(), pushI18n(), pushBackup(), ]); const hasFailedAssetPush = results.some((r) => r.status === "rejected"); if (hasFailedAssetPush) { openPanel?.track("sdk_iframe_handshake_failed", { reason: "asset_push", }); } }