/** * Zod schemas that validate the host event detail contract. * Paired with host-event-contract.ts, which defines the types and lightweight guards. */ import * as z from "zod/mini"; import { allCommandKindSchema } from "../command-kinds.js"; import { HANDLE_INVALIDATED_REASON_CODES, HANDLE_INVALIDATED_SOURCES, type NavigationOutcome, } from "./host-event-contract.js"; import { isWidgetFrameAppearance, WIDGET_FRAME_RESOLVED_BORDER_STYLES, type WidgetFrameAppearance, } from "./widget-frame-appearance.js"; const nonEmptyStringSchema = z.string().check(z.trim(), z.minLength(1)); const finiteNumberSchema = z.number().check( z.refine(Number.isFinite, { message: "Expected a finite number.", }), ); const cubicBezierXControlPointSchema = finiteNumberSchema.check( z.minimum(0), z.maximum(1), ); const srgbChannelSchema = finiteNumberSchema.check( z.int(), z.minimum(0), z.maximum(255), ); const alphaChannelSchema = finiteNumberSchema.check(z.minimum(0), z.maximum(1)); const widgetFrameResolvedBorderSchema = z.strictObject({ color: z.strictObject({ colorSpace: z.literal("srgb"), red: srgbChannelSchema, green: srgbChannelSchema, blue: srgbChannelSchema, alpha: alphaChannelSchema, }), style: z.enum(WIDGET_FRAME_RESOLVED_BORDER_STYLES), widthPx: finiteNumberSchema.check(z.minimum(0)), }); const widgetFrameResolvedBorderRadiusCornerSchema = z.strictObject({ horizontalPx: finiteNumberSchema.check(z.minimum(0)), verticalPx: finiteNumberSchema.check(z.minimum(0)), }); const widgetFrameResolvedBorderRadiusSchema = z.strictObject({ topLeft: widgetFrameResolvedBorderRadiusCornerSchema, topRight: widgetFrameResolvedBorderRadiusCornerSchema, bottomRight: widgetFrameResolvedBorderRadiusCornerSchema, bottomLeft: widgetFrameResolvedBorderRadiusCornerSchema, }); const widgetFrameResolvedBoxShadowLayerSchema = z.strictObject({ color: z.strictObject({ colorSpace: z.literal("srgb"), red: srgbChannelSchema, green: srgbChannelSchema, blue: srgbChannelSchema, alpha: alphaChannelSchema, }), offsetXPx: finiteNumberSchema, offsetYPx: finiteNumberSchema, blurRadiusPx: finiteNumberSchema.check(z.minimum(0)), spreadRadiusPx: finiteNumberSchema, inset: z.boolean(), }); const commandErrorSchema = z.strictObject({ message: nonEmptyStringSchema, code: z.optional(nonEmptyStringSchema), }); export const widgetFrameAppearanceSchema = z.pipe( z.custom(isWidgetFrameAppearance, { message: "Expected an explicit widget frame appearance", }), z.discriminatedUnion("kind", [ z.strictObject({ kind: z.literal("none"), }), z.strictObject({ kind: z.literal("page"), border: z.string(), borderRadius: z.string(), boxShadow: z.string(), resolvedBorder: z.optional(widgetFrameResolvedBorderSchema), resolvedBorderRadius: z.optional(widgetFrameResolvedBorderRadiusSchema), resolvedBoxShadow: z.optional( z.array(widgetFrameResolvedBoxShadowLayerSchema), ), }), ]), ); const commandSettledDetailBase = { requestId: nonEmptyStringSchema, instanceId: z.nullable(nonEmptyStringSchema), kind: allCommandKindSchema, }; /** * Canonical shape when a command (open, prerender, prefetch) settles and core * has allocated a flow handle. flowRunId is present for open/prerender, absent for prefetch. */ export const flowHandleSettlementResultSchema = z.strictObject({ flowHandleId: nonEmptyStringSchema, flowRunId: z.optional(nonEmptyStringSchema), }); export type FlowHandleSettlementResult = z.output< typeof flowHandleSettlementResultSchema >; const commandSettledSuccessDetailSchema = z.strictObject({ ...commandSettledDetailBase, ok: z.literal(true), result: z.optional(z.unknown()), }); const commandSettledFailureDetailSchema = z.strictObject({ ...commandSettledDetailBase, ok: z.literal(false), error: commandErrorSchema, }); export const commandSettledDetailSchema = z.discriminatedUnion("ok", [ commandSettledSuccessDetailSchema, commandSettledFailureDetailSchema, ]); export type { CommandSettledDetail } from "./host-event-contract.js"; const flowRenderStateFields = { isOpen: z.boolean(), isLoading: z.boolean(), width: z.optional(z.number()), height: z.optional(z.number()), } as const; const flowTransitionFields = { transitionPolicy: z.optional( z.discriminatedUnion("kind", [ z.strictObject({ kind: z.literal("instant"), durationMs: z.literal(0), }), z.strictObject({ kind: z.literal("timed"), durationMs: finiteNumberSchema.check(z.positive()), easing: z.tuple([ cubicBezierXControlPointSchema, finiteNumberSchema, cubicBezierXControlPointSchema, finiteNumberSchema, ]), }), ]), ), } as const; const flowStateChangedBase = { instanceId: nonEmptyStringSchema, ...flowRenderStateFields, ...flowTransitionFields, } as const; export const widgetLayoutTransitionScheduleSchema = z.strictObject({ sequence: z.number().check(z.int(), z.minimum(0)), transactionId: nonEmptyStringSchema, startAtEpochMs: finiteNumberSchema, }); export const flowStateChangedDetailSchema = z.strictObject({ ...flowStateChangedBase, flowHandleId: nonEmptyStringSchema, }); export type { FlowStateChangedDetail } from "./host-event-contract.js"; export const flowLayoutUpdatedDetailSchema = z.strictObject({ instanceId: nonEmptyStringSchema, flowHandleId: nonEmptyStringSchema, width: finiteNumberSchema, height: finiteNumberSchema, transitionPolicy: z.strictObject({ kind: z.literal("timed"), durationMs: finiteNumberSchema.check(z.positive()), easing: z.tuple([ cubicBezierXControlPointSchema, finiteNumberSchema, cubicBezierXControlPointSchema, finiteNumberSchema, ]), }), }); export type { FlowLayoutUpdatedDetail } from "./host-event-contract.js"; export const instanceFlowStateChangedDetailSchema = z.strictObject({ ...flowStateChangedBase, shouldRender: z.optional(z.boolean()), flowHandleId: z.optional(nonEmptyStringSchema), }); export type { InstanceFlowStateChangedDetail } from "./host-event-contract.js"; const viewHostFlowRunSnapshotSchema = z.strictObject({ ...flowRenderStateFields, ...flowTransitionFields, frameAppearance: z.optional(widgetFrameAppearanceSchema), transitionSchedule: z.optional(widgetLayoutTransitionScheduleSchema), kind: z.literal("flowRun"), flowRunId: nonEmptyStringSchema, flowHandleId: z.optional(nonEmptyStringSchema), }); const viewHostPendingFlowHandleSnapshotSchema = z.strictObject({ ...flowRenderStateFields, ...flowTransitionFields, frameAppearance: z.optional(widgetFrameAppearanceSchema), transitionSchedule: z.optional(widgetLayoutTransitionScheduleSchema), kind: z.literal("pendingFlowHandle"), flowHandleId: nonEmptyStringSchema, }); export const viewHostSnapshotSchema = z.discriminatedUnion("kind", [ viewHostFlowRunSnapshotSchema, viewHostPendingFlowHandleSnapshotSchema, ]); export const instanceViewHostSnapshotsChangedDetailSchema = z.strictObject({ instanceId: nonEmptyStringSchema, snapshots: z.array(viewHostSnapshotSchema), }); export type { InstanceViewHostSnapshotsChangedDetail, ViewHostSnapshot, } from "./host-event-contract.js"; export const openRequestedDetailSchema = z.strictObject({ instanceId: nonEmptyStringSchema, source: z.enum(["command", "targeting"]), flowId: nonEmptyStringSchema, flowHandleId: z.optional(nonEmptyStringSchema), hideCloseButton: z.optional(z.boolean()), }); export type { OpenRequestedDetail } from "./host-event-contract.js"; export const navigationRequestedDetailSchema = z .strictObject({ instanceId: nonEmptyStringSchema, url: z.string().check( z.url(), z.refine( (value) => { try { const protocol = new URL(value).protocol; return protocol === "http:" || protocol === "https:"; } catch { return false; } }, { message: "Expected an HTTP(S) URL." }, ), ), target: z.optional(z.enum(["_blank", "self", "blank"])), version: z.number().check(z.int(), z.positive()), settle: z.optional( z.custom<(outcome: NavigationOutcome) => void>( (value) => typeof value === "function", ), ), }) .check( z.refine( (detail) => (detail.version === 1 && detail.target === "_blank") || (detail.version > 1 && (detail.target === undefined || detail.target === "self" || detail.target === "blank")), { message: "Target does not match the open-url event version." }, ), ); export type { NavigationRequestedDetail } from "./host-event-contract.js"; export const handleInvalidatedDetailSchema = z.strictObject({ instanceId: nonEmptyStringSchema, handleKind: nonEmptyStringSchema, handleId: nonEmptyStringSchema, reasonCode: z.enum(HANDLE_INVALIDATED_REASON_CODES), reasonMessage: z.optional(nonEmptyStringSchema), relatedRequestId: z.optional(nonEmptyStringSchema), source: z.enum(HANDLE_INVALIDATED_SOURCES), at: z.number(), }); export type { HandleInvalidatedDetail } from "./host-event-contract.js"; /** * Parse unknown result into FlowHandleSettlementResult. Returns null if invalid. */ export function parseFlowHandleSettlementResult( result: unknown, ): FlowHandleSettlementResult | null { const parsed = flowHandleSettlementResultSchema.safeParse(result); return parsed.success ? parsed.data : null; } /** * Extract flow handle from a successful command settlement when the command * is one that can return a handle (open, prerender, prefetch). Returns null * if detail is not ok, kind is not one of those, or result does not match * flowHandleSettlementResultSchema. */ export function getFlowHandleFromSettlementDetail(detail: { ok: boolean; kind: string; result?: unknown; }): FlowHandleSettlementResult | null { if ( !detail.ok || (detail.kind !== "open" && detail.kind !== "prerender" && detail.kind !== "prefetch") ) { return null; } return parseFlowHandleSettlementResult(detail.result); } export type { FlowHandleAllocationFromSettlement } from "./host-event-contract.js"; export { getFlowHandleAllocationFromSettlement } from "./host-event-contract.js";