/** * Types and type guards for flow state and lifecycle events. */ import type { CaptureInstanceFeedbackResponse, InstanceFeedbackMineEvaluation, ListInstanceFeedbackMineResponse, } from "../instance-feedback.js"; import { areWidgetFrameAppearancesEqual, cloneWidgetFrameAppearance, isWidgetFrameAppearance, type WidgetFrameAppearance, } from "./widget-frame-appearance.js"; import type { WidgetLayoutTimedTransitionPolicy, WidgetLayoutTransitionPolicy, WidgetLayoutTransitionSchedule, } from "./widget-layout-transition-plan.js"; /** Durable flow render state: whether it is open, opening, and its dimensions (if known). */ export interface FlowRenderState { /** True when the flow is visible. */ isOpen: boolean; /** * True after an open request while the flow is not visible yet. Background * prefetching or prerendering without open intent must not set this. */ isLoading: boolean; /** Flow width in pixels when known. */ width?: number; /** Flow height in pixels when known. */ height?: number; } /** Legacy transport state whose transition policy may be retained and replayed. */ export interface FlowState extends FlowRenderState { /** * Optional host transition policy. This field does not identify a new * transition occurrence by itself; use FlowRenderState for durable state. */ transitionPolicy?: WidgetLayoutTransitionPolicy; } export interface FlowStateChangedDetail extends FlowState { instanceId: string; flowHandleId: string; } /** One timed host-owned geometry occurrence. This detail is never replayed. */ export interface FlowLayoutUpdatedDetail { instanceId: string; flowHandleId: string; width: number; height: number; transitionPolicy: WidgetLayoutTimedTransitionPolicy; } export interface InstanceFlowStateChangedDetail extends FlowState { instanceId: string; /** * True when the host container should render for the aggregate instance state. * Older emitters omit this field; consumers should use * resolveInstanceFlowStateShouldRender to preserve the legacy isOpen/isLoading * behavior at compatibility boundaries. */ shouldRender?: boolean; /** Flow handle for the currently pinned aggregate flow, when known. */ flowHandleId?: string; } type ViewHostFrameAppearanceState = { /** Optional read-only runtime frame appearance serialized as theme CSS values. */ frameAppearance?: WidgetFrameAppearance; /** @internal Shared identity and start time for one host/View transition. */ transitionSchedule?: WidgetLayoutTransitionSchedule; }; export type ViewHostFlowRunSnapshot = FlowState & ViewHostFrameAppearanceState & { kind: "flowRun"; flowRunId: string; flowHandleId?: string; }; export type ViewHostPendingFlowHandleSnapshot = FlowState & ViewHostFrameAppearanceState & { kind: "pendingFlowHandle"; flowHandleId: string; }; export type ViewHostSnapshot = | ViewHostFlowRunSnapshot | ViewHostPendingFlowHandleSnapshot; export interface InstanceViewHostSnapshotsChangedDetail { instanceId: string; /** * Active views in host priority order. Consumers that can present only one * view should use the first snapshot without discarding the remaining state. */ snapshots: ViewHostSnapshot[]; } function areWidgetLayoutTransitionPoliciesEqual( a: WidgetLayoutTransitionPolicy | undefined, b: WidgetLayoutTransitionPolicy | undefined, ): boolean { if (a === undefined || b === undefined) { return a === b; } if (a.kind !== b.kind || a.durationMs !== b.durationMs) { return false; } if (a.kind === "instant") { return true; } return ( b.kind === "timed" && a.easing.every((point, index) => point === b.easing[index]) ); } export function areViewHostSnapshotsEqual( a: ViewHostSnapshot | undefined, b: ViewHostSnapshot | undefined, ): boolean { if (a === undefined || b === undefined) { return a === b; } if ( a.kind !== b.kind || a.isOpen !== b.isOpen || a.isLoading !== b.isLoading || a.width !== b.width || a.height !== b.height || !areWidgetFrameAppearancesEqual(a.frameAppearance, b.frameAppearance) || a.flowHandleId !== b.flowHandleId || !areWidgetLayoutTransitionPoliciesEqual( a.transitionPolicy, b.transitionPolicy, ) || a.transitionSchedule?.startAtEpochMs !== b.transitionSchedule?.startAtEpochMs || a.transitionSchedule?.sequence !== b.transitionSchedule?.sequence || a.transitionSchedule?.transactionId !== b.transitionSchedule?.transactionId ) { return false; } return ( a.kind === "pendingFlowHandle" || (b.kind === "flowRun" && a.flowRunId === b.flowRunId) ); } export function cloneViewHostSnapshots( snapshots: readonly Snapshot[], ): Snapshot[] { return snapshots.map((snapshot) => { const frameAppearance = snapshot.frameAppearance === undefined ? {} : { frameAppearance: cloneWidgetFrameAppearance( snapshot.frameAppearance, ), }; if (snapshot.transitionPolicy?.kind === "timed") { return { ...snapshot, ...frameAppearance, transitionPolicy: { ...snapshot.transitionPolicy, easing: [...snapshot.transitionPolicy.easing], }, ...(snapshot.transitionSchedule ? { transitionSchedule: { ...snapshot.transitionSchedule } } : {}), }; } return { ...snapshot, ...frameAppearance, ...(snapshot.transitionPolicy ? { transitionPolicy: { ...snapshot.transitionPolicy } } : {}), ...(snapshot.transitionSchedule ? { transitionSchedule: { ...snapshot.transitionSchedule } } : {}), }; }); } export function resolveInstanceFlowStateShouldRender( detail: Pick & { shouldRender?: boolean; }, ): boolean { return detail.shouldRender ?? (detail.isOpen || detail.isLoading); } export interface OpenRequestedDetail { instanceId: string; source: "command" | "targeting"; flowId: string; flowHandleId?: string; hideCloseButton?: boolean; } /** @internal Trusted host-realm navigation adapter event. */ export const NAVIGATION_OUTCOMES = [ "succeeded", "failed", "unhandled", ] as const; export type NavigationOutcome = (typeof NAVIGATION_OUTCOMES)[number]; export function isNavigationOutcome( value: unknown, ): value is NavigationOutcome { return NAVIGATION_OUTCOMES.some((outcome) => outcome === value); } export interface NavigationRequestedDetail { instanceId: string; url: string; /** * Requested browser target, resolved from the trusted served survey. * `_blank` is accepted only on the version 1 rollout event. */ target?: "_blank" | "self" | "blank"; /** Internal open-url action contract version. */ version: number; /** * Synchronous one-shot outcome settlement when negotiated by capability. * Only the internal SDK adapter is intended to call this. The browser host * remains client-reported; this callback is not trusted execution attestation. */ settle?: (outcome: NavigationOutcome) => void; } /** @internal Trusted host-realm adapter event emitted after Core authorization. */ export interface HostActionRequestedDetail { instanceId: string; definition: { key: string; version: number }; settle: (outcome: "success" | "failure" | "unavailable") => void; } export const HANDLE_INVALIDATED_REASON_CODES: readonly [ "RESET", "CLOSED", "STALE_HANDLE", "OWNERSHIP_CONFLICT", "OWNER_DISPOSED", "UPSTREAM_INVALIDATED", "INTERNAL", ] = [ "RESET", "CLOSED", "STALE_HANDLE", "OWNERSHIP_CONFLICT", "OWNER_DISPOSED", "UPSTREAM_INVALIDATED", "INTERNAL", ]; export type HandleInvalidatedReasonCode = (typeof HANDLE_INVALIDATED_REASON_CODES)[number]; export const HANDLE_INVALIDATED_SOURCES: readonly ["loader", "core"] = [ "loader", "core", ]; export type HandleInvalidatedSource = (typeof HANDLE_INVALIDATED_SOURCES)[number]; /** Generic handle lifecycle event emitted when a previously issued handle is invalidated upstream. */ export interface HandleInvalidatedDetail { instanceId: string; handleKind: string; handleId: string; reasonCode: HandleInvalidatedReasonCode; reasonMessage?: string; relatedRequestId?: string; source: HandleInvalidatedSource; at: number; } const isNonEmptyString = (v: unknown): v is string => typeof v === "string" && v.trim().length > 0; const isRecord = (v: unknown): v is Record => typeof v === "object" && v !== null; const HANDLE_INVALIDATED_REASON_CODE_SET: ReadonlySet = new Set( HANDLE_INVALIDATED_REASON_CODES, ); const isHandleInvalidatedReasonCode = ( v: unknown, ): v is HandleInvalidatedReasonCode => typeof v === "string" && HANDLE_INVALIDATED_REASON_CODE_SET.has(v); const HANDLE_INVALIDATED_SOURCE_SET: ReadonlySet = new Set( HANDLE_INVALIDATED_SOURCES, ); const isHandleInvalidatedSource = (v: unknown): v is HandleInvalidatedSource => typeof v === "string" && HANDLE_INVALIDATED_SOURCE_SET.has(v); function isWidgetLayoutTransitionPolicyLike( value: unknown, ): value is WidgetLayoutTransitionPolicy | undefined { if (value === undefined) { return true; } if (!isRecord(value)) { return false; } if (value.kind === "instant") { return value.durationMs === 0; } if (value.kind !== "timed") { return false; } const easing = value.easing; return ( typeof value.durationMs === "number" && Number.isFinite(value.durationMs) && value.durationMs > 0 && Array.isArray(easing) && easing.length === 4 && typeof easing[0] === "number" && easing[0] >= 0 && easing[0] <= 1 && typeof easing[1] === "number" && Number.isFinite(easing[1]) && typeof easing[2] === "number" && easing[2] >= 0 && easing[2] <= 1 && typeof easing[3] === "number" && Number.isFinite(easing[3]) ); } function isWidgetLayoutTransitionScheduleLike( value: unknown, ): value is WidgetLayoutTransitionSchedule | undefined { if (value === undefined) { return true; } return ( isRecord(value) && typeof value.sequence === "number" && Number.isSafeInteger(value.sequence) && value.sequence >= 0 && isNonEmptyString(value.transactionId) && typeof value.startAtEpochMs === "number" && Number.isFinite(value.startAtEpochMs) ); } export function isFlowStateChangedDetail( detail: unknown, ): detail is FlowStateChangedDetail { if (!isRecord(detail)) return false; const { instanceId, isOpen, isLoading, width, height, flowHandleId, transitionPolicy, } = detail; return ( isNonEmptyString(instanceId) && typeof isOpen === "boolean" && typeof isLoading === "boolean" && (typeof width === "undefined" || typeof width === "number") && (typeof height === "undefined" || typeof height === "number") && isWidgetLayoutTransitionPolicyLike(transitionPolicy) && isNonEmptyString(flowHandleId) ); } export function isFlowLayoutUpdatedDetail( detail: unknown, ): detail is FlowLayoutUpdatedDetail { if (!isRecord(detail)) return false; const { instanceId, flowHandleId, width, height, transitionPolicy } = detail; return ( isNonEmptyString(instanceId) && isNonEmptyString(flowHandleId) && typeof width === "number" && Number.isFinite(width) && typeof height === "number" && Number.isFinite(height) && isWidgetLayoutTransitionPolicyLike(transitionPolicy) && transitionPolicy?.kind === "timed" ); } export function isInstanceFlowStateChangedDetail( detail: unknown, ): detail is InstanceFlowStateChangedDetail { if (!isRecord(detail)) return false; const { instanceId, isOpen, isLoading, shouldRender, width, height, flowHandleId, transitionPolicy, } = detail; return ( isNonEmptyString(instanceId) && typeof isOpen === "boolean" && typeof isLoading === "boolean" && (typeof shouldRender === "undefined" || typeof shouldRender === "boolean") && (typeof width === "undefined" || typeof width === "number") && (typeof height === "undefined" || typeof height === "number") && isWidgetLayoutTransitionPolicyLike(transitionPolicy) && (typeof flowHandleId === "undefined" || isNonEmptyString(flowHandleId)) ); } function isFlowStateShape(detail: Record): boolean { const { isOpen, isLoading, width, height, transitionPolicy } = detail; return ( typeof isOpen === "boolean" && typeof isLoading === "boolean" && (typeof width === "undefined" || typeof width === "number") && (typeof height === "undefined" || typeof height === "number") && isWidgetLayoutTransitionPolicyLike(transitionPolicy) ); } export function isViewHostSnapshot( snapshot: unknown, ): snapshot is ViewHostSnapshot { if (!isRecord(snapshot) || !isFlowStateShape(snapshot)) { return false; } if ( snapshot.frameAppearance !== undefined && !isWidgetFrameAppearance(snapshot.frameAppearance) ) { return false; } if (!isWidgetLayoutTransitionScheduleLike(snapshot.transitionSchedule)) { return false; } if (snapshot.kind === "flowRun") { return ( isNonEmptyString(snapshot.flowRunId) && (typeof snapshot.flowHandleId === "undefined" || isNonEmptyString(snapshot.flowHandleId)) ); } if (snapshot.kind === "pendingFlowHandle") { return isNonEmptyString(snapshot.flowHandleId); } return false; } export function isInstanceViewHostSnapshotsChangedDetail( detail: unknown, ): detail is InstanceViewHostSnapshotsChangedDetail { if (!isRecord(detail)) return false; const { instanceId, snapshots } = detail; return ( isNonEmptyString(instanceId) && Array.isArray(snapshots) && Array.from(snapshots).every(isViewHostSnapshot) ); } export function isOpenRequestedDetail( detail: unknown, ): detail is OpenRequestedDetail { if (!isRecord(detail)) return false; const { instanceId, source, flowId, flowHandleId, hideCloseButton } = detail; return ( isNonEmptyString(instanceId) && (source === "command" || source === "targeting") && isNonEmptyString(flowId) && (typeof flowHandleId === "undefined" || isNonEmptyString(flowHandleId)) && (typeof hideCloseButton === "undefined" || typeof hideCloseButton === "boolean") ); } export function isNavigationRequestedDetail( detail: unknown, ): detail is NavigationRequestedDetail { if (!isRecord(detail)) return false; const { instanceId, url, target, version, settle } = detail; try { const protocol = typeof url === "string" ? new URL(url).protocol : ""; return ( isNonEmptyString(instanceId) && (protocol === "http:" || protocol === "https:") && typeof version === "number" && Number.isSafeInteger(version) && version > 0 && (settle === undefined || typeof settle === "function") && ((version === 1 && target === "_blank") || (version > 1 && (target === undefined || target === "self" || target === "blank"))) ); } catch { return false; } } export function isHostActionRequestedDetail( detail: unknown, ): detail is HostActionRequestedDetail { if (!isRecord(detail)) return false; const { instanceId, definition, settle } = detail; return ( isNonEmptyString(instanceId) && typeof settle === "function" && isRecord(definition) && isNonEmptyString(definition.key) && typeof definition.version === "number" && Number.isSafeInteger(definition.version) && definition.version > 0 ); } export function isHandleInvalidatedDetail( detail: unknown, ): detail is HandleInvalidatedDetail { if (!isRecord(detail)) return false; const { instanceId, handleKind, handleId, reasonCode, reasonMessage, relatedRequestId, source, at, } = detail; return ( isNonEmptyString(instanceId) && isNonEmptyString(handleKind) && isNonEmptyString(handleId) && isHandleInvalidatedReasonCode(reasonCode) && (typeof reasonMessage === "undefined" || typeof reasonMessage === "string") && (typeof relatedRequestId === "undefined" || isNonEmptyString(relatedRequestId)) && isHandleInvalidatedSource(source) && typeof at === "number" && Number.isFinite(at) ); } export interface CommandSettledSuccessDetail { requestId: string; instanceId: string | null; kind: string; ok: true; result?: unknown; } export interface CommandSettledFailureDetail { requestId: string; instanceId: string | null; kind: string; ok: false; error: { message: string; code?: string }; } export type CommandSettledDetail = | CommandSettledSuccessDetail | CommandSettledFailureDetail; export function isCommandSettledDetail( detail: unknown, ): detail is CommandSettledDetail { if (!isRecord(detail)) return false; const { requestId, instanceId, kind, ok, error } = detail; if ( !isNonEmptyString(requestId) || (typeof instanceId !== "string" && instanceId !== null) || typeof kind !== "string" || typeof ok !== "boolean" ) { return false; } if (ok === true) { return true; } if (ok === false && isRecord(error)) { return typeof error.message === "string"; } return false; } /** Result of extracting a flow handle from a successful open/prerender/prefetch settlement. */ export type FlowHandleFromSettlement = { flowHandleId: string; flowRunId?: string; }; /** * Host-facing handle result: same shape as FlowHandleFromSettlement. * Used when projecting instance:command:settled so the SDK receives flowHandleId (and flowRunId when present). */ export type HostHandleSettlementResult = FlowHandleFromSettlement; /** * Return the host-facing handle result for a command settlement, or null. * Use in the loader when projecting instance:command:settled; keeps the host contract in one place (no zod). */ export function getHostHandleResultFromSettlement(detail: { ok: boolean; kind: string; result?: unknown; }): HostHandleSettlementResult | null { return getFlowHandleFromSettlementDetail(detail); } const toCanonicalUtcTimestamp = (value: unknown): string | null => { if (typeof value !== "string") { return null; } const normalizedValue = value.trim(); if ( !/^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}\.[0-9]{3}Z$/u.test( normalizedValue, ) ) { return null; } const date = new Date(normalizedValue); return !Number.isNaN(date.getTime()) && date.toISOString() === normalizedValue ? normalizedValue : null; }; /** Return a validated feedback receipt from a successful capture settlement. */ export function getFeedbackSubmissionFromSettlementDetail(detail: { ok: boolean; kind: string; result?: unknown; }): CaptureInstanceFeedbackResponse | null { if ( !detail.ok || detail.kind !== "instanceFeedback.capture" || !isRecord(detail.result) ) { return null; } const { evaluationId, status } = detail.result; const capturedAt = toCanonicalUtcTimestamp(detail.result.capturedAt); if ( status !== "accepted" || !isNonEmptyString(evaluationId) || capturedAt === null ) { return null; } return { capturedAt, evaluationId: evaluationId.trim(), status, }; } const toFeedbackMineEvaluation = ( value: unknown, ): InstanceFeedbackMineEvaluation | null => { if (!isRecord(value)) { return null; } const { category, comment, evaluationId, reasons } = value; const capturedAt = toCanonicalUtcTimestamp(value.capturedAt); const evaluationValue = value.value; if ( capturedAt === null || (typeof category !== "string" && category !== null) || (typeof comment !== "string" && comment !== null) || !isNonEmptyString(evaluationId) || !Array.isArray(reasons) || !reasons.every((reason) => typeof reason === "string") || (typeof evaluationValue !== "boolean" && typeof evaluationValue !== "number" && typeof evaluationValue !== "string" && evaluationValue !== null) || (typeof evaluationValue === "number" && !Number.isFinite(evaluationValue)) ) { return null; } return { capturedAt, category, comment, evaluationId: evaluationId.trim(), reasons, value: evaluationValue, }; }; /** Return a validated authenticated-feedback read result from a settlement. */ export function getFeedbackReadFromSettlementDetail(detail: { ok: boolean; kind: string; result?: unknown; }): ListInstanceFeedbackMineResponse | null { if ( !detail.ok || detail.kind !== "instanceFeedback.listMine" || !isRecord(detail.result) || !Array.isArray(detail.result.items) ) { return null; } const items: ListInstanceFeedbackMineResponse["items"] = []; for (const item of detail.result.items) { if (!isRecord(item) || !("evaluation" in item)) { return null; } if (item.evaluation === null) { items.push({ evaluation: null }); continue; } const evaluation = toFeedbackMineEvaluation(item.evaluation); if (!evaluation) { return null; } items.push({ evaluation }); } return { items }; } const HANDLE_RETURNING_KINDS: readonly ["open", "prerender", "prefetch"] = [ "open", "prerender", "prefetch", ]; type HandleReturningKind = (typeof HANDLE_RETURNING_KINDS)[number]; const HANDLE_RETURNING_KIND_SET: ReadonlySet = new Set( HANDLE_RETURNING_KINDS, ); const isHandleReturningKind = (kind: string): kind is HandleReturningKind => HANDLE_RETURNING_KIND_SET.has(kind); /** * Extract flow handle from a successful command settlement when the command * is one that can return a handle (open, prerender, prefetch). Uses plain * checks only (no Zod). Returns null if detail is not ok, kind is not one of * those, or result does not contain a non-empty flowHandleId. */ export function getFlowHandleFromSettlementDetail(detail: { ok: boolean; kind: string; result?: unknown; }): FlowHandleFromSettlement | null { if (!detail.ok || !isHandleReturningKind(detail.kind)) { return null; } const result = detail.result; if (!isRecord(result)) { return null; } const { flowHandleId, flowRunId } = result; if (!isNonEmptyString(flowHandleId)) { return null; } return { flowHandleId, ...(isNonEmptyString(flowRunId) && { flowRunId }), }; } /** Normalized allocation from a handle-returning settlement: handle + instanceId for state. */ export type FlowHandleAllocationFromSettlement = { flowHandleId: string; flowRunId: string | null; instanceId: string | null; }; const nonEmptyOrNull = (value: string | null | undefined): string | null => typeof value === "string" && value.length > 0 ? value : null; /** * Parse a command settlement into a single allocation object when the command * returned a flow handle (open/prerender/prefetch). Returns null otherwise. */ export function getFlowHandleAllocationFromSettlement(detail: { ok: boolean; kind: string; result?: unknown; instanceId?: string | null; }): FlowHandleAllocationFromSettlement | null { const handle = getFlowHandleFromSettlementDetail(detail); if (!handle) { return null; } return { flowHandleId: handle.flowHandleId, flowRunId: handle.flowRunId ?? null, instanceId: nonEmptyOrNull(detail.instanceId ?? null), }; }