/** * Unified Widget Entry Point * This script combines prefetching bot data and initializing the widget * to simplify integration to a single script tag. */ import { captureStructuredError } from "./utils/sentry"; import { saveBotDataWithETag, loadBotDataWithETag, WidgetType, } from "./utils/session-storage"; interface BotDataResponse { data?: any; error?: string; } declare global { interface Window { moriBotData?: any; moriBotDataError?: string; moriBotDataPromise?: Promise; moriBotDataCached?: any; moriBotDataBackgroundValidation?: Promise; moriWidgetCleanup?: () => void; } } export {}; /** * Dispatch custom DOM events for bot data */ function dispatchBotDataEvent(eventName: string, detail: any): void { const event = new CustomEvent(eventName, { detail }); window.dispatchEvent(event); } /** * Extract sharing ID from script tag */ function getSharingIdFromScript(): string | null { const widgetScriptTag = document.querySelector( "script#mori-react-widget" ) as HTMLScriptElement | null; return widgetScriptTag?.getAttribute("data-sharing-id") || null; } /** * Fetch fresh bot data with optional ETag */ async function fetchFreshBotData( url: string, etag: string | null ): Promise<{ data: any; etag: string | null; status: number }> { const headers: Record = { Accept: "application/json", }; if (etag) { headers["If-None-Match"] = etag; } const response = await retryRequestPrefetch(async () => { return await fetchWithTimeoutPrefetch(url, headers, 30000); }); if (response.status === 304) { return { data: null, etag: null, status: 304 }; } if (response.status !== 200) { throw new Error(`Failed to fetch bot data: ${response.status}`); } const result: BotDataResponse = await response.json(); if (result.error) { throw new Error(result.error); } if (!result.data) { throw new Error("No data received from bot sharing API"); } return { data: result.data, etag: response.headers.get("ETag"), status: 200, }; } /** * Save bot data and ETag */ function saveBotDataAndETag( data: any, etag: string | null, widgetType: WidgetType, sharingId: string ): void { if (data && etag) { saveBotDataWithETag(data, etag, widgetType, sharingId); } } /** * Fetch with timeout support for prefetch */ async function fetchWithTimeoutPrefetch( url: string, headers: Record = {}, timeoutMs: number = 30000 ): Promise { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), timeoutMs); try { const response = await fetch(url, { signal: controller.signal, headers, }); clearTimeout(timeoutId); return response; } catch (error) { clearTimeout(timeoutId); throw error; } } /** * Retry request with exponential backoff for prefetch */ async function retryRequestPrefetch( fn: () => Promise, retries: number = 2, delays: number[] = [2000, 4000] ): Promise { let lastError: unknown; for (let attempt = 0; attempt < retries; attempt++) { try { return await fn(); } catch (error) { lastError = error; if (attempt === retries - 1) { throw lastError; } if (error instanceof Error && error.name === "AbortError") { throw error; } const delay = delays[attempt] || delays[delays.length - 1]; await new Promise((resolve) => setTimeout(resolve, delay)); } } throw lastError; } /** * Fetch and save fresh bot data (first load without ETag) */ async function fetchAndSaveFreshData( url: string, sharingId: string ): Promise { const { data, etag } = await fetchFreshBotData(url, null); // null = no ETag if (data) { saveBotDataAndETag(data, etag, WidgetType.FLOATING, sharingId); dispatchBotDataEvent("mori:bot-data-ready", data); return data; } throw new Error("Failed to fetch bot data"); } /** * Validate cache in background */ async function validateCacheInBackground( url: string, cachedEtag: string, sharingId: string ): Promise { try { const { data, etag: newEtag, status, } = await fetchFreshBotData(url, cachedEtag); if (status === 304) { // Cache valid, nothing to do return; } if (status === 200 && data) { // Cache stale - save new data and trigger remount saveBotDataAndETag(data, newEtag, WidgetType.FLOATING, sharingId); window.moriBotDataCached = data; window.moriBotDataPromise = Promise.resolve(data); triggerWidgetRemount(); } } catch (error) { // Network error - continue with cached data (widget already rendered) // Log to Sentry but don't throw captureStructuredError(error as Error, { operation: "validateCacheInBackground", request: { url, method: "GET" }, additionalData: { sharing_id: sharingId, has_cache: true }, }); } } /** * Trigger widget remount with fresh data */ function triggerWidgetRemount(): void { if (window.moriWidgetCleanup) { window.moriWidgetCleanup(); } setTimeout(() => { import("./main.tsx").then(({ initializeDoriWidget }) => { initializeDoriWidget().then((cleanup) => { window.moriWidgetCleanup = cleanup; }); }); }, 10); } /** * Common initialization and render logic */ async function initializeAndRender(): Promise { try { const { initializeDoriWidget } = await import("./main.tsx"); const cleanup = await initializeDoriWidget(); window.moriWidgetCleanup = cleanup; } catch (error) { console.error("[Mori Widget] Widget initialization failed:", error); captureStructuredError(error as Error, { operation: "initializeAndRender", request: { url: window.location.href, method: "GET", }, additionalData: { context: "dynamic_import", user_agent: navigator.userAgent, error_type: error instanceof Error ? error.constructor.name : "Unknown", }, }); } } // Dual-flow IIFE: renders immediately with cache, validates in background (async function initializeWidget() { const sharingId = getSharingIdFromScript(); if (!sharingId) { console.warn("[Mori Widget] No sharing ID found"); window.moriBotDataPromise = Promise.reject( new Error("No sharing ID found") ); window.moriBotDataPromise.catch(() => {}); return; } const url = `https://standardapi.dori.tech/bot-sharing-data/${sharingId}`; const { botData: cachedData, etag: cachedEtag } = loadBotDataWithETag( WidgetType.FLOATING, sharingId ); if (cachedData && cachedEtag) { // FLOW 2: Has cache - render immediately, validate in background window.moriBotDataCached = cachedData; window.moriBotDataPromise = Promise.resolve(cachedData); // Start background validation (don't await) window.moriBotDataBackgroundValidation = validateCacheInBackground( url, cachedEtag, sharingId ); await initializeAndRender(); } else { // FLOW 1: No cache - fetch WITHOUT ETag and wait window.moriBotDataPromise = fetchAndSaveFreshData(url, sharingId); window.moriBotDataPromise.catch(() => {}); await initializeAndRender(); } })();