diff --git a/package.json b/package.json index cb04968ac..9d39ab8e3 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,11 @@ "require": "./cjs/src/react/all-types-export.js", "types": "./lib/src/react/all-types-export.d.ts" }, + "./contracts": { + "import": "./lib/src/contracts/index.js", + "require": "./cjs/src/contracts/index.js", + "types": "./lib/src/contracts/index.d.ts" + }, "./lib/src/react": { "import": "./lib/src/react/all-types-export.js", "require": "./cjs/src/react/all-types-export.js", @@ -32,6 +37,9 @@ "*": { "react": [ "./lib/src/react/all-types-export.d.ts" + ], + "contracts": [ + "./lib/src/contracts/index.d.ts" ] } }, diff --git a/src/contracts/contracts.spec.ts b/src/contracts/contracts.spec.ts new file mode 100644 index 000000000..b30d4b746 --- /dev/null +++ b/src/contracts/contracts.spec.ts @@ -0,0 +1,260 @@ +/** + * Contract drift guardrails. + * + * These tests are the CI gate for the additive-only contract policy: + * - The explicit wire-value lists below record which events have TYPED + * contracts. Removing an event from the typed maps (or renaming its wire + * value) fails the equality check and must be treated as a breaking change, + * not a refactor. + * - Type-level assertions verify the request/response resolution helpers + * keep resolving typed events to their contracts and unknown events to + * `any` (backward compatibility). + * + * When a list check fails: if you ADDED an event, add its wire value to the + * expected list here. If an existing entry disappeared or changed value, + * stop — that breaks published SDK consumers and the host runtime validation + * derived from these contracts. + */ +import { HostEvent, EmbedEvent } from '../types'; +import { UIPassthroughEvent } from './ui-passthrough-contracts'; +import type { + HostEventRequest, + HostEventResponse, + NavigateRequest, + SetActiveTabRequest, +} from './host-event-contracts'; +import type { CustomActionPayload, RuntimeFilter } from '../types'; +import type { + CustomActionEventPayload, + EmbedEventData, + EmbedEventPayload, +} from './embed-event-payloads'; + +// Events with explicitly typed contracts in HostEventContractExtension. +// Keep in sync with the interface — this list is what the snapshot locks. +const TYPED_HOST_EVENTS: HostEvent[] = [ + // Filters and parameters + HostEvent.UpdateRuntimeFilters, + HostEvent.UpdateParameters, + HostEvent.UpdateFilters, + HostEvent.UpdateCrossFilter, + HostEvent.OpenFilter, + HostEvent.OpenParameter, + // Tabs and vizs + HostEvent.SetVisibleVizs, + HostEvent.SetVisibleTabs, + HostEvent.SetHiddenTabs, + HostEvent.SetActiveTab, + // Navigation + HostEvent.Navigate, + // Search and columns + HostEvent.Search, + HostEvent.ResetSearch, + HostEvent.AddColumns, + HostEvent.RemoveColumn, + // Viz-scoped actions + HostEvent.Edit, + HostEvent.Save, + HostEvent.Delete, + HostEvent.Share, + HostEvent.Present, + HostEvent.CopyLink, + HostEvent.ExportTML, + HostEvent.EditTML, + HostEvent.UpdateTML, + HostEvent.SchedulesList, + HostEvent.Schedule, + HostEvent.SpotIQAnalyze, + HostEvent.ShowUnderlyingData, + HostEvent.CreateMonitor, + HostEvent.ManageMonitor, + HostEvent.SyncToSheets, + HostEvent.SyncToOtherApps, + HostEvent.ManagePipelines, + HostEvent.DownloadAsPng, + HostEvent.DownloadAsCsv, + HostEvent.DownloadAsXlsx, + HostEvent.DownloadAsPdf, + HostEvent.Explore, + HostEvent.AskSage, + HostEvent.AskSpotter, + HostEvent.AnswerChartSwitcher, + // Liveboard + HostEvent.UpdatePersonalisedView, + HostEvent.SelectPersonalizedView, + HostEvent.ResetLiveboardPersonalisedView, + HostEvent.AIHighlights, + HostEvent.SendTestScheduleEmail, + HostEvent.RefreshLiveboardBrowserCache, + // Spotter + HostEvent.SpotterSearch, + HostEvent.ResetSpotterConversation, + HostEvent.ShareSpotterConversation, + HostEvent.CloseSpotterShareConversation, + HostEvent.ExitSpotterSharedConversation, + HostEvent.PinSpotterConversation, + HostEvent.UnpinSpotterConversation, + HostEvent.EditLastPrompt, + HostEvent.DeleteLastPrompt, + HostEvent.PreviewSpotterData, + HostEvent.SpotterVizSendUserMessage, + HostEvent.InitSpotterVizConversation, + HostEvent.OpenSpotterVizPanel, + HostEvent.CloseSpotterVizPanel, +]; + +// Type-level assertions: compile failures here mean contract resolution +// regressed. `expectType` is erased at runtime. +const expectType = (value: T): T => value; + +describe('event contracts (drift guardrails)', () => { + // The lists below LOCK the set of typed events. Adding an event is an + // intentional, additive edit here; a removed/renamed wire value fails this + // test — which is the signal that it is a breaking change, not a refactor. + test('typed host event wire values are stable (additive-only)', () => { + expect(TYPED_HOST_EVENTS.map((event) => `${event}`).sort()).toEqual([ + 'AIHighlights', + 'AskSage', + 'AskSpotter', + 'CloseSpotterShareConversation', + 'CloseSpotterVizPanel', + 'DeleteLastPrompt', + 'EditLastPrompt', + 'ExitSpotterSharedConversation', + 'InitSpotterVizConversation', + 'Navigate', + 'OpenSpotterVizPanel', + 'PinSpotterConversation', + 'PreviewSpotterData', + 'ResetLiveboardPersonalisedView', + 'ResetSpotterConversation', + 'SelectPersonalisedView', + 'SetActiveTab', + 'SetPinboardHiddenTabs', + 'SetPinboardVisibleTabs', + 'SetPinboardVisibleVizs', + 'ShareSpotterConversation', + 'SpotterSearch', + 'SpotterVizSendUserMessage', + 'UnpinSpotterConversation', + 'UpdateCrossFilter', + 'UpdateParameters', + 'UpdatePersonalisedView', + 'UpdateRuntimeFilters', + 'addColumns', + 'answerChartSwitcher', + 'createMonitor', + 'downloadAsCSV', + 'downloadAsPdf', + 'downloadAsPng', + 'downloadAsXLSX', + 'edit', + 'editTSL', + 'embedDocument', + 'explore', + 'exportTSL', + 'manage-pipeline', + 'manageMonitor', + 'onDeleteAnswer', + 'openFilter', + 'openParameter', + 'present', + 'refreshLiveboardBrowserCache', + 'removeColumn', + 'resetSearch', + 'save', + 'schedule-list', + 'search', + 'sendTestScheduleEmail', + 'share', + 'showUnderlyingData', + 'spotIQAnalyze', + 'subscription', + 'sync-to-other-apps', + 'sync-to-sheets', + 'updateFilters', + 'updateTSL', + ]); + }); + + test('UI passthrough wire values are stable (additive-only)', () => { + expect(Object.values(UIPassthroughEvent).sort()).toEqual([ + 'addVizToPinboard', + 'drillDown', + 'getAnswerPageConfig', + 'getAnswerSession', + 'getAvailableUiPassthroughs', + 'getDiscoverabilityStatus', + 'getExportRequestForCurrentPinboard', + 'getFilters', + 'getGroups', + 'getIframeUrl', + 'getParameters', + 'getPinboardPageConfig', + 'getTML', + 'getTabs', + 'getUnsavedAnswerTML', + 'saveAnswer', + 'updateFilters', + ]); + }); + + test('every typed host event is a real HostEvent member', () => { + const allHostEventValues = new Set(Object.values(HostEvent)); + TYPED_HOST_EVENTS.forEach((event) => { + expect(allHostEventValues.has(event)).toBe(true); + }); + }); + + test('contract resolution helpers compile against the typed map', () => { + // Typed event resolves to its contract type. + expectType( + (undefined as unknown) as HostEventRequest, + ); + expectType( + (undefined as unknown) as HostEventRequest, + ); + expectType( + (undefined as unknown) as HostEventRequest, + ); + // Untyped event stays `any` (backward compatible). + const untyped: HostEventRequest = { anything: 'goes' }; + expect(untyped).toBeDefined(); + // Response helper resolves for UI-passthrough-backed events. + expectType>( + (undefined as unknown) as HostEventResponse, + ); + }); + + test('embed event enum wire values referenced by typed payloads are stable', () => { + expect( + [ + EmbedEvent.AuthInit, + EmbedEvent.EmbedListenerReady, + EmbedEvent.CustomAction, + ].map((e) => `${e}`).sort(), + ).toEqual([ + 'EmbedListenerReady', + 'authInit', + 'customAction', + ]); + }); + + test('CustomAction resolves to its typed payload; answerService on the dedicated type', () => { + // Wire data resolves to CustomActionPayload. + expectType( + (undefined as unknown) as EmbedEventData, + ); + // payload.data.id is a typed string (type-level indexed access). + expectType( + (undefined as unknown) as EmbedEventPayload['data']['id'], + ); + // The dedicated type carries the SDK-added answerService (optional). + type AnswerServiceField = CustomActionEventPayload['answerService']; + const svc: AnswerServiceField = undefined; + expect(svc).toBeUndefined(); + // Untyped embed event stays `any` (backward compatible). + const untyped: EmbedEventData = { anything: 'goes' }; + expect(untyped).toBeDefined(); + }); +}); diff --git a/src/contracts/embed-event-payloads.ts b/src/contracts/embed-event-payloads.ts new file mode 100644 index 000000000..a0162e040 --- /dev/null +++ b/src/contracts/embed-event-payloads.ts @@ -0,0 +1,83 @@ +/** + * Copyright (c) 2026 + * + * Typed payload contracts for {@link EmbedEvent}s (ThoughtSpot app -> SDK). + * + * The SDK delivers embed events to `embed.on()` callbacks wrapped in the + * {@link MessagePayload} envelope `{ type, data, status? }`. The map below + * types the `data` field per event. Events absent from the map resolve to + * `any` until their payload is audited against what the host actually sends + * (codify observed behavior, not documented behavior). + * + * Same additive-only evolution rules as host-event-contracts.ts. + * @module contracts + */ +import type { CustomActionPayload, EmbedEvent, MessagePayload } from '../types'; +// Type-only: AnswerService is an SDK-side enrichment (see +// EmbedEventEnvelopeExtension). Type-only import keeps the contracts subpath +// free of any runtime dependency on the answer-service implementation. +import type { AnswerService } from '../utils/graphql/answerService/answerService'; + +/** + * Typed `data` field per embed event. Seed with audited events only. + */ +export interface EmbedEventDataExtension { + [EmbedEvent.AuthInit]: { + /** + * Whether authentication was successful. + */ + isLoggedIn?: boolean; + [key: string]: any; + }; + [EmbedEvent.EmbedListenerReady]: Record; + /** + * Fired when a callback-type custom action is triggered. Use + * `payload.data.id` to identify which action fired. + */ + [EmbedEvent.CustomAction]: CustomActionPayload; +} + +/** + * Resolves the typed `data` payload for an embed event; `any` when the + * event has not been audited/typed yet. + */ +export type EmbedEventData = + EmbedEventT extends keyof EmbedEventDataExtension + ? EmbedEventDataExtension[EmbedEventT] + : any; + +/** + * Full envelope delivered to `embed.on()` callbacks for a given event: + * the wire fields (`type`, `status`) plus the typed `data`. + */ +export type EmbedEventPayload = + Omit & { + data: EmbedEventData; + }; + +/** + * Payload delivered to `on(EmbedEvent.CustomAction)`. Beyond the wire fields, + * the SDK attaches an {@link AnswerService} built from the event's session and + * answer data (see utils/processData.ts `processCustomAction`) — annotate your + * callback with this type to access it: + * + * ```ts + * embed.on(EmbedEvent.CustomAction, (payload: CustomActionEventPayload) => { + * if (payload.data.id === 'my-action') { + * payload.answerService?.getUnderlyingDataForPoint([]); + * } + * }); + * ``` + * + * It is a dedicated type rather than folded into `on()`'s generic callback + * because the SDK's event registry types callbacks as the plain + * {@link MessagePayload}, which the enrichment cannot be intersected into + * without restructuring that registry. + */ +export type CustomActionEventPayload = + EmbedEventPayload & { answerService?: AnswerService }; + +/** + * String-name keyed view for the host side (addresses events by wire value). + */ +export type EmbedEventName = `${EmbedEvent}`; diff --git a/src/contracts/host-event-contracts.ts b/src/contracts/host-event-contracts.ts new file mode 100644 index 000000000..c67bb73e6 --- /dev/null +++ b/src/contracts/host-event-contracts.ts @@ -0,0 +1,380 @@ +/** + * Copyright (c) 2026 + * + * Typed contracts for {@link HostEvent} requests and responses. + * + * This module is the single source of truth for host event payload shapes. + * It is published under the `@thoughtspot/visual-embed-sdk/contracts` subpath + * so the ThoughtSpot app (host) can consume the exact same contract types the + * SDK compiles against, preventing SDK <-> host contract drift. + * + * Contract evolution rules (enforced by contracts.spec.ts snapshot): + * - Additive only: new events and new OPTIONAL fields may be added. + * - Never remove or rename an event key, or change an existing field's type. + * - An event absent from the maps below intentionally resolves to `any` + * (untyped, backward compatible) until it is audited and added. + * @module contracts + */ +import type { + ContextType, + HostEvent, + RuntimeFilter, + RuntimeParameter, +} from '../types'; +import type { + Applicability, + EmbedApiHostEventMapping, + UIPassthroughContractBase, + UIPassthroughRequest, + UIPassthroughResponse, +} from './ui-passthrough-contracts'; + +/** + * Shorthand for a contract entry whose response shape is not formally + * specified yet. Tightening a response later is additive for readers; + * never loosen an already-typed response. + */ +type ContractEntryOf = { request: RequestT; response: any }; + +/** + * Request for host events that MAY target a specific visualization. + * Omitting vizId targets the current answer/liveboard as a whole. + * (In some contexts, e.g. Spotter, the app requires vizId at runtime.) + */ +export interface VizScopedRequest { + vizId?: string; +} + +/** + * Request for host events that MUST target a specific visualization. + */ +export interface RequiredVizRequest { + vizId: string; +} + +/** + * Request for Spotter conversation-scoped host events. + */ +export interface ConversationScopedRequest { + conversationId: string; +} + +/** + * Request payload for {@link HostEvent.OpenFilter}. Field requirements + * vary by context (Search requires columnId/type/dataType/name); the + * contract is the cross-context superset — the app validates per context + * at runtime. + */ +export interface OpenFilterRequest { + column: { + columnId?: string; + columnName?: string; + type?: string; + dataType?: string; + name?: string; + isStrictDateColumn?: boolean; + }; + applicability?: Applicability; + visualizationId?: string; + liveboardId?: string; +} + +/** + * Request payload for {@link HostEvent.OpenParameter}. One of parameterId + * or parameterName must be provided (validated at runtime). + */ +export interface OpenParameterRequest { + parameter: { + parameterId?: string; + parameterName?: string; + }; + applicability?: Applicability; +} + +/** + * Request payload for {@link HostEvent.Search}. + */ +export interface SearchRequest { + searchQuery: string; + dataSources: string[]; + execute?: boolean; +} + +/** + * Request payload for {@link HostEvent.SpotterSearch}. + */ +export interface SpotterSearchRequest { + query: string; + executeSearch: boolean; +} + +/** + * A single cross-filter condition for {@link HostEvent.UpdateCrossFilter}. + */ +export interface CrossFilterCondition { + columnName?: string; + operator?: string; + values: Array; +} + +/** + * Request payload for {@link HostEvent.UpdateCrossFilter}. + */ +export interface UpdateCrossFilterRequest { + vizId: string; + conditions: CrossFilterCondition[]; +} + +/** + * A single filter entry for {@link HostEvent.UpdateFilters}. Supports both + * the current (columnName/operator) and legacy (column/oper) field names. + */ +export interface HostFilterUpdate { + columnName?: string; + columnId?: string; + operator?: string; + values: Array; + type?: string; + datePeriod?: string; + negate?: boolean; + /** Legacy field name for columnName. */ + column?: string; + /** Legacy field name for operator. */ + oper?: string; + applicability?: Applicability; +} + +/** + * Request payload for {@link HostEvent.UpdateFilters} (singular or plural + * form). + */ +export interface UpdateFiltersRequest { + filter?: HostFilterUpdate; + filters?: HostFilterUpdate[]; +} + +/** + * Request payload for personalised-view host events. + */ +export interface PersonalisedViewRequest { + viewId?: string; + viewName?: string; +} + +/** + * Request payload for schedule-email related host events. + */ +export interface ScheduleEmailRequest { + sendToSelf?: boolean; +} + +/** + * Object form of the {@link HostEvent.Navigate} payload. + */ +export interface NavigateRequest { + /** + * Route to navigate to, or a history delta such as `1` or `-1`. + */ + path: string | number; + /** + * When `true`, replaces the current history entry instead of pushing. + */ + replace?: boolean; +} + +/** + * Request payload for {@link HostEvent.SetActiveTab}. + */ +export interface SetActiveTabRequest { + /** + * Id of the liveboard tab to make active. + */ + tabId: string; +} + +/** + * Typed request/response contracts for host events that do not go through + * the UI passthrough pipeline. + * + * Request shapes are transcribed from the host's runtime validation + * schemas (embed-util HostEventContract) — the shapes the ThoughtSpot app + * actually enforces — flattened across contexts to the permissive + * superset (context-specific requirements are validated at runtime). + * + * NOTE: `response` is typed `any` for events whose host-side response + * shape is not formally specified yet. Tightening a response type later + * is additive for consumers reading properties off it, but do not LOOSEN + * an already-typed response. + * + * Deliberately absent (do not add without an audit): + * - DrillDown: the runtime schema (object-shaped points) and the UI + * passthrough contract (string-shaped points) disagree — resolve the + * drift first. + * - GetAnswerSession/GetParameters/GetTML: typed via the UI passthrough + * mapping; their Spotter-context vizId requirement is runtime-only. + */ +export interface HostEventContractExtension { + // ==================== FILTERS AND PARAMETERS ==================== + [HostEvent.UpdateRuntimeFilters]: ContractEntryOf; + [HostEvent.UpdateParameters]: ContractEntryOf; + [HostEvent.UpdateFilters]: ContractEntryOf; + [HostEvent.UpdateCrossFilter]: ContractEntryOf; + [HostEvent.OpenFilter]: ContractEntryOf; + [HostEvent.OpenParameter]: ContractEntryOf; + + // ==================== TABS AND VIZS ==================== + [HostEvent.SetVisibleVizs]: ContractEntryOf; + [HostEvent.SetVisibleTabs]: ContractEntryOf; + [HostEvent.SetHiddenTabs]: ContractEntryOf; + [HostEvent.SetActiveTab]: ContractEntryOf; + + // ==================== NAVIGATION ==================== + [HostEvent.Navigate]: ContractEntryOf; + + // ==================== SEARCH AND COLUMNS ==================== + [HostEvent.Search]: ContractEntryOf; + [HostEvent.ResetSearch]: ContractEntryOf; + [HostEvent.AddColumns]: ContractEntryOf<{ columnIds: string[] }>; + [HostEvent.RemoveColumn]: ContractEntryOf<{ columnId: string }>; + + // ==================== VIZ-SCOPED ACTIONS ==================== + // vizId optional in most contexts; some contexts require it at runtime. + [HostEvent.Edit]: ContractEntryOf; + [HostEvent.Save]: ContractEntryOf; + [HostEvent.Delete]: ContractEntryOf; + [HostEvent.Share]: ContractEntryOf; + [HostEvent.Present]: ContractEntryOf; + [HostEvent.CopyLink]: ContractEntryOf; + [HostEvent.ExportTML]: ContractEntryOf; + [HostEvent.EditTML]: ContractEntryOf; + [HostEvent.UpdateTML]: ContractEntryOf; + [HostEvent.SchedulesList]: ContractEntryOf; + [HostEvent.Schedule]: ContractEntryOf; + [HostEvent.SpotIQAnalyze]: ContractEntryOf; + [HostEvent.ShowUnderlyingData]: ContractEntryOf; + [HostEvent.CreateMonitor]: ContractEntryOf; + [HostEvent.ManageMonitor]: ContractEntryOf; + [HostEvent.SyncToSheets]: ContractEntryOf; + [HostEvent.SyncToOtherApps]: ContractEntryOf; + [HostEvent.ManagePipelines]: ContractEntryOf; + // Download shares the downloadAsPng wire value with DownloadAsPng. + [HostEvent.DownloadAsPng]: ContractEntryOf; + [HostEvent.DownloadAsCsv]: ContractEntryOf; + [HostEvent.DownloadAsXlsx]: ContractEntryOf; + [HostEvent.DownloadAsPdf]: ContractEntryOf; + + // Viz id required in every context. + [HostEvent.Explore]: ContractEntryOf; + [HostEvent.AskSage]: ContractEntryOf; + [HostEvent.AskSpotter]: ContractEntryOf; + [HostEvent.AnswerChartSwitcher]: ContractEntryOf; + + // ==================== LIVEBOARD ==================== + [HostEvent.UpdatePersonalisedView]: ContractEntryOf>; + [HostEvent.SelectPersonalizedView]: ContractEntryOf; + [HostEvent.ResetLiveboardPersonalisedView]: ContractEntryOf; + [HostEvent.AIHighlights]: ContractEntryOf; + [HostEvent.SendTestScheduleEmail]: ContractEntryOf; + [HostEvent.RefreshLiveboardBrowserCache]: ContractEntryOf; + + // ==================== SPOTTER ==================== + [HostEvent.SpotterSearch]: ContractEntryOf; + [HostEvent.ResetSpotterConversation]: ContractEntryOf; + [HostEvent.ShareSpotterConversation]: ContractEntryOf; + [HostEvent.CloseSpotterShareConversation]: ContractEntryOf; + [HostEvent.ExitSpotterSharedConversation]: ContractEntryOf; + [HostEvent.PinSpotterConversation]: ContractEntryOf; + [HostEvent.UnpinSpotterConversation]: ContractEntryOf; + [HostEvent.EditLastPrompt]: ContractEntryOf; + [HostEvent.DeleteLastPrompt]: ContractEntryOf; + [HostEvent.PreviewSpotterData]: ContractEntryOf; + [HostEvent.SpotterVizSendUserMessage]: ContractEntryOf<{ query: string }>; + [HostEvent.InitSpotterVizConversation]: ContractEntryOf; + [HostEvent.OpenSpotterVizPanel]: ContractEntryOf; + [HostEvent.CloseSpotterVizPanel]: ContractEntryOf; +} + +/** + * Resolves the typed request payload for a host event. + * Resolution order: + * 1. Explicitly typed contract in {@link HostEventContractExtension} + * 2. UI passthrough backed contract ({@link EmbedApiHostEventMapping}) + * 3. `any` (event not audited/typed yet — backward compatible) + */ +export type HostEventRequest = + HostEventT extends keyof HostEventContractExtension + ? HostEventContractExtension[HostEventT]['request'] + : HostEventT extends keyof EmbedApiHostEventMapping + ? UIPassthroughRequest + : any; + +/** + * Resolves the typed response payload for a host event. + * Same resolution order as {@link HostEventRequest}. + */ +export type HostEventResponse< + HostEventT extends HostEvent, + // Reserved for context-dependent response shapes (additive change later). + ContextT extends ContextType = ContextType, +> = HostEventT extends keyof HostEventContractExtension + ? HostEventContractExtension[HostEventT]['response'] + : HostEventT extends keyof EmbedApiHostEventMapping + ? UIPassthroughResponse + : any; + +/** + * Payload type accepted by `embed.trigger()`. Keeps the historical + * `PayloadT` escape hatch so untyped existing call sites keep compiling. + */ +export type TriggerPayload = + PayloadT | HostEventRequest; + +/** + * Response type returned by `embed.trigger()`. + */ +export type TriggerResponse< + PayloadT, + HostEventT extends HostEvent, + ContextT extends ContextType = ContextType, +> = PayloadT extends HostEventRequest + ? HostEventResponse + : any; + +/** + * String-name keyed views of the contracts, for the host (ThoughtSpot app) + * side, which addresses events by their wire value (e.g. + * 'UpdateRuntimeFilters') rather than the {@link HostEvent} enum member. + */ +export type HostEventName = `${HostEvent}`; + +/** + * Resolves a host event's request type from its wire name. + */ +export type HostEventRequestByName = { + [EventT in HostEvent]: `${EventT}` extends NameT + ? HostEventRequest + : never; +}[HostEvent] extends never + ? any + : { + [EventT in HostEvent]: `${EventT}` extends NameT + ? HostEventRequest + : never; + }[HostEvent]; + +/** + * Resolves a host event's response type from its wire name. + */ +export type HostEventResponseByName = { + [EventT in HostEvent]: `${EventT}` extends NameT + ? HostEventResponse + : never; +}[HostEvent] extends never + ? any + : { + [EventT in HostEvent]: `${EventT}` extends NameT + ? HostEventResponse + : never; + }[HostEvent]; + +export type { UIPassthroughContractBase, EmbedApiHostEventMapping }; diff --git a/src/contracts/index.ts b/src/contracts/index.ts new file mode 100644 index 000000000..0277923da --- /dev/null +++ b/src/contracts/index.ts @@ -0,0 +1,53 @@ +/** + * Copyright (c) 2026 + * + * `@thoughtspot/visual-embed-sdk/contracts` + * + * Single source of truth for the Visual Embed SDK <-> ThoughtSpot app event + * contracts. Consumed by: + * - the SDK itself (compile-time types for `trigger()` / `on()`), + * - the ThoughtSpot app (host-side handler typing + runtime validation), + * - documentation generation. + * + * Evolution policy: ADDITIVE ONLY. See host-event-contracts.ts header. + * @module contracts + */ +export { + HostEvent, + EmbedEvent, + ContextType, +} from '../types'; +export type { + RuntimeFilter, + RuntimeParameter, + MessagePayload, + // Code-based custom action config (sent to the host at init as + // DefaultAppInitData.customActions) + the click event payload. + CustomAction, + CustomActionPayload, +} from '../types'; +export { + CustomActionsPosition, + CustomActionTarget, +} from '../types'; + +export * from './host-event-contracts'; +export * from './embed-event-payloads'; + +// UI passthrough contracts remain in their historical home; re-exported so +// the contracts subpath is self-sufficient for host-side consumers. +export { + UIPassthroughEvent, + ApplicabilityLevel, +} from './ui-passthrough-contracts'; +export type { + Applicability, + FilterUpdate, + LiveboardFilter, + LiveboardParameter, + LiveboardTab, + LiveboardGroup, + UIPassthroughRequest, + UIPassthroughResponse, + UIPassthroughArrayResponse, +} from './ui-passthrough-contracts'; diff --git a/src/embed/hostEventClient/contracts.ts b/src/contracts/ui-passthrough-contracts.ts similarity index 86% rename from src/embed/hostEventClient/contracts.ts rename to src/contracts/ui-passthrough-contracts.ts index 290ef2e60..31c79ab12 100644 --- a/src/embed/hostEventClient/contracts.ts +++ b/src/contracts/ui-passthrough-contracts.ts @@ -1,5 +1,5 @@ -import { ContextType, HostEvent, RuntimeFilter } from '../../types'; -import { SessionInterface } from '../../utils/graphql/answerService/answerService'; +import { HostEvent, RuntimeFilter } from '../types'; +import { SessionInterface } from '../utils/graphql/answerService/answerService'; export interface LiveboardTab { id: string; @@ -244,19 +244,7 @@ export type EmbedApiHostEventMapping = { [HostEvent.getExportRequestForCurrentPinboard]: UIPassthroughEvent.GetExportRequestForCurrentPinboard; } -// Host Event Request and Response -export type HostEventRequest = - HostEventT extends keyof EmbedApiHostEventMapping - ? UIPassthroughRequest - : any; - -export type HostEventResponse = - HostEventT extends keyof EmbedApiHostEventMapping - ? UIPassthroughResponse - : any; - -// trigger response and request -export type TriggerPayload = - PayloadT | HostEventRequest; -export type TriggerResponse = - PayloadT extends HostEventRequest ? HostEventResponse : any; \ No newline at end of file +// NOTE: HostEventRequest / HostEventResponse / TriggerPayload / TriggerResponse +// live in the sibling ./host-event-contracts, which layers the +// HostEventContractExtension map on top of the UI-passthrough mapping above. +// The former 2-tier definitions that lived here were superseded and removed. \ No newline at end of file diff --git a/src/embed/hostEventClient/host-event-client.spec.ts b/src/embed/hostEventClient/host-event-client.spec.ts index 069320d73..ea2b1841a 100644 --- a/src/embed/hostEventClient/host-event-client.spec.ts +++ b/src/embed/hostEventClient/host-event-client.spec.ts @@ -6,8 +6,8 @@ import { UIPassthroughEvent, UIPassthroughRequest, UIPassthroughArrayResponse, - HostEventRequest, -} from './contracts'; +} from '../../contracts/ui-passthrough-contracts'; +import { HostEventRequest } from '../../contracts/host-event-contracts'; import { HostEventClient } from './host-event-client'; jest.mock('../../utils/processTrigger'); diff --git a/src/embed/hostEventClient/host-event-client.ts b/src/embed/hostEventClient/host-event-client.ts index 46e0f8cb0..efce4cb72 100644 --- a/src/embed/hostEventClient/host-event-client.ts +++ b/src/embed/hostEventClient/host-event-client.ts @@ -12,13 +12,17 @@ import { import { UIPassthroughArrayResponse, UIPassthroughEvent, - HostEventRequest, - HostEventResponse, UIPassthroughRequest, UIPassthroughResponse, +} from '../../contracts/ui-passthrough-contracts'; +// Contract resolution from the shared contracts module — see +// src/contracts/host-event-contracts.ts (single source of truth). +import { + HostEventRequest, + HostEventResponse, TriggerPayload, TriggerResponse, -} from './contracts'; +} from '../../contracts/host-event-contracts'; /** * Maps HostEvent to its corresponding UIPassthroughEvent. @@ -244,7 +248,16 @@ export class HostEventClient { throwUpdateFiltersValidationError(); } - return this.handleHostEventWithParam(UIPassthroughEvent.UpdateFilters, payload, context as ContextType); + // The shared contract accepts both current (columnName/operator) and + // legacy (column/oper) filter field names — as does the validation + // above and the app at runtime. The UIPassthrough FilterUpdate type + // still requires the legacy names; bridge until that contract is + // audited. + return this.handleHostEventWithParam( + UIPassthroughEvent.UpdateFilters, + payload as UIPassthroughRequest, + context as ContextType, + ); } protected handleUpdateParametersEvent( @@ -287,7 +300,7 @@ export class HostEventClient { hostEvent: HostEventT, payload?: TriggerPayload, context?: ContextT, - ): Promise> { + ): Promise> { const customHandler = this.customHandlers[hostEvent]; const passthroughEvent = PASSTHROUGH_MAP[hostEvent]; diff --git a/src/embed/hostEventClient/utils.ts b/src/embed/hostEventClient/utils.ts index 91e6a997b..9488a9262 100644 --- a/src/embed/hostEventClient/utils.ts +++ b/src/embed/hostEventClient/utils.ts @@ -4,7 +4,8 @@ import isString from 'lodash/isString'; import isUndefined from 'lodash/isUndefined'; import { EmbedErrorCodes, EmbedEvent, ErrorDetailsTypes, HostEvent } from '../../types'; import { ERROR_MESSAGE } from '../../errors'; -import { ApplicabilityLevel, HostEventRequest } from './contracts'; +import { ApplicabilityLevel } from '../../contracts/ui-passthrough-contracts'; +import { HostEventRequest } from '../../contracts/host-event-contracts'; import { embedEventStatus } from '../../utils'; const isValidApplicability = (a?: { level?: string; targetId?: string }) => { diff --git a/src/embed/liveboard.ts b/src/embed/liveboard.ts index 2c2797d6c..d54e23fc2 100644 --- a/src/embed/liveboard.ts +++ b/src/embed/liveboard.ts @@ -29,7 +29,7 @@ import { calculateVisibleElementData, getEffectiveClippingAncestors, getQueryPar import { getAuthPromise } from './base'; import { TsEmbed, V1Embed } from './ts-embed'; import { addPreviewStylesIfNotPresent } from '../utils/global-styles'; -import { HostEventRequest, TriggerPayload, TriggerResponse } from './hostEventClient/contracts'; +import { HostEventRequest, TriggerPayload, TriggerResponse } from '../contracts/host-event-contracts'; import { logger } from '../utils/logger'; import { SpotterChatViewConfig, StarterPromptsConfig } from './conversation'; import { buildStarterPromptsAppInitData } from './spotter-utils'; diff --git a/src/embed/ts-embed.spec.ts b/src/embed/ts-embed.spec.ts index 319ae3b6b..bb9ea5cd1 100644 --- a/src/embed/ts-embed.spec.ts +++ b/src/embed/ts-embed.spec.ts @@ -61,7 +61,7 @@ import { logger } from '../utils/logger'; import { version } from '../../package.json'; import { HiddenActionItemByDefaultForSearchEmbed } from './search'; import { processTrigger } from '../utils/processTrigger'; -import { UIPassthroughEvent } from './hostEventClient/contracts'; +import { UIPassthroughEvent } from '../contracts/ui-passthrough-contracts'; import * as sessionInfoService from '../utils/sessionInfoService'; import * as authToken from '../authToken'; import * as apiIntercept from '../api-intercept'; diff --git a/src/embed/ts-embed.ts b/src/embed/ts-embed.ts index 10ed5d535..19d37739a 100644 --- a/src/embed/ts-embed.ts +++ b/src/embed/ts-embed.ts @@ -10,13 +10,16 @@ import isEqual from 'lodash/isEqual'; import isEmpty from 'lodash/isEmpty'; import isObject from 'lodash/isObject'; import { - HostEventRequest, - TriggerPayload, - TriggerResponse, UIPassthroughArrayResponse, UIPassthroughEvent, UIPassthroughRequest, -} from './hostEventClient/contracts'; +} from '../contracts/ui-passthrough-contracts'; +// Contract resolution comes from the shared contracts module (the single +// source of truth for event payload shapes) rather than the legacy +// UI-passthrough-only mapping. +import { HostEventRequest, TriggerPayload, TriggerResponse } from '../contracts/host-event-contracts'; +import { EmbedEventPayload } from '../contracts/embed-event-payloads'; +import { isMessageFromIframe } from '../utils/transport/iframe-transport'; import { logger } from '../utils/logger'; import { getAuthenticationToken } from '../authToken'; import { AnswerService } from '../utils/graphql/answerService/answerService'; @@ -432,7 +435,7 @@ export class TsEmbed { const eventType = this.getEventType(event); const eventPort = this.getEventPort(event); const eventData = this.formatEventData(event, eventType); - if (event.source === this.iFrame.contentWindow) { + if (isMessageFromIframe(event, this.iFrame, this.thoughtSpotHost)) { const processedEventData = processEventData( eventType, eventData, @@ -1516,9 +1519,12 @@ export class TsEmbed { * }); * ``` */ - public on( - messageType: EmbedEvent, - callback: MessageCallback, + public on( + messageType: EmbedEventT, + callback: ( + payload: EmbedEventPayload, + responder?: (data: any) => void, + ) => void, options: MessageOptions = { start: false }, isRegisteredBySDK = false, ): typeof TsEmbed.prototype { @@ -2327,12 +2333,18 @@ export class V1Embed extends TsEmbed { * }); * ``` */ - public on( - messageType: EmbedEvent, - callback: MessageCallback, + public on( + messageType: EmbedEventT, + callback: ( + payload: EmbedEventPayload, + responder?: (data: any) => void, + ) => void, options: MessageOptions = { start: false }, ): typeof TsEmbed.prototype { - const eventType = this.getCompatibleEventType(messageType); + // Mirror the base TsEmbed.on generic signature so the enriched + // EmbedEventPayload (e.g. CustomAction's answerService) flows through + // the override too, and the class hierarchy stays assignable. + const eventType = this.getCompatibleEventType(messageType) as EmbedEventT; return super.on(eventType, callback, options); } diff --git a/src/index.ts b/src/index.ts index 47f5ecf01..4cb9a8998 100644 --- a/src/index.ts +++ b/src/index.ts @@ -94,10 +94,6 @@ import { UIPassthroughRequest, UIPassthroughResponse, UIPassthroughArrayResponse, - HostEventRequest, - HostEventResponse, - TriggerPayload, - TriggerResponse, LiveboardTab, LiveboardGroup, ApplicabilityLevel, @@ -105,7 +101,13 @@ import { FilterUpdate, LiveboardFilter, LiveboardParameter, -} from './embed/hostEventClient/contracts'; +} from './contracts/ui-passthrough-contracts'; +import { + HostEventRequest, + HostEventResponse, + TriggerPayload, + TriggerResponse, +} from './contracts/host-event-contracts'; export { init, diff --git a/src/types.ts b/src/types.ts index b86c19a54..afcb6dc1a 100644 --- a/src/types.ts +++ b/src/types.ts @@ -8979,6 +8979,15 @@ export interface VizPoint { * @group Events */ export interface CustomActionPayload { + /** + * Id of the custom action that was triggered. Matches the `id` you set on + * the {@link CustomAction} — use it to distinguish which action fired. + */ + id: string; + /** + * Name of the custom action that was triggered, when the host includes it. + */ + name?: string; contextMenuPoints?: { clickedPoint: VizPoint; selectedPoints: VizPoint[]; diff --git a/src/utils/processTrigger.ts b/src/utils/processTrigger.ts index 761eb6463..c44ef2fa0 100644 --- a/src/utils/processTrigger.ts +++ b/src/utils/processTrigger.ts @@ -1,8 +1,11 @@ -import { ERROR_MESSAGE } from '../errors'; -import { ContextType, HostEvent, MessagePayload } from '../types'; +import { ContextType, HostEvent } from '../types'; import { logger } from '../utils/logger'; import { handlePresentEvent } from '../utils'; import { getEmbedConfig } from '../embed/embedConfig'; +import { + MESSAGE_RESPONSE_TIMEOUT, + sendMessageWithResponse, +} from './transport/iframe-transport'; /** * Reloads the ThoughtSpot iframe. @@ -16,29 +19,12 @@ export const reload = (iFrame: HTMLIFrameElement) => { }, 100); }; -/** - * Post iframe message. - * @param iFrame - * @param message - * @param message.type - * @param message.data - * @param message.context - * @param thoughtSpotHost - * @param channel - */ -function postIframeMessage( - iFrame: HTMLIFrameElement, - message: { type: HostEvent; data: any, context?: any }, - thoughtSpotHost: string, - channel?: MessageChannel, -) { - return iFrame.contentWindow?.postMessage(message, thoughtSpotHost, [channel?.port2]); -} - -export const TRIGGER_TIMEOUT = 30000; +export const TRIGGER_TIMEOUT = MESSAGE_RESPONSE_TIMEOUT; /** - * + * Processes a host event trigger: handles SDK-local events (Reload, + * Present) and forwards everything else to the embedded app over the + * iframe transport. * @param iFrame * @param messageType * @param thoughtSpotHost @@ -52,42 +38,26 @@ export function processTrigger( data: any, context?: ContextType, ): Promise { - return new Promise((res, rej) => { - if (messageType === HostEvent.Reload) { - reload(iFrame); - return res(null); - } - - if (messageType === HostEvent.Present) { - const embedConfig = getEmbedConfig(); - const disableFullscreenPresentation = embedConfig?.disableFullscreenPresentation ?? true; - - if (!disableFullscreenPresentation) { - handlePresentEvent(iFrame); - } else { - logger.warn('Fullscreen presentation mode is disabled. Set disableFullscreenPresentation: false to enable this feature.'); - } - } - - const channel = new MessageChannel(); + if (messageType === HostEvent.Reload) { + reload(iFrame); + return Promise.resolve(null); + } - // Close the messageChannel and resolve the promise if timeout. - const timeoutId = setTimeout(() => { - channel.port1.close(); - res(new Error(ERROR_MESSAGE.TRIGGER_TIMED_OUT)); - }, TRIGGER_TIMEOUT); + if (messageType === HostEvent.Present) { + const embedConfig = getEmbedConfig(); + const disableFullscreenPresentation = embedConfig?.disableFullscreenPresentation ?? true; - channel.port1.onmessage = ({ data: responseData }) => { - clearTimeout(timeoutId); - channel.port1.close(); - const error = responseData?.error || responseData?.data?.error; - if (error) { - rej(error); - } else { - res(responseData); - } - }; + if (!disableFullscreenPresentation) { + handlePresentEvent(iFrame); + } else { + logger.warn('Fullscreen presentation mode is disabled. Set disableFullscreenPresentation: false to enable this feature.'); + } + } - return postIframeMessage(iFrame, { type: messageType, data, context }, thoughtSpotHost, channel); - }); + return sendMessageWithResponse( + iFrame, + { type: messageType, data, context }, + thoughtSpotHost, + TRIGGER_TIMEOUT, + ); } diff --git a/src/utils/transport/iframe-transport.ts b/src/utils/transport/iframe-transport.ts new file mode 100644 index 000000000..dfba70824 --- /dev/null +++ b/src/utils/transport/iframe-transport.ts @@ -0,0 +1,122 @@ +/** + * Copyright (c) 2026 + * + * Dedicated transport layer for SDK <-> ThoughtSpot iframe messaging. + * + * Owns the postMessage/MessageChannel mechanics and origin resolution so + * that embed/business logic (ts-embed.ts, hostEventClient) never touches + * the wire directly. The wire format is unchanged from the historical + * implementation — this layer is a pure structural extraction. + */ +import { ERROR_MESSAGE } from '../../errors'; +import { logger } from '../logger'; + +/** + * Default time to wait for the app to respond to a request-response + * message before giving up and reclaiming the channel. + */ +export const MESSAGE_RESPONSE_TIMEOUT = 30000; + +/** + * Single implementation of origin resolution for the embedded app. + * Returns the origin (scheme://host[:port]) for a ThoughtSpot host URL, + * or null when it cannot be parsed. + * @param thoughtSpotHost + */ +export const resolveMessageOrigin = (thoughtSpotHost: string): string | null => { + try { + return new URL(thoughtSpotHost).origin; + } catch (e) { + return null; + } +}; + +/** + * Validates that an inbound window message came from the embedded + * ThoughtSpot app: the source window must be the embed iframe's window and, + * when the expected origin is resolvable, the message origin must match it. + * + * Fails open (returns true) on the origin check when the expected origin + * cannot be resolved, to avoid breaking unconventional-but-working setups; + * a warning is logged on mismatch either way. + * @param event The message event received on window. + * @param iFrame The embed iframe the message should originate from. + * @param thoughtSpotHost The configured ThoughtSpot host. + */ +export const isMessageFromIframe = ( + event: MessageEvent, + iFrame: HTMLIFrameElement, + thoughtSpotHost: string, +): boolean => { + if (event.source !== iFrame?.contentWindow) { + return false; + } + const expectedOrigin = resolveMessageOrigin(thoughtSpotHost); + if (expectedOrigin && event.origin && event.origin !== expectedOrigin) { + logger.warn( + `Dropped message from unexpected origin ${event.origin}; expected ${expectedOrigin}`, + ); + return false; + } + return true; +}; + +/** + * Posts a one-way message to the embedded app's iframe, optionally + * transferring a MessageChannel port for the response. + * @param iFrame + * @param message + * @param thoughtSpotHost Used as the postMessage targetOrigin. + * @param channel + */ +export const postMessageToIframe = ( + iFrame: HTMLIFrameElement, + message: { type: string; data: any; context?: any }, + thoughtSpotHost: string, + channel?: MessageChannel, +): void => iFrame.contentWindow?.postMessage( + message, + thoughtSpotHost, + channel ? [channel.port2] : [], +); + +/** + * Sends a request-response message to the embedded app over a dedicated + * MessageChannel. Resolves with the response payload; rejects when the app + * responds with an error; resolves with a timeout Error object when no + * response arrives in time (historical behavior, preserved for backward + * compatibility). + * @param iFrame + * @param message + * @param thoughtSpotHost + * @param timeoutMs + */ +export function sendMessageWithResponse( + iFrame: HTMLIFrameElement, + message: { type: string; data: any; context?: any }, + thoughtSpotHost: string, + timeoutMs: number = MESSAGE_RESPONSE_TIMEOUT, +): Promise { + return new Promise((res, rej) => { + const channel = new MessageChannel(); + + // Close the messageChannel and resolve the promise if timeout. + const timeoutId = setTimeout(() => { + channel.port1.close(); + res(new Error(ERROR_MESSAGE.TRIGGER_TIMED_OUT)); + }, timeoutMs); + + channel.port1.onmessage = ({ data: responseData }) => { + clearTimeout(timeoutId); + channel.port1.close(); + const error = responseData?.error || responseData?.data?.error; + if (error) { + rej(error); + } else { + res(responseData); + } + }; + + postMessageToIframe(iFrame, message, thoughtSpotHost, channel); + }); +}