/** * @classic-homes/chat-widget * * High-performance embeddable chat widget built with Svelte. * Can be used as a Svelte component, Custom Element, or via framework adapters. */ // Import theme design tokens and chat-specific styles (bundled into widget for standalone use) import '@classic-homes/theme-tokens/css'; import '@classic-homes/chat-styles'; // Re-export everything from chat-core for convenience export * from '@classic-homes/chat-core'; // Widget-specific components import ChatWidget from './components/ChatWidget.svelte'; export { ChatWidget }; // Import types and utilities from core import { type ChatWidgetConfig, type ConversationPreview, type ConversationWithMessages, type ChatWidgetEventType, type ChatWidgetEventCallback, type ConversationRepository, LocalStorageRepository, ApiRepository, getAnonToken, ChatWidgetEventEmitter, STORAGE_KEY_PREFIX, initWidgetLogger, } from '@classic-homes/chat-core'; import { mount, unmount, type ComponentProps } from 'svelte'; // Internal props type that matches ChatWidget component type WidgetProps = ComponentProps; /** * Widget API interface returned by initChatWidget */ export interface ChatWidgetAPI { // === Existing Methods === /** Clear all messages and reset the chat */ clearMessages: () => void; /** Unmount the widget */ destroy: () => void; /** * Get the repository for conversation management * @deprecated Use the conversation management methods instead (listConversations, selectConversation, etc.) */ getRepository: () => ConversationRepository; /** Refresh the widget (reload conversations from storage) */ refresh: () => void; // === Conversation Management === /** Get all conversations, sorted by most recent first */ listConversations: () => Promise; /** Get a specific conversation with all its messages */ getConversation: (id: string) => Promise; /** Select/switch to a conversation */ selectConversation: (id: string) => Promise; /** Start a new conversation (clears current state) */ startNewConversation: () => Promise; /** Delete a conversation */ deleteConversation: (id: string) => Promise; /** Rename a conversation */ renameConversation: (id: string, title: string) => Promise; // === State Getters === /** Get the current active conversation ID (null if new chat) */ getCurrentConversationId: () => Promise; /** Get the current conversation title */ getCurrentTitle: () => Promise; // === Event System === /** * Subscribe to widget events * @returns Unsubscribe function */ on: ( event: T, callback: ChatWidgetEventCallback ) => () => void; /** * Subscribe to an event, automatically unsubscribe after first call * @returns Unsubscribe function */ once: ( event: T, callback: ChatWidgetEventCallback ) => () => void; /** Get the event emitter instance for advanced usage */ getEventEmitter: () => ChatWidgetEventEmitter; } // Only define and register the custom element in browser environment // This prevents "HTMLElement is not defined" errors during SSR if (typeof window !== 'undefined' && typeof HTMLElement !== 'undefined') { /** * Custom Element wrapper for the chat widget */ class ChatWidgetElement extends HTMLElement { private component: ReturnType | null = null; private remountScheduled = false; /** * Optional token provider for CHAPI account login. A function can't be an * HTML attribute, so the host sets it imperatively BEFORE the element * connects (or sets it then re-appends): `el.getAuthToken = () => token`. * Read in connectedCallback and threaded into the widget config. */ public getAuthToken?: () => string | null | Promise; connectedCallback() { // Get all attributes as props const props: Record = {}; // List of allowed prop names to prevent prototype pollution const allowedProps = new Set([ 'apiBase', 'site', 'siteName', 'title', 'logoUrl', 'primaryColor', 'fullscreen', 'welcomeMessage', 'welcomeTopics', 'disclaimerHtml', 'storagePrefix', 'sites', 'sentryDsn', 'environment', 'homeRid', ]); for (const attr of this.attributes) { // Convert kebab-case to camelCase const name = attr.name.replace(/-([a-z])/g, (_, c: string) => c.toUpperCase()); // Security: Only allow known prop names to prevent prototype pollution // Reject __proto__, constructor, prototype, etc. if (!allowedProps.has(name)) { continue; } // Parse special attributes if (name === 'welcomeTopics') { try { const parsed = JSON.parse(attr.value); // Validate it's an array of strings if (Array.isArray(parsed) && parsed.every((item) => typeof item === 'string')) { props[name] = parsed; } else { props[name] = []; } } catch { props[name] = []; } } else if (name === 'sites') { try { const parsed = JSON.parse(attr.value); // Validate it's an object with string values if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { // Sanitize: only allow string properties const sanitized: Record = {}; for (const [key, value] of Object.entries(parsed)) { if ( typeof key === 'string' && value && typeof value === 'object' && typeof (value as Record).name === 'string' && typeof (value as Record).url === 'string' ) { sanitized[key] = value as { name: string; url: string }; } } props[name] = sanitized; } } catch { // Skip invalid JSON } } else if (name === 'fullscreen') { props[name] = true; } else if (name === 'homeRid') { // Tour anchor (`home-rid="1234"`): positive integer or dropped. if (/^\d+$/.test(attr.value) && Number(attr.value) > 0) { props[name] = Number(attr.value); } } else { props[name] = attr.value; } } // Handle boolean attributes if (this.hasAttribute('fullscreen')) { props.fullscreen = true; } // Auth token provider is set imperatively (functions can't be attributes). if (typeof this.getAuthToken === 'function') { props.getAuthToken = this.getAuthToken; } // Mount Svelte component (props are built dynamically from attributes) this.component = mount(ChatWidget, { target: this, props: props as unknown as WidgetProps, }); } disconnectedCallback() { if (this.component) { unmount(this.component); this.component = null; } } // Observe attribute changes static get observedAttributes() { return ['api-base', 'site', 'site-name', 'title', 'primary-color', 'fullscreen']; } attributeChangedCallback(_name: string, oldValue: string, newValue: string) { if (oldValue === newValue || !this.component) return; // Coalesce multiple attribute changes in the same tick into a single // remount. There's no live prop-update path (Svelte `mount`), so we still // remount — but setting several attributes at once (e.g. title + // primary-color) now tears down and rebuilds once instead of N times. if (this.remountScheduled) return; this.remountScheduled = true; queueMicrotask(() => { this.remountScheduled = false; // Still connected? (attributes can change after removal) if (!this.isConnected) return; this.disconnectedCallback(); this.connectedCallback(); }); } } // Register custom element if (!customElements.get('chat-widget')) { customElements.define('chat-widget', ChatWidgetElement); } } /** * Programmatic initialization function * Only works in browser environment * * @returns ChatWidgetAPI with methods to interact with the widget */ export function initChatWidget( target: HTMLElement, config: ChatWidgetConfig ): ChatWidgetAPI | null { if (typeof window === 'undefined') { return null; } // Initialize logger with Sentry if configured initWidgetLogger({ sentryDsn: config.sentryDsn, environment: config.environment, apiBase: config.apiBase, }); const storagePrefix = config.storagePrefix || STORAGE_KEY_PREFIX; // Shared repository for conversation management. The SAME instance backs the // widget's imperative API (below) and the internal stores (passed as // `_repository`), so both read/write the same conversation data. // // Selection mirrors createChatStores(): when an API base + anonymous owner // token are present, use the server-authoritative ApiRepository (localStorage // as cache) so account history syncs across reloads/devices; otherwise fall // back to localStorage-only (SSR / privacy mode / no API base). Passing an // explicit `_repository` makes createChatStores use this instance rather than // constructing its own, so the one-time migrate/claim below is the single // owner of that side effect. const anonToken = getAnonToken(storagePrefix); let repository: ConversationRepository; if (config.apiBase && anonToken) { const cache = new LocalStorageRepository(storagePrefix); const apiRepo = new ApiRepository( config.apiBase, anonToken, cache, storagePrefix, config.getAuthToken ); // One-time, best-effort and flag-guarded: upload any existing local history, // then claim anonymous server history into the logged-in account. Claim runs // strictly AFTER migrate — a concurrent claim can complete (and set its // once-flag) before the import finishes, orphaning anything that lands under // the anon owner, and the two would race writes on the same KV owner index. void apiRepo.migrateLocalHistory().then(() => apiRepo.claimAnonHistory()); repository = apiRepo; } else { repository = new LocalStorageRepository(storagePrefix); } // Create event emitter for this widget instance const eventEmitter = new ChatWidgetEventEmitter(); // Props include config plus the shared repository and event emitter const props = { ...config, _repository: repository, _eventEmitter: eventEmitter, }; let component = mount(ChatWidget, { target, props: props as WidgetProps, }); // Track remount state to prevent race conditions let isRemounting = false; let pendingRemount = false; /** * Helper to remount the component * * Uses a mutex pattern to prevent race conditions when multiple * remount requests come in rapid succession. */ const remount = async () => { // If already remounting, queue another remount for after if (isRemounting) { pendingRemount = true; return; } isRemounting = true; try { unmount(component); component = mount(ChatWidget, { target, props: props as WidgetProps, }); // Allow DOM to settle before processing pending remounts await new Promise((resolve) => requestAnimationFrame(resolve)); } finally { isRemounting = false; // Process pending remount if one was queued if (pendingRemount) { pendingRemount = false; remount(); } } }; // Emit ready event after mount setTimeout(() => { eventEmitter.emit('ready', undefined); }, 0); return { // === Existing Methods === clearMessages: () => { // Remount the component to reset state (it will start a new conversation) remount(); }, destroy: () => { eventEmitter.off(); // Clean up all listeners unmount(component); }, getRepository: () => repository, refresh: () => { // Remount to refresh state from storage remount(); }, // === Conversation Management === listConversations: async () => { const conversations = await repository.listConversations(); return conversations; }, getConversation: async (id: string) => { return repository.getConversation(id); }, selectConversation: async (id: string) => { const conversation = await repository.getConversation(id); if (conversation) { await repository.setActiveConversationId(id); remount(); eventEmitter.emit('conversation:selected', { id, title: conversation.conversation.title, }); } }, startNewConversation: async () => { await repository.setActiveConversationId(null); remount(); eventEmitter.emit('conversation:selected', { id: null, title: null, }); }, deleteConversation: async (id: string) => { const activeId = await repository.getActiveConversationId(); await repository.deleteConversation(id); // If we deleted the active conversation, clear it if (activeId === id) { await repository.setActiveConversationId(null); remount(); } eventEmitter.emit('conversation:deleted', { id }); // Also emit updated list const conversations = await repository.listConversations(); eventEmitter.emit('conversations:updated', { conversations }); }, renameConversation: async (id: string, title: string) => { await repository.updateConversation(id, { title }); eventEmitter.emit('conversation:titleChanged', { id, title }); // Also emit updated list const conversations = await repository.listConversations(); eventEmitter.emit('conversations:updated', { conversations }); }, // === State Getters === getCurrentConversationId: async () => { return repository.getActiveConversationId(); }, getCurrentTitle: async () => { const activeId = await repository.getActiveConversationId(); if (!activeId) return null; const conversation = await repository.getConversation(activeId); return conversation?.conversation.title ?? null; }, // === Event System === on: ( event: T, callback: ChatWidgetEventCallback ) => { return eventEmitter.on(event, callback); }, once: ( event: T, callback: ChatWidgetEventCallback ) => { return eventEmitter.once(event, callback); }, getEventEmitter: () => eventEmitter, }; } // Version export export const version = '0.1.0';