import { ChannelEvents } from "@local-logic/channel"; import { StateManager } from "../StateManager"; import { SDKKeys, On, Options } from "../types"; type ResizePayload = { scrollHeight: number; scrollWidth: number; }; /** * Used to determine the state of a Promise. */ function promiseState( p: Promise, ): Promise<"pending" | "fulfilled" | "rejected"> { const t = {}; return Promise.race([p, t]).then( (v) => (v === t ? "pending" : "fulfilled"), () => "rejected", ); } class SDK { private options: Options; public stateManager?: StateManager, On["change"]>; private renderedElement?: HTMLIFrameElement; private renderTo: HTMLElement; private lazyInterval?: ReturnType; // Build ID can be used to point to a specific build for QA purposes. // Currently this should correspond to a PR number in staging (qa), or "v1" otherwise private sdkBuildId: string; public hasMounted: Promise; private hasMountedResolve!: (value: void | PromiseLike) => void; private hasMountedReject!: (reason?: unknown) => void; private renderHandler?: () => Promise; constructor( elementType: T, renderTo: HTMLElement, options: Options, renderOptions: { lazy?: boolean; buildId?: string } = { lazy: true, buildId: "v1", }, ) { this.options = options; this.renderTo = renderTo; this.sdkBuildId = renderOptions.buildId || "v1"; this.hasMounted = new Promise((resolve, reject) => { this.hasMountedResolve = resolve; this.hasMountedReject = reject; }); this.renderTo.style.position = "relative"; /** * If lazy is true, only mount when scrolled into view. If lazy is false, * just mount immediately. */ if (renderOptions.lazy) { this.renderHandler = async () => { const state = await promiseState(this.hasMounted); if (this.isInViewport() && state === "pending") { this.renderedElement = this.mount(elementType, renderTo); clearInterval(this.lazyInterval); } }; /** * Call once to check immediately without waiting for interval. */ this.renderHandler(); /** * We check for element presence on interval and not scroll event. This is * because scroll events can occur on nested elements (not just the * document). If we wanted to get rid of the setInterval, we would * therefore need to make the scroll container configurable, which * complicates the public API. */ this.lazyInterval = setInterval(this.renderHandler, 200); } else { this.renderedElement = this.mount(elementType, renderTo); } } public async update(options: Options["options"]) { await this.hasMounted; this.stateManager?.setState((prev) => ({ ...prev, options: { ...prev.options, ...options, }, })); } public async on>(eventType: U, callback: On[U]) { await this.hasMounted; if (eventType === "change") { this.stateManager?.onChange(callback); } } public async destroy() { /** * This method needs to be async and await this.hasMounted, otherwise it * could run synchronously before the constructor has initialized. */ await promiseState(this.hasMounted); this.hasMountedReject("SDK has already been destroyed."); if (this.renderHandler) { document.removeEventListener("scroll", this.renderHandler); } this.stateManager?.destroy(); this.renderedElement?.remove(); } private mount(elementType: T, renderTo: HTMLElement) { this.hasMountedResolve(); const environment = this.options.globalOptions?.environment ?.sdk as keyof typeof targetOriginMap; if ( typeof this.options.globalOptions?.environment?.sdk !== "undefined" && typeof environment === "undefined" ) { console.warn( "Invalid SDK environment defined. Using production instead.", ); } const channelId = `chn-${Math.random() * 100}`; const targetOriginMap = { dev: "http://localhost:3001", qa: "https://staging.locallogic.co", prod: "https://sdk.locallogic.co", }; let targetOrigin = targetOriginMap.prod; if (Object.keys(targetOriginMap).includes(environment)) { targetOrigin = targetOriginMap[environment]; } const parentOrigin = encodeURIComponent(window.location.origin); const path = `sdks-app/${this.sdkBuildId}/${String(elementType)}?channelId=${channelId}&parentOrigin=${parentOrigin}`; const src = `${targetOrigin}/${path}`; const iframe = document.createElement("iframe"); iframe.src = src; // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore-next-line iframe["data-testid"] = "locallogic-sdk-iframe"; iframe.name = "Local Logic SDK"; iframe.title = "Local Logic SDK"; iframe.style.cssText = ` width: 100%; height: 100%; border: 0; `; renderTo.appendChild(iframe); const { contentWindow } = iframe; this.stateManager = new StateManager(this.options, channelId, { targetOrigin, window: contentWindow!, location: window?.location, }); this.handleResize(iframe); return iframe; } private isInViewport() { const rect = this.renderTo.getBoundingClientRect(); return ( (window.innerHeight || document.documentElement.clientHeight) - rect.top >= -350 ); } private handleResize(iframe: HTMLIFrameElement) { this.stateManager?.leaderNode.onMessage((e) => { if (e.name === ChannelEvents.RESIZE) { /** * Assign iframe to const to avoid eslint no-param-reassign error */ const iframeEl = iframe; iframeEl.style.cssText = `height: ${ (e.data as ResizePayload).scrollHeight }px !important; width: 100%; border: 0;`; } }); } } export default SDK;