@getuserfeedback/protocol 0.6.1 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/dist/app-event.d.ts +779 -10
  2. package/dist/app-event.d.ts.map +1 -1
  3. package/dist/app-event.js +81 -8
  4. package/dist/flow-assignments.d.ts +7 -4
  5. package/dist/flow-assignments.d.ts.map +1 -1
  6. package/dist/flow-assignments.js +15 -5
  7. package/dist/host/constants.d.ts +2 -2
  8. package/dist/host/constants.d.ts.map +1 -1
  9. package/dist/host/constants.js +3 -1
  10. package/dist/host/external-id-argument-guards.d.ts +10 -0
  11. package/dist/host/external-id-argument-guards.d.ts.map +1 -0
  12. package/dist/host/external-id-argument-guards.js +27 -0
  13. package/dist/host/index.d.ts +1 -0
  14. package/dist/host/index.d.ts.map +1 -1
  15. package/dist/host/index.js +1 -0
  16. package/dist/host/sdk-types.d.ts +92 -27
  17. package/dist/host/sdk-types.d.ts.map +1 -1
  18. package/dist/host/sdk.d.ts.map +1 -1
  19. package/dist/identity-type.d.ts +2 -1
  20. package/dist/identity-type.d.ts.map +1 -1
  21. package/dist/identity-type.js +1 -1
  22. package/dist/index.d.ts +3 -87
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js +1 -6
  25. package/dist/widget-commands.d.ts +124 -3
  26. package/dist/widget-commands.d.ts.map +1 -1
  27. package/dist/widget-commands.js +10 -0
  28. package/dist/widget-config.d.ts +23 -5
  29. package/dist/widget-config.d.ts.map +1 -1
  30. package/dist/widget-config.js +2 -1
  31. package/package.json +22 -2
  32. package/src/app-event.ts +235 -0
  33. package/src/client-meta.ts +25 -0
  34. package/src/defaults.ts +4 -0
  35. package/src/errors.ts +58 -0
  36. package/src/flow-assignments.test.ts +82 -0
  37. package/src/flow-assignments.ts +151 -0
  38. package/src/host/command-dispatch.ts +59 -0
  39. package/src/host/command-envelope.ts +41 -0
  40. package/src/host/command-settlement.ts +121 -0
  41. package/src/host/constants.ts +103 -0
  42. package/src/host/external-id-argument-guards.ts +57 -0
  43. package/src/host/host-event-contract.ts +277 -0
  44. package/src/host/index.ts +14 -0
  45. package/src/host/lazy-handle-client.ts +207 -0
  46. package/src/host/request-id.ts +3 -0
  47. package/src/host/sdk-types.ts +528 -0
  48. package/src/host/sdk.ts +43 -0
  49. package/src/host/unique-id.ts +17 -0
  50. package/src/identity-type.ts +96 -0
  51. package/src/index.ts +139 -0
  52. package/src/protocol-root.test.ts +582 -0
  53. package/src/public-grant-scope.ts +25 -0
  54. package/src/runtime-endpoints.ts +69 -0
  55. package/src/scopes.ts +79 -0
  56. package/src/trpc-envelope.ts +9 -0
  57. package/src/version-resolution.ts +18 -0
  58. package/src/widget-commands.ts +162 -0
  59. package/src/widget-config.ts +164 -0
@@ -0,0 +1,59 @@
1
+ import { createCommandEnvelope } from "./command-envelope.js";
2
+ import { waitForCommandSettlement } from "./command-settlement.js";
3
+ import type { CommandEnvelope, PublicCommandPayload } from "./sdk-types.js";
4
+
5
+ /**
6
+ * A command input: the pure payload with `kind` and command-specific fields.
7
+ * Distributes over the PublicCommandPayload union so discriminant
8
+ * narrowing is preserved (e.g. `{ kind: "open", flowId }` type-checks).
9
+ */
10
+ export type CommandInput = PublicCommandPayload extends infer C
11
+ ? C extends PublicCommandPayload
12
+ ? C
13
+ : never
14
+ : never;
15
+
16
+ type DispatchOptions = {
17
+ idempotencyKey?: string;
18
+ };
19
+
20
+ /**
21
+ * Creates a dispatch function that wraps a command payload in an envelope,
22
+ * pushes it into the queue, and returns a promise that settles when the
23
+ * runtime acknowledges it.
24
+ *
25
+ * Request IDs are generated automatically.
26
+ *
27
+ * @example
28
+ * ```ts
29
+ * const dispatch = createCommandDispatch((env) => queue.push(env));
30
+ * await dispatch({ kind: "open", flowId: "f1", flowHandleId: "h1" });
31
+ * ```
32
+ */
33
+ export const createCommandDispatch = (
34
+ push: (envelope: CommandEnvelope) => void,
35
+ ): ((input: CommandInput, options?: DispatchOptions) => Promise<void>) => {
36
+ return async (
37
+ input: CommandInput,
38
+ options?: DispatchOptions,
39
+ ): Promise<void> => {
40
+ const envelope: CommandEnvelope = createCommandEnvelope({
41
+ command: input,
42
+ idempotencyKey:
43
+ resolveIdempotencyKey(options?.idempotencyKey) ?? undefined,
44
+ });
45
+ push(envelope);
46
+ await waitForCommandSettlement({ requestId: envelope.requestId });
47
+ };
48
+ };
49
+
50
+ const resolveIdempotencyKey = (value?: string): string | null => {
51
+ if (value === undefined) {
52
+ return null;
53
+ }
54
+ const idempotencyKey = value.trim();
55
+ if (!idempotencyKey) {
56
+ throw new Error("idempotencyKey must be a non-empty string");
57
+ }
58
+ return idempotencyKey;
59
+ };
@@ -0,0 +1,41 @@
1
+ import { createCommandRequestId } from "./request-id.js";
2
+ import type {
3
+ ClientMeta,
4
+ CommandEnvelope,
5
+ PublicCommandPayload,
6
+ } from "./sdk-types.js";
7
+
8
+ export type CreateCommandEnvelopeInput = {
9
+ command: PublicCommandPayload;
10
+ requestId?: string | undefined;
11
+ idempotencyKey?: string | undefined;
12
+ instanceId?: string | undefined;
13
+ clientMeta?: ClientMeta | undefined;
14
+ };
15
+
16
+ const normalizeOptionalString = (value?: string): string | undefined => {
17
+ if (value === undefined) {
18
+ return undefined;
19
+ }
20
+ const normalized = value.trim();
21
+ return normalized.length > 0 ? normalized : undefined;
22
+ };
23
+
24
+ export const createCommandEnvelope = (
25
+ input: CreateCommandEnvelopeInput,
26
+ ): CommandEnvelope => {
27
+ const requestId =
28
+ normalizeOptionalString(input.requestId) ?? createCommandRequestId();
29
+ const idempotencyKey =
30
+ normalizeOptionalString(input.idempotencyKey) ?? requestId;
31
+ const instanceId = normalizeOptionalString(input.instanceId);
32
+
33
+ return {
34
+ version: "1",
35
+ command: input.command,
36
+ requestId,
37
+ idempotencyKey,
38
+ ...(instanceId !== undefined ? { instanceId } : {}),
39
+ ...(input.clientMeta !== undefined ? { clientMeta: input.clientMeta } : {}),
40
+ };
41
+ };
@@ -0,0 +1,121 @@
1
+ import { HOST_EVENT_KEYS, toHostEventName } from "./constants.js";
2
+ import {
3
+ type CommandSettledDetail,
4
+ isCommandSettledDetail,
5
+ } from "./host-event-contract.js";
6
+
7
+ type WaitForCommandSettlementInput = {
8
+ requestId: string;
9
+ windowRef?: Window;
10
+ timeoutMs?: number;
11
+ };
12
+
13
+ const DEFAULT_SETTLEMENT_TIMEOUT_MS = 30_000;
14
+ export const COMMAND_SETTLEMENT_TIMEOUT_CODE =
15
+ "COMMAND_SETTLEMENT_TIMEOUT" as const;
16
+
17
+ export class CommandSettlementError extends Error {
18
+ readonly code?: string;
19
+ readonly requestId: string;
20
+ readonly kind: string;
21
+ readonly instanceId: string | null;
22
+
23
+ constructor(input: {
24
+ requestId: string;
25
+ kind: string;
26
+ instanceId: string | null;
27
+ message: string;
28
+ code?: string;
29
+ }) {
30
+ super(input.message);
31
+ this.name = "CommandSettlementError";
32
+ this.requestId = input.requestId;
33
+ this.kind = input.kind;
34
+ this.instanceId = input.instanceId;
35
+ if (input.code) {
36
+ this.code = input.code;
37
+ }
38
+ }
39
+ }
40
+
41
+ type CommandSettledFailureDetail = Extract<CommandSettledDetail, { ok: false }>;
42
+
43
+ export const toCommandSettlementError = (
44
+ detail: CommandSettledFailureDetail,
45
+ ): CommandSettlementError => {
46
+ return new CommandSettlementError({
47
+ requestId: detail.requestId,
48
+ kind: detail.kind,
49
+ instanceId: detail.instanceId,
50
+ message:
51
+ detail.error.message ??
52
+ `Command ${detail.kind} failed without an error payload`,
53
+ code: detail.error.code,
54
+ });
55
+ };
56
+
57
+ export class CommandSettlementTimeoutError extends Error {
58
+ readonly code = COMMAND_SETTLEMENT_TIMEOUT_CODE;
59
+ readonly requestId: string;
60
+
61
+ constructor(requestId: string) {
62
+ super(`Timed out waiting for command settlement: ${requestId}`);
63
+ this.name = "CommandSettlementTimeoutError";
64
+ this.requestId = requestId;
65
+ }
66
+ }
67
+
68
+ const eventHasDetail = (event: Event): event is Event & { detail: unknown } =>
69
+ typeof event === "object" && event !== null && "detail" in event;
70
+
71
+ export const waitForCommandSettlement = (
72
+ input: WaitForCommandSettlementInput,
73
+ ): Promise<CommandSettledDetail> => {
74
+ const requestId = input.requestId;
75
+ if (!requestId) {
76
+ return Promise.reject(new Error("requestId must be a non-empty string"));
77
+ }
78
+ const targetWindow =
79
+ input.windowRef ?? (typeof window === "undefined" ? undefined : window);
80
+ if (!targetWindow) {
81
+ return Promise.reject(
82
+ new Error("Cannot await command settlement without window"),
83
+ );
84
+ }
85
+ const eventName = toHostEventName(HOST_EVENT_KEYS.INSTANCE_COMMAND_SETTLED);
86
+ const timeoutMs = Math.max(
87
+ 0,
88
+ input.timeoutMs ?? DEFAULT_SETTLEMENT_TIMEOUT_MS,
89
+ );
90
+ return new Promise<CommandSettledDetail>((resolve, reject) => {
91
+ const cleanup = (): void => {
92
+ if (typeof targetWindow.removeEventListener === "function") {
93
+ targetWindow.removeEventListener(eventName, handleEvent);
94
+ }
95
+ };
96
+
97
+ const timeoutId = setTimeout(() => {
98
+ cleanup();
99
+ reject(new CommandSettlementTimeoutError(requestId));
100
+ }, timeoutMs);
101
+
102
+ const handleEvent = (event: Event): void => {
103
+ if (!eventHasDetail(event)) {
104
+ return;
105
+ }
106
+ const raw = event.detail;
107
+ if (!isCommandSettledDetail(raw) || raw.requestId !== requestId) {
108
+ return;
109
+ }
110
+ const detail = raw;
111
+ cleanup();
112
+ clearTimeout(timeoutId);
113
+ if (detail.ok) {
114
+ resolve(detail);
115
+ return;
116
+ }
117
+ reject(toCommandSettlementError(detail));
118
+ };
119
+ targetWindow.addEventListener(eventName, handleEvent);
120
+ });
121
+ };
@@ -0,0 +1,103 @@
1
+ import type { PublicCommandPayload } from "./sdk-types.js";
2
+
3
+ export const TELEMETRY_EVENTS = {
4
+ INSTANCE_READY: "instance_ready",
5
+ VIEW_DISPLAYED: "view_displayed",
6
+ VIEW_CLOSED: "view_closed",
7
+ FLOW_DISPLAYED: "flow_displayed",
8
+ FLOW_CLOSED: "flow_closed",
9
+ } as const;
10
+
11
+ export type TelemetryEventName =
12
+ (typeof TELEMETRY_EVENTS)[keyof typeof TELEMETRY_EVENTS];
13
+
14
+ // Data attribute used to anchor widget styles & root element. Shared so build
15
+ // scripts, runtime code and tests stay in sync.
16
+ export const WIDGET_SELECTOR = "[data-gx-widget]" as const;
17
+
18
+ export const PARENT_ORIGIN_PARAM = "gx_parent_origin" as const;
19
+
20
+ // Host event names (DOM events emitted by widget for external consumption)
21
+ // Uses prefix "getuserfeedback:" for namespacing
22
+ export const HOST_EVENT_PREFIX = "getuserfeedback:" as const;
23
+
24
+ export const HOST_EVENT_KEYS = {
25
+ INSTANCE_FLOW_STATE_CHANGED: "instance:flow:state-changed",
26
+ INSTANCE_FLOW_STATE_CHANGED_INSTANCE: "instance:flow:state-changed:instance",
27
+ INSTANCE_COMMAND_SETTLED: "instance:command:settled",
28
+ INSTANCE_HANDLE_INVALIDATED: "instance:handle:invalidated",
29
+ INSTANCE_OPEN_REQUESTED: "instance:open:requested",
30
+ INSTANCE_COMMAND_UNSUPPORTED: "instance:command:unsupported",
31
+ INSTANCE_READY: "instance:ready",
32
+ INSTANCE_BRIDGE_MOUNTED: "instance:bridge:mounted",
33
+ INSTANCE_BRIDGE_UNMOUNTED: "instance:bridge:unmounted",
34
+ INSTANCE_IFRAME_READY: "instance:iframe:ready",
35
+ INSTANCE_ERROR: "instance:error",
36
+ INSTANCE_APP_EVENT: "instance:app-event",
37
+ } as const;
38
+
39
+ type SdkCapabilityCommandKind = Exclude<PublicCommandPayload["kind"], "init">;
40
+
41
+ const SDK_COMMAND_CAPABILITY_BY_KIND = {
42
+ configure: "gx.command.configure.v1",
43
+ open: "gx.command.open.v1",
44
+ prefetch: "gx.command.prefetch.v1",
45
+ prerender: "gx.command.prerender.v1",
46
+ identify: "gx.command.identify.v1",
47
+ setContainer: "gx.command.setContainer.v1",
48
+ close: "gx.command.close.v1",
49
+ reset: "gx.command.reset.v1",
50
+ setDefaultContainerPolicy: "gx.command.setDefaultContainerPolicy.v1",
51
+ track: "gx.command.track.v1",
52
+ } as const satisfies Record<
53
+ SdkCapabilityCommandKind,
54
+ `gx.command.${string}.v${number}`
55
+ >;
56
+
57
+ const SDK_COMMAND_CAPABILITIES = [
58
+ SDK_COMMAND_CAPABILITY_BY_KIND.configure,
59
+ SDK_COMMAND_CAPABILITY_BY_KIND.open,
60
+ SDK_COMMAND_CAPABILITY_BY_KIND.prefetch,
61
+ SDK_COMMAND_CAPABILITY_BY_KIND.prerender,
62
+ SDK_COMMAND_CAPABILITY_BY_KIND.identify,
63
+ SDK_COMMAND_CAPABILITY_BY_KIND.setContainer,
64
+ SDK_COMMAND_CAPABILITY_BY_KIND.close,
65
+ SDK_COMMAND_CAPABILITY_BY_KIND.reset,
66
+ SDK_COMMAND_CAPABILITY_BY_KIND.setDefaultContainerPolicy,
67
+ SDK_COMMAND_CAPABILITY_BY_KIND.track,
68
+ ] as const;
69
+
70
+ type SdkCommandCapability =
71
+ (typeof SDK_COMMAND_CAPABILITY_BY_KIND)[keyof typeof SDK_COMMAND_CAPABILITY_BY_KIND];
72
+ type _AssertAllCommandCapabilitiesAreAnnounced =
73
+ Exclude<
74
+ SdkCommandCapability,
75
+ (typeof SDK_COMMAND_CAPABILITIES)[number]
76
+ > extends never
77
+ ? true
78
+ : never;
79
+ type _AssertNoUnknownCommandCapabilities =
80
+ Exclude<
81
+ (typeof SDK_COMMAND_CAPABILITIES)[number],
82
+ SdkCommandCapability
83
+ > extends never
84
+ ? true
85
+ : never;
86
+
87
+ /**
88
+ * Stable capability tokens announced by the browser SDK during init.
89
+ * These are versioned and namespaced to support forward-compatible negotiation.
90
+ */
91
+ export const SDK_PROTOCOL_CAPABILITIES = [
92
+ "gx.protocol.public.v1",
93
+ ...SDK_COMMAND_CAPABILITIES,
94
+ ] as const;
95
+
96
+ export type SdkProtocolCapability = (typeof SDK_PROTOCOL_CAPABILITIES)[number];
97
+
98
+ export const LOADER_RUNTIME_GLOBAL_KEY = "__getuserfeedback_runtime" as const;
99
+ export const LOADER_RUNTIME_PROTOCOL_VERSION = "v2" as const;
100
+
101
+ // Helper to construct full event name with prefix
102
+ export const toHostEventName = (key: string): string =>
103
+ `${HOST_EVENT_PREFIX}${key}`;
@@ -0,0 +1,57 @@
1
+ type ExternalIdArgumentCommandName = "identify" | "track";
2
+ type ExternalIdArgumentValueName = "traits" | "properties";
3
+
4
+ const isAmbiguousExternalIdsArgument = (value: unknown): boolean => {
5
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
6
+ return false;
7
+ }
8
+
9
+ const entries = Object.entries(value);
10
+ const externalIds = entries[0]?.[1];
11
+ return (
12
+ entries.length === 1 &&
13
+ entries[0]?.[0] === "externalIds" &&
14
+ Array.isArray(externalIds) &&
15
+ externalIds.length > 0 &&
16
+ externalIds.every(isSegmentExternalIdLike)
17
+ );
18
+ };
19
+
20
+ const isSegmentExternalIdLike = (
21
+ value: unknown,
22
+ ): value is {
23
+ id: string;
24
+ type: string;
25
+ collection: "users";
26
+ encoding: "none";
27
+ } => {
28
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
29
+ return false;
30
+ }
31
+ return (
32
+ typeof Reflect.get(value, "id") === "string" &&
33
+ typeof Reflect.get(value, "type") === "string" &&
34
+ Reflect.get(value, "collection") === "users" &&
35
+ Reflect.get(value, "encoding") === "none"
36
+ );
37
+ };
38
+
39
+ export const assertNoAmbiguousExternalIdsArgument = ({
40
+ argument,
41
+ options,
42
+ commandName,
43
+ valueArgumentName,
44
+ }: {
45
+ argument: unknown;
46
+ options: unknown;
47
+ commandName: ExternalIdArgumentCommandName;
48
+ valueArgumentName: ExternalIdArgumentValueName;
49
+ }): void => {
50
+ if (options !== undefined || !isAmbiguousExternalIdsArgument(argument)) {
51
+ return;
52
+ }
53
+
54
+ throw new Error(
55
+ `${commandName} received externalIds in the ${valueArgumentName} argument. Pass undefined for ${valueArgumentName} and provide externalIds as options instead.`,
56
+ );
57
+ };
@@ -0,0 +1,277 @@
1
+ /**
2
+ * Types and type guards for flow state and lifecycle events.
3
+ */
4
+
5
+ /** Current state of the flow: whether it is open, opening, and its dimensions (if known). */
6
+ export interface FlowState {
7
+ /** True when the flow is visible. */
8
+ isOpen: boolean;
9
+ /**
10
+ * True after an open request while the flow is not visible yet. Background
11
+ * prefetching or prerendering without open intent must not set this.
12
+ */
13
+ isLoading: boolean;
14
+ /** Flow width in pixels when known. */
15
+ width?: number;
16
+ /** Flow height in pixels when known. */
17
+ height?: number;
18
+ }
19
+
20
+ export interface FlowStateChangedDetail extends FlowState {
21
+ instanceId: string;
22
+ flowHandleId: string;
23
+ }
24
+
25
+ export interface InstanceFlowStateChangedDetail extends FlowState {
26
+ instanceId: string;
27
+ }
28
+
29
+ export interface OpenRequestedDetail {
30
+ instanceId: string;
31
+ source: "command" | "targeting";
32
+ flowId: string;
33
+ flowHandleId?: string;
34
+ hideCloseButton?: boolean;
35
+ }
36
+
37
+ export const HANDLE_INVALIDATED_REASON_CODES: readonly [
38
+ "RESET",
39
+ "CLOSED",
40
+ "STALE_HANDLE",
41
+ "OWNERSHIP_CONFLICT",
42
+ "OWNER_DISPOSED",
43
+ "UPSTREAM_INVALIDATED",
44
+ "INTERNAL",
45
+ ] = [
46
+ "RESET",
47
+ "CLOSED",
48
+ "STALE_HANDLE",
49
+ "OWNERSHIP_CONFLICT",
50
+ "OWNER_DISPOSED",
51
+ "UPSTREAM_INVALIDATED",
52
+ "INTERNAL",
53
+ ];
54
+
55
+ export type HandleInvalidatedReasonCode =
56
+ (typeof HANDLE_INVALIDATED_REASON_CODES)[number];
57
+
58
+ export const HANDLE_INVALIDATED_SOURCES: readonly ["loader", "core"] = [
59
+ "loader",
60
+ "core",
61
+ ];
62
+
63
+ export type HandleInvalidatedSource =
64
+ (typeof HANDLE_INVALIDATED_SOURCES)[number];
65
+
66
+ /** Generic handle lifecycle event emitted when a previously issued handle is invalidated upstream. */
67
+ export interface HandleInvalidatedDetail {
68
+ instanceId: string;
69
+ handleKind: string;
70
+ handleId: string;
71
+ reasonCode: HandleInvalidatedReasonCode;
72
+ reasonMessage?: string;
73
+ relatedRequestId?: string;
74
+ source: HandleInvalidatedSource;
75
+ at: number;
76
+ }
77
+
78
+ const isNonEmptyString = (v: unknown): v is string =>
79
+ typeof v === "string" && v.trim().length > 0;
80
+
81
+ const isRecord = (v: unknown): v is Record<string, unknown> =>
82
+ typeof v === "object" && v !== null;
83
+
84
+ const HANDLE_INVALIDATED_REASON_CODE_SET: ReadonlySet<string> = new Set(
85
+ HANDLE_INVALIDATED_REASON_CODES,
86
+ );
87
+
88
+ const isHandleInvalidatedReasonCode = (
89
+ v: unknown,
90
+ ): v is HandleInvalidatedReasonCode =>
91
+ typeof v === "string" && HANDLE_INVALIDATED_REASON_CODE_SET.has(v);
92
+
93
+ const HANDLE_INVALIDATED_SOURCE_SET: ReadonlySet<string> = new Set(
94
+ HANDLE_INVALIDATED_SOURCES,
95
+ );
96
+
97
+ const isHandleInvalidatedSource = (v: unknown): v is HandleInvalidatedSource =>
98
+ typeof v === "string" && HANDLE_INVALIDATED_SOURCE_SET.has(v);
99
+
100
+ export function isFlowStateChangedDetail(
101
+ detail: unknown,
102
+ ): detail is FlowStateChangedDetail {
103
+ if (!isRecord(detail)) return false;
104
+ const { instanceId, isOpen, isLoading, width, height, flowHandleId } = detail;
105
+ return (
106
+ isNonEmptyString(instanceId) &&
107
+ typeof isOpen === "boolean" &&
108
+ typeof isLoading === "boolean" &&
109
+ (typeof width === "undefined" || typeof width === "number") &&
110
+ (typeof height === "undefined" || typeof height === "number") &&
111
+ isNonEmptyString(flowHandleId)
112
+ );
113
+ }
114
+
115
+ export function isInstanceFlowStateChangedDetail(
116
+ detail: unknown,
117
+ ): detail is InstanceFlowStateChangedDetail {
118
+ if (!isRecord(detail)) return false;
119
+ const { instanceId, isOpen, isLoading, width, height } = detail;
120
+ return (
121
+ isNonEmptyString(instanceId) &&
122
+ typeof isOpen === "boolean" &&
123
+ typeof isLoading === "boolean" &&
124
+ (typeof width === "undefined" || typeof width === "number") &&
125
+ (typeof height === "undefined" || typeof height === "number")
126
+ );
127
+ }
128
+
129
+ export function isOpenRequestedDetail(
130
+ detail: unknown,
131
+ ): detail is OpenRequestedDetail {
132
+ if (!isRecord(detail)) return false;
133
+ const { instanceId, source, flowId, flowHandleId, hideCloseButton } = detail;
134
+ return (
135
+ isNonEmptyString(instanceId) &&
136
+ (source === "command" || source === "targeting") &&
137
+ isNonEmptyString(flowId) &&
138
+ (typeof flowHandleId === "undefined" || isNonEmptyString(flowHandleId)) &&
139
+ (typeof hideCloseButton === "undefined" ||
140
+ typeof hideCloseButton === "boolean")
141
+ );
142
+ }
143
+
144
+ export function isHandleInvalidatedDetail(
145
+ detail: unknown,
146
+ ): detail is HandleInvalidatedDetail {
147
+ if (!isRecord(detail)) return false;
148
+ const {
149
+ instanceId,
150
+ handleKind,
151
+ handleId,
152
+ reasonCode,
153
+ reasonMessage,
154
+ relatedRequestId,
155
+ source,
156
+ at,
157
+ } = detail;
158
+ return (
159
+ isNonEmptyString(instanceId) &&
160
+ isNonEmptyString(handleKind) &&
161
+ isNonEmptyString(handleId) &&
162
+ isHandleInvalidatedReasonCode(reasonCode) &&
163
+ (typeof reasonMessage === "undefined" ||
164
+ typeof reasonMessage === "string") &&
165
+ (typeof relatedRequestId === "undefined" ||
166
+ isNonEmptyString(relatedRequestId)) &&
167
+ isHandleInvalidatedSource(source) &&
168
+ typeof at === "number" &&
169
+ Number.isFinite(at)
170
+ );
171
+ }
172
+
173
+ export interface CommandSettledSuccessDetail {
174
+ requestId: string;
175
+ instanceId: string | null;
176
+ kind: string;
177
+ ok: true;
178
+ result?: unknown;
179
+ }
180
+
181
+ export interface CommandSettledFailureDetail {
182
+ requestId: string;
183
+ instanceId: string | null;
184
+ kind: string;
185
+ ok: false;
186
+ error: { message: string; code?: string };
187
+ }
188
+
189
+ export type CommandSettledDetail =
190
+ | CommandSettledSuccessDetail
191
+ | CommandSettledFailureDetail;
192
+
193
+ export function isCommandSettledDetail(
194
+ detail: unknown,
195
+ ): detail is CommandSettledDetail {
196
+ if (!isRecord(detail)) return false;
197
+ const { requestId, instanceId, kind, ok, error } = detail;
198
+ if (
199
+ !isNonEmptyString(requestId) ||
200
+ (typeof instanceId !== "string" && instanceId !== null) ||
201
+ typeof kind !== "string" ||
202
+ typeof ok !== "boolean"
203
+ ) {
204
+ return false;
205
+ }
206
+ if (ok === true) {
207
+ return true;
208
+ }
209
+ if (ok === false && isRecord(error)) {
210
+ return typeof error.message === "string";
211
+ }
212
+ return false;
213
+ }
214
+
215
+ /** Result of extracting a flow handle from a successful open/prerender/prefetch settlement. */
216
+ export type FlowHandleFromSettlement = {
217
+ flowHandleId: string;
218
+ flowRunId?: string;
219
+ };
220
+
221
+ /**
222
+ * Host-facing handle result: same shape as FlowHandleFromSettlement.
223
+ * Used when projecting instance:command:settled so the SDK receives flowHandleId (and flowRunId when present).
224
+ */
225
+ export type HostHandleSettlementResult = FlowHandleFromSettlement;
226
+
227
+ /**
228
+ * Return the host-facing handle result for a command settlement, or null.
229
+ * Use in the loader when projecting instance:command:settled; keeps the host contract in one place (no zod).
230
+ */
231
+ export function getHostHandleResultFromSettlement(detail: {
232
+ ok: boolean;
233
+ kind: string;
234
+ result?: unknown;
235
+ }): HostHandleSettlementResult | null {
236
+ return getFlowHandleFromSettlementDetail(detail);
237
+ }
238
+
239
+ const HANDLE_RETURNING_KINDS: readonly ["open", "prerender", "prefetch"] = [
240
+ "open",
241
+ "prerender",
242
+ "prefetch",
243
+ ];
244
+ type HandleReturningKind = (typeof HANDLE_RETURNING_KINDS)[number];
245
+ const HANDLE_RETURNING_KIND_SET: ReadonlySet<string> = new Set(
246
+ HANDLE_RETURNING_KINDS,
247
+ );
248
+ const isHandleReturningKind = (kind: string): kind is HandleReturningKind =>
249
+ HANDLE_RETURNING_KIND_SET.has(kind);
250
+
251
+ /**
252
+ * Extract flow handle from a successful command settlement when the command
253
+ * is one that can return a handle (open, prerender, prefetch). Uses plain
254
+ * checks only (no Zod). Returns null if detail is not ok, kind is not one of
255
+ * those, or result does not contain a non-empty flowHandleId.
256
+ */
257
+ export function getFlowHandleFromSettlementDetail(detail: {
258
+ ok: boolean;
259
+ kind: string;
260
+ result?: unknown;
261
+ }): FlowHandleFromSettlement | null {
262
+ if (!detail.ok || !isHandleReturningKind(detail.kind)) {
263
+ return null;
264
+ }
265
+ const result = detail.result;
266
+ if (!isRecord(result)) {
267
+ return null;
268
+ }
269
+ const { flowHandleId, flowRunId } = result;
270
+ if (!isNonEmptyString(flowHandleId)) {
271
+ return null;
272
+ }
273
+ return {
274
+ flowHandleId,
275
+ ...(isNonEmptyString(flowRunId) && { flowRunId }),
276
+ };
277
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Host-page / npm SDK protocol surface (Zod-free).
3
+ */
4
+
5
+ export * from "./command-dispatch.js";
6
+ export * from "./command-envelope.js";
7
+ export * from "./command-settlement.js";
8
+ export * from "./constants.js";
9
+ export * from "./external-id-argument-guards.js";
10
+ export * from "./host-event-contract.js";
11
+ export * from "./lazy-handle-client.js";
12
+ export { createCommandRequestId } from "./request-id.js";
13
+ export * from "./sdk-types.js";
14
+ export { createUniqueId } from "./unique-id.js";