diff --git a/platforms/web/package.json b/platforms/web/package.json index 0251d45fc..fac18d0e2 100644 --- a/platforms/web/package.json +++ b/platforms/web/package.json @@ -28,13 +28,20 @@ "import": "./dist/index.js", "default": "./dist/index.js" }, + "./universal": { + "types": "./dist/universal.d.ts", + "import": "./dist/universal.js", + "default": "./dist/universal.js" + }, "./custom-elements.json": "./dist/custom-elements.json", "./package.json": "./package.json" }, "sideEffects": [ "./src/index.ts", "./src/checkout-web-component.ts", - "./dist/index.js" + "./src/universal.ts", + "./dist/index.js", + "./dist/universal.js" ], "files": [ "LICENSE", diff --git a/platforms/web/package.snapshot.json b/platforms/web/package.snapshot.json index 5c8d7138e..4a27650ab 100644 --- a/platforms/web/package.snapshot.json +++ b/platforms/web/package.snapshot.json @@ -3,6 +3,11 @@ "dist/index.d.ts", "dist/index.js", "dist/index.js.map", + "dist/telemetry-BhYuVVw_.js", + "dist/telemetry-BhYuVVw_.js.map", + "dist/universal.d.ts", + "dist/universal.js", + "dist/universal.js.map", "LICENSE", "package.json", "README.md" diff --git a/platforms/web/src/__fixtures__/provisional-universal-checkout-batches.json b/platforms/web/src/__fixtures__/provisional-universal-checkout-batches.json new file mode 100644 index 000000000..69e610a85 --- /dev/null +++ b/platforms/web/src/__fixtures__/provisional-universal-checkout-batches.json @@ -0,0 +1,281 @@ +{ + "ready": { + "request": [ + { + "jsonrpc": "2.0", + "id": "ready_1", + "method": "ec.ready", + "params": { + "delegate": ["window.open"] + } + }, + { + "jsonrpc": "2.0", + "id": "ready_2", + "method": "ec.ready", + "params": { + "delegate": ["window.open"] + } + } + ], + "response": [ + { + "jsonrpc": "2.0", + "id": "ready_1", + "result": { + "ucp": { + "version": "2026-08-25", + "status": "success" + } + } + }, + { + "jsonrpc": "2.0", + "id": "ready_2", + "result": { + "ucp": { + "version": "2026-08-25", + "status": "success" + } + } + } + ] + }, + "start": [ + { + "jsonrpc": "2.0", + "method": "ec.start", + "params": { + "context": { + "session_id": "gid://shopify/UniversalCheckoutSession/123", + "revision": 1, + "shop_id": "gid://shopify/Shop/123" + }, + "checkout": { + "ucp": { + "version": "2026-08-25", + "capabilities": {}, + "payment_handlers": {} + }, + "id": "gid://shopify/Checkout/123", + "line_items": [ + { + "id": "line_1", + "item": { + "id": "sku_canvas_tote", + "title": "Canvas tote", + "price": 12000 + }, + "quantity": 1, + "totals": [ + { "type": "subtotal", "amount": 12000 }, + { "type": "total", "amount": 12000 } + ] + } + ], + "status": "incomplete", + "currency": "USD", + "totals": [ + { "type": "subtotal", "amount": 12000 }, + { "type": "total", "amount": 12000 } + ], + "links": [] + } + } + }, + { + "jsonrpc": "2.0", + "method": "ec.start", + "params": { + "context": { + "session_id": "gid://shopify/UniversalCheckoutSession/123", + "revision": 1, + "shop_id": "gid://shopify/Shop/456" + }, + "checkout": { + "ucp": { + "version": "2026-08-25", + "capabilities": {}, + "payment_handlers": {} + }, + "id": "gid://shopify/Checkout/456", + "line_items": [ + { + "id": "line_2", + "item": { + "id": "sku_wool_scarf", + "title": "Wool scarf", + "price": 8000 + }, + "quantity": 1, + "totals": [ + { "type": "subtotal", "amount": 8000 }, + { "type": "total", "amount": 8000 } + ] + } + ], + "status": "incomplete", + "currency": "USD", + "totals": [ + { "type": "subtotal", "amount": 8000 }, + { "type": "total", "amount": 8000 } + ], + "links": [] + } + } + } + ], + "update": [ + { + "jsonrpc": "2.0", + "method": "ec.update", + "params": { + "context": { + "session_id": "gid://shopify/UniversalCheckoutSession/123", + "revision": 2, + "shop_id": "gid://shopify/Shop/123" + }, + "checkout": { + "ucp": { + "version": "2026-08-25", + "capabilities": {}, + "payment_handlers": {} + }, + "id": "gid://shopify/Checkout/123", + "line_items": [ + { + "id": "line_1", + "item": { + "id": "sku_canvas_tote", + "title": "Canvas tote", + "price": 12000 + }, + "quantity": 2, + "totals": [ + { "type": "subtotal", "amount": 24000 }, + { "type": "total", "amount": 24000 } + ] + } + ], + "status": "ready_for_complete", + "currency": "USD", + "totals": [ + { "type": "subtotal", "amount": 24000 }, + { "type": "total", "amount": 24000 } + ], + "links": [] + } + } + } + ], + "complete": [ + { + "jsonrpc": "2.0", + "method": "ec.complete", + "params": { + "context": { + "session_id": "gid://shopify/UniversalCheckoutSession/123", + "revision": 3, + "shop_id": "gid://shopify/Shop/123" + }, + "checkout": { + "ucp": { + "version": "2026-08-25", + "capabilities": {}, + "payment_handlers": {} + }, + "id": "gid://shopify/Checkout/123", + "line_items": [], + "status": "completed", + "currency": "USD", + "totals": [{ "type": "total", "amount": 24000 }], + "links": [], + "order": { + "id": "gid://shopify/Order/123", + "permalink_url": "https://shop.example.com/orders/123" + } + } + } + }, + { + "jsonrpc": "2.0", + "method": "ec.complete", + "params": { + "context": { + "session_id": "gid://shopify/UniversalCheckoutSession/123", + "revision": 3, + "shop_id": "gid://shopify/Shop/456" + }, + "checkout": { + "ucp": { + "version": "2026-08-25", + "capabilities": {}, + "payment_handlers": {} + }, + "id": "gid://shopify/Checkout/456", + "line_items": [], + "status": "completed", + "currency": "USD", + "totals": [{ "type": "total", "amount": 8000 }], + "links": [], + "order": { + "id": "gid://shopify/Order/456", + "permalink_url": "https://shop.example.com/orders/456" + } + } + } + } + ], + "error": [ + { + "jsonrpc": "2.0", + "method": "ec.error", + "params": { + "context": { + "session_id": "gid://shopify/UniversalCheckoutSession/123", + "revision": 4, + "shop_id": "gid://shopify/Shop/123" + }, + "error": { + "ucp": { + "version": "2026-08-25", + "status": "error" + }, + "messages": [ + { + "type": "error", + "code": "universal_checkout_unavailable", + "content": "Universal Checkout is unavailable.", + "severity": "unrecoverable" + } + ] + } + } + }, + { + "jsonrpc": "2.0", + "method": "ec.error", + "params": { + "context": { + "session_id": "gid://shopify/UniversalCheckoutSession/123", + "revision": 4, + "shop_id": "gid://shopify/Shop/456" + }, + "error": { + "ucp": { + "version": "2026-08-25", + "status": "error" + }, + "messages": [ + { + "type": "error", + "code": "universal_checkout_unavailable", + "content": "Universal Checkout is unavailable.", + "severity": "unrecoverable" + } + ] + } + } + } + ] +} diff --git a/platforms/web/src/universal.protocol.ts b/platforms/web/src/universal.protocol.ts new file mode 100644 index 000000000..228ae4de9 --- /dev/null +++ b/platforms/web/src/universal.protocol.ts @@ -0,0 +1,392 @@ +import { + EmbeddedCheckoutProtocol, + INVALID_PARAMS_CODE, + INVALID_PARAMS_MESSAGE, + METHOD_NOT_FOUND_CODE, + METHOD_NOT_FOUND_MESSAGE, + ProtocolValidationError, + type ErrorResponse, + type WindowOpenRequest, +} from "@shopify/checkout-kit-protocol"; + +import type { + UniversalCheckoutContext, + UniversalCheckoutFailure, + UniversalCheckoutEventType, + UniversalCheckoutSnapshot, +} from "./universal.types"; + +export const UNIVERSAL_CHECKOUT_PROTOCOL_VERSION = "2026-08-25"; + +export type JsonRpcId = string | number | null; + +export type JsonRpcResponse = + | { readonly jsonrpc: "2.0"; readonly id: JsonRpcId; readonly result: unknown } + | { + readonly jsonrpc: "2.0"; + readonly id: JsonRpcId; + readonly error: { readonly code: number; readonly message: string }; + }; + +export type UniversalCheckoutRequest = + | { readonly kind: "ready"; readonly id: JsonRpcId } + | { readonly kind: "windowOpen"; readonly id: JsonRpcId; readonly request: WindowOpenRequest } + | { readonly kind: "reject"; readonly response: JsonRpcResponse }; + +export interface UniversalCheckoutResourceNotification { + readonly kind: "resource"; + readonly eventType: UniversalCheckoutEventType; + readonly context: UniversalCheckoutContext; + readonly checkout: UniversalCheckoutSnapshot; +} + +interface UniversalCheckoutErrorNotificationBase { + readonly kind: "error"; + readonly context: { + readonly sessionId: string; + readonly revision: number; + readonly shopId?: string; + }; + readonly scope: "resource" | "session"; +} + +export type UniversalCheckoutErrorNotification = UniversalCheckoutErrorNotificationBase & + ( + | { readonly source: "protocol"; readonly error: ErrorResponse } + | { readonly source: "kit"; readonly failure: UniversalCheckoutFailure } + ); + +export type UniversalCheckoutNotification = + | UniversalCheckoutResourceNotification + | UniversalCheckoutErrorNotification; + +export interface UniversalCheckoutInvalidEntry { + readonly index: number; + /** An allowlisted method, never an arbitrary incoming method string. */ + readonly method: string; + /** A schema-owned path, never a raw value or arbitrary exception message. */ + readonly field: string; + readonly reason: "missing_required" | "invalid_type"; + readonly revision?: number; +} + +export interface UniversalCheckoutProtocolBatch { + readonly requests: readonly UniversalCheckoutRequest[]; + /** Accepted members in their original wire order. */ + readonly notifications: readonly UniversalCheckoutNotification[]; + readonly invalidEntries: readonly UniversalCheckoutInvalidEntry[]; +} + +interface JsonRpcEntry { + readonly jsonrpc: "2.0"; + readonly method: string; + readonly id?: unknown; + readonly params?: unknown; +} + +const { Event } = EmbeddedCheckoutProtocol; + +const RESOURCE_EVENT_TYPES: Readonly> = { + "ec.start": "start", + "ec.update": "update", + "ec.complete": "complete", +}; + +const KNOWN_METHODS = new Set([ + ...Object.keys(RESOURCE_EVENT_TYPES), + Event.error.method, + Event.ready.method, + Event.windowOpen.method, +]); + +const CHECKOUT_STATUSES = new Set([ + "incomplete", + "requires_escalation", + "ready_for_complete", + "complete_in_progress", + "completed", + "canceled", +]); + +/** Universal Checkout continuation URLs have a `/checkouts/uc/` path. */ +export function isUniversalCheckoutUrl(url: URL): boolean { + const segments = url.pathname.split("/"); + return segments.some((segment, index) => segment === "checkouts" && segments[index + 1] === "uc"); +} + +/** + * Parse a structured-clone JSON-RPC batch one member at a time. A bad member + * never discards its valid siblings, and unsupported notifications are ignored. + */ +export function parseUniversalCheckoutProtocolBatch( + data: unknown, +): UniversalCheckoutProtocolBatch | undefined { + if (!Array.isArray(data) || data.length === 0) return; + + const requests: UniversalCheckoutRequest[] = []; + const notifications: UniversalCheckoutNotification[] = []; + const invalidEntries: UniversalCheckoutInvalidEntry[] = []; + + data.forEach((value: unknown, index) => { + if (!isJsonRpcEntry(value)) { + invalidEntries.push({ index, method: "unknown", field: "message", reason: "invalid_type" }); + return; + } + + const { method, params } = value; + const safeMethod = KNOWN_METHODS.has(method) ? method : "unknown"; + // Structured clone preserves an own key whose value is undefined. JSON-RPC + // treats that the same as an omitted id, while an actual invalid id still + // invalidates the member. + const hasId = Object.hasOwn(value, "id") && value.id !== undefined; + const id = hasId && isJsonRpcId(value.id) ? value.id : undefined; + if (hasId && id === undefined) { + invalidEntries.push({ index, method: safeMethod, field: "id", reason: "invalid_type" }); + return; + } + + const reject = (field: string, reason: UniversalCheckoutInvalidEntry["reason"]) => { + invalidEntries.push({ index, method: safeMethod, field, reason, ...safeRevision(params) }); + if (id !== undefined) requests.push({ kind: "reject", response: invalidParamsResponse(id) }); + }; + + if (method === Event.ready.method) { + if (id === undefined) { + reject("id", "missing_required"); + return; + } + try { + Event.ready.decode(params); + requests.push({ kind: "ready", id }); + } catch (error) { + const failure = decodeFailure(error); + reject(failure.field, failure.reason); + } + return; + } + + if (method === Event.windowOpen.method) { + if (id === undefined) { + reject("id", "missing_required"); + return; + } + try { + requests.push({ kind: "windowOpen", id, request: Event.windowOpen.decode(params) }); + } catch (error) { + const failure = decodeFailure(error); + reject(failure.field, failure.reason); + } + return; + } + + const eventType = Object.hasOwn(RESOURCE_EVENT_TYPES, method) + ? RESOURCE_EVENT_TYPES[method] + : undefined; + if (eventType) { + try { + const context = parseContext(params, "resource"); + const checkout = Event.start.decode(params).checkout; + // The shared codec verifies these fields exist, but does not verify + // their container types. Keep an invalid resource out of public state. + if (!Array.isArray(checkout.lineItems)) { + throw new EntryValidationError("Checkout.line_items", "invalid_type"); + } + if (!Array.isArray(checkout.links)) { + throw new EntryValidationError("Checkout.links", "invalid_type"); + } + if (!Array.isArray(checkout.totals)) { + throw new EntryValidationError("Checkout.totals", "invalid_type"); + } + if (typeof checkout.status !== "string") { + throw new EntryValidationError("Checkout.status", "invalid_type"); + } + const { ucp: _, ...publicFields } = checkout; + const snapshot = immutableClone({ + ...publicFields, + status: CHECKOUT_STATUSES.has(checkout.status) ? checkout.status : "unknown", + }) as UniversalCheckoutSnapshot; + notifications.push({ kind: "resource", eventType, context, checkout: snapshot }); + } catch (error) { + const failure = decodeFailure(error); + reject(failure.field, failure.reason); + } + return; + } + + if (method === Event.error.method) { + let context: ReturnType; + try { + context = parseContext(params, "error"); + } catch (error) { + const failure = decodeFailure(error); + reject(failure.field, failure.reason); + return; + } + const scope = context.shopId === undefined ? "session" : "resource"; + try { + const error = Event.error.decode(params).error; + validateProtocolError(error); + notifications.push({ kind: "error", source: "protocol", scope, context, error }); + } catch (error) { + const failure = decodeFailure(error); + reject(failure.field, failure.reason); + // A recognized terminal notification with a validated scope still has + // a terminal outcome. Never promote an unscoped malformed member to a + // session-wide failure; that path returned above. + notifications.push({ + kind: "error", + source: "kit", + scope, + context, + failure: Object.freeze({ + code: "sdk_error", + message: "Checkout sent an invalid terminal error.", + }), + }); + } + return; + } + + if (id !== undefined) { + requests.push({ kind: "reject", response: methodNotFoundResponse(id) }); + } + }); + + return { requests, notifications, invalidEntries }; +} + +/** Distinguishes malformed protocol envelopes from unrelated popup messages. */ +export function isMalformedUniversalCheckoutProtocolEnvelope(data: unknown): boolean { + if (Array.isArray(data)) return data.length === 0; + return isRecord(data) && Object.hasOwn(data, "jsonrpc") && data.jsonrpc === "2.0"; +} + +function validateProtocolError(error: ErrorResponse): void { + if (!Array.isArray(error.messages)) { + throw new EntryValidationError("ErrorResponse.messages", "invalid_type"); + } + for (const message of error.messages) { + if ( + !isRecord(message) || + typeof message.type !== "string" || + typeof message.content !== "string" + ) { + throw new EntryValidationError("ErrorResponse.messages", "invalid_type"); + } + } +} + +function parseContext(params: unknown, kind: "resource"): UniversalCheckoutContext; +function parseContext( + params: unknown, + kind: "error", +): { sessionId: string; revision: number; shopId?: string }; +function parseContext( + params: unknown, + kind: "resource" | "error", +): { sessionId: string; revision: number; shopId?: string } { + if (!isRecord(params) || !isRecord(params.context)) { + throw new EntryValidationError("context", "missing_required"); + } + + const context = params.context; + if (typeof context.session_id !== "string" || context.session_id.length === 0) { + throw new EntryValidationError("context.session_id", "invalid_type"); + } + if (!Number.isSafeInteger(context.revision) || (context.revision as number) < 0) { + throw new EntryValidationError("context.revision", "invalid_type"); + } + if (typeof context.shop_id === "string" && context.shop_id.length > 0) { + return { + sessionId: context.session_id, + revision: context.revision as number, + shopId: context.shop_id, + }; + } + // Absence of shop_id alone is not authority to fail every resource. A wire + // session error must explicitly declare its scope. + if (kind === "error" && context.scope === "session" && context.shop_id === undefined) { + return { sessionId: context.session_id, revision: context.revision as number }; + } + throw new EntryValidationError("context.shop_id", "missing_required"); +} + +function immutableClone(value: Value): Value { + const clone = structuredClone(value); + const seen = new WeakSet(); + const freeze = (item: unknown): void => { + if (item === null || typeof item !== "object" || seen.has(item)) return; + seen.add(item); + for (const child of Object.values(item)) freeze(child); + Object.freeze(item); + }; + freeze(clone); + return clone; +} + +function decodeFailure(error: unknown): { + field: string; + reason: UniversalCheckoutInvalidEntry["reason"]; +} { + if (error instanceof ProtocolValidationError) { + return { field: error.modelPath, reason: error.reason }; + } + if (error instanceof EntryValidationError) { + return { field: error.field, reason: error.reason }; + } + return { field: "params", reason: "invalid_type" }; +} + +class EntryValidationError extends Error { + constructor( + readonly field: string, + readonly reason: UniversalCheckoutInvalidEntry["reason"], + ) { + super(`Invalid ${field}`); + } +} + +function safeRevision(params: unknown): { revision?: number } { + if (!isRecord(params) || !isRecord(params.context)) return {}; + const revision = params.context.revision; + return Number.isSafeInteger(revision) && (revision as number) >= 0 + ? { revision: revision as number } + : {}; +} + +function invalidParamsResponse(id: JsonRpcId): JsonRpcResponse { + return { + jsonrpc: "2.0", + id, + error: { code: INVALID_PARAMS_CODE, message: INVALID_PARAMS_MESSAGE }, + }; +} + +function methodNotFoundResponse(id: JsonRpcId): JsonRpcResponse { + return { + jsonrpc: "2.0", + id, + error: { code: METHOD_NOT_FOUND_CODE, message: METHOD_NOT_FOUND_MESSAGE }, + }; +} + +function isJsonRpcEntry(value: unknown): value is JsonRpcEntry { + return ( + isRecord(value) && + value.jsonrpc === "2.0" && + typeof value.method === "string" && + value.method.length > 0 + ); +} + +function isJsonRpcId(value: unknown): value is JsonRpcId { + return ( + value === null || + typeof value === "string" || + (typeof value === "number" && Number.isFinite(value)) + ); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/platforms/web/src/universal.reducer.ts b/platforms/web/src/universal.reducer.ts new file mode 100644 index 000000000..131613a2a --- /dev/null +++ b/platforms/web/src/universal.reducer.ts @@ -0,0 +1,263 @@ +import type { ErrorResponse } from "./checkout.types"; +import { toCheckoutError } from "./models/error"; +import type { UniversalCheckoutNotification } from "./universal.protocol"; +import type { + UniversalCheckout, + UniversalCheckoutError, + UniversalCheckoutErrorEventDetail, + UniversalCheckoutEventType, + UniversalCheckoutFailure, + UniversalCheckoutResource, + UniversalCheckoutResourceError, + UniversalCheckoutResourceEventDetail, +} from "./universal.types"; + +export type UniversalCheckoutLifecycleEvent = + | { + readonly type: UniversalCheckoutEventType; + readonly detail: UniversalCheckoutResourceEventDetail; + } + | { readonly type: "error"; readonly detail: UniversalCheckoutErrorEventDetail }; + +export interface UniversalCheckoutReduction { + readonly checkout?: UniversalCheckout; + readonly error?: UniversalCheckoutError; + /** Contiguous lifecycle groups, kept in wire order. */ + readonly events: readonly UniversalCheckoutLifecycleEvent[]; + /** Accepted ec.error members, independent of public event grouping. */ + readonly acceptedTerminalErrors: number; +} + +/** + * Reduces one decoded wire batch into immutable public state and event details. + * Full snapshots replace only their named shop; omitted shops remain intact. + */ +export class UniversalCheckoutReducer { + #sessionId?: string; + #revision?: number; + #errorRevision?: number; + #sessionTerminal = false; + readonly #resourcesByShopId = new Map(); + readonly #errorsByShopId = new Map(); + readonly #failedCheckoutIds = new Map(); + readonly #completedCheckoutIds = new Set(); + #sessionError?: UniversalCheckoutResourceError; + + reduce(notifications: readonly UniversalCheckoutNotification[]): UniversalCheckoutReduction { + const groups: Array<{ + type: UniversalCheckoutLifecycleEvent["type"]; + revision: number; + entries: Array< + UniversalCheckoutResourceEventDetail[number] | UniversalCheckoutErrorEventDetail[number] + >; + }> = []; + let terminalCompleteRevision: number | undefined; + let sawSessionError = false; + let acceptedTerminalErrors = 0; + + const append = ( + type: UniversalCheckoutLifecycleEvent["type"], + revision: number, + entry: + | UniversalCheckoutResourceEventDetail[number] + | UniversalCheckoutErrorEventDetail[number], + ) => { + const last = groups.at(-1); + if (last?.type === type && last.revision === revision) { + last.entries.push(entry); + } else { + groups.push({ type, revision, entries: [entry] }); + } + }; + + for (const notification of notifications) { + // An accepted terminal member ends this session within the current wire + // batch too. A complete batch may still contain one member per resource + // at the same revision, even when unrelated members are interleaved. + if ( + sawSessionError || + (terminalCompleteRevision !== undefined && + (notification.kind !== "resource" || + notification.eventType !== "complete" || + notification.context.revision !== terminalCompleteRevision)) + ) { + continue; + } + if (!this.#acceptsSession(notification.context.sessionId, notification.context.revision)) { + continue; + } + + if (notification.kind === "resource") { + const { context } = notification; + const { shopId } = context; + const { checkout, eventType } = notification; + const previous = this.#resourcesByShopId.get(shopId); + const failedCheckoutId = this.#failedCheckoutIds.get(shopId); + const replacement = previous !== undefined && previous.id !== checkout.id; + + // Recovery from a terminal resource error requires an explicit new + // checkout identity and a new start. A changed-resource update alone + // cannot silently replace a failed checkout. + if (failedCheckoutId !== undefined || this.#failedCheckoutIds.has(shopId)) { + if ( + eventType !== "start" || + (previous !== undefined && !replacement) || + checkout.id === failedCheckoutId + ) { + continue; + } + this.#failedCheckoutIds.delete(shopId); + this.#errorsByShopId.delete(shopId); + } else if (replacement && eventType !== "start") { + continue; + } + + if (eventType === "update" && previous === undefined) continue; + + const completedKey = `${shopId}\u0000${checkout.id}`; + if (this.#completedCheckoutIds.has(completedKey)) continue; + + const unchanged = + previous !== undefined && isEqualSnapshotValue(previous.checkout, checkout); + const resource = unchanged + ? previous + : Object.freeze({ id: checkout.id, shopId, checkout }); + this.#sessionId ??= context.sessionId; + this.#resourcesByShopId.set(shopId, resource); + this.#revision = Math.max(this.#revision ?? context.revision, context.revision); + + let publicType: UniversalCheckoutEventType = eventType; + if (eventType === "start" && previous && !replacement) publicType = "update"; + if (eventType === "update" && unchanged) continue; + if (publicType === "update" && unchanged) continue; + if (eventType === "complete") { + terminalCompleteRevision = context.revision; + this.#completedCheckoutIds.add(completedKey); + } + + append( + publicType, + context.revision, + Object.freeze({ context: Object.freeze({ ...context }), checkout }), + ); + continue; + } + + const { context } = notification; + const failure = + notification.source === "kit" ? notification.failure : mapProtocolError(notification.error); + const errorContext = Object.freeze({ ...context }); + const entry = Object.freeze({ + context: errorContext, + scope: notification.scope, + error: failure, + } as const); + + this.#sessionId ??= context.sessionId; + this.#errorRevision = Math.max(this.#errorRevision ?? context.revision, context.revision); + + if (notification.scope === "resource" && context.shopId !== undefined) { + const previous = this.#errorsByShopId.get(context.shopId); + if (previous && isEqualSnapshotValue(previous.error, failure)) continue; + const resourceError = Object.freeze({ + context: errorContext, + shopId: context.shopId, + scope: "resource" as const, + error: failure, + }); + this.#errorsByShopId.set(context.shopId, resourceError); + this.#failedCheckoutIds.set( + context.shopId, + this.#resourcesByShopId.get(context.shopId)?.id, + ); + } else { + if (this.#sessionError) continue; + this.#sessionError = Object.freeze({ + context: errorContext, + scope: "session" as const, + error: failure, + }); + sawSessionError = true; + } + acceptedTerminalErrors += 1; + append("error", context.revision, entry); + } + + if (terminalCompleteRevision !== undefined || sawSessionError) this.#sessionTerminal = true; + + const sessionId = this.#sessionId; + const revision = this.#revision; + const errorRevision = this.#errorRevision; + const resourceErrors = [...this.#errorsByShopId.values()]; + if (this.#sessionError) resourceErrors.push(this.#sessionError); + + return { + checkout: + sessionId !== undefined && revision !== undefined && this.#resourcesByShopId.size > 0 + ? Object.freeze({ + sessionId, + revision, + resources: Object.freeze([...this.#resourcesByShopId.values()]), + }) + : undefined, + error: + resourceErrors.length > 0 + ? Object.freeze({ + sessionId, + revision: errorRevision, + errors: Object.freeze(resourceErrors), + }) + : undefined, + events: groups.map((group) => + group.type === "error" + ? { + type: "error" as const, + detail: Object.freeze(group.entries) as UniversalCheckoutErrorEventDetail, + } + : { + type: group.type, + detail: Object.freeze(group.entries) as UniversalCheckoutResourceEventDetail, + }, + ), + acceptedTerminalErrors, + }; + } + + #acceptsSession(sessionId: string, revision: number): boolean { + if (this.#sessionTerminal) return false; + if (this.#sessionId !== undefined && sessionId !== this.#sessionId) return false; + const lastRevision = Math.max(this.#revision ?? -1, this.#errorRevision ?? -1); + if (revision < lastRevision) return false; + return true; + } +} + +export function mapProtocolError(error: ErrorResponse): UniversalCheckoutFailure { + return Object.freeze(toCheckoutError(error)); +} + +/** Structural equality compares public fields, excluding transport metadata. */ +function isEqualSnapshotValue(left: unknown, right: unknown): boolean { + if (Object.is(left, right)) return true; + if (typeof left !== "object" || typeof right !== "object" || left === null || right === null) { + return false; + } + if (Array.isArray(left) || Array.isArray(right)) { + return ( + Array.isArray(left) && + Array.isArray(right) && + left.length === right.length && + left.every((item, index) => isEqualSnapshotValue(item, right[index])) + ); + } + + const leftEntries = Object.entries(left); + return ( + leftEntries.length === Object.keys(right).length && + leftEntries.every( + ([key, value]) => + Object.hasOwn(right, key) && + isEqualSnapshotValue(value, (right as Record)[key]), + ) + ); +} diff --git a/platforms/web/src/universal.session.ts b/platforms/web/src/universal.session.ts new file mode 100644 index 000000000..518da7ea7 --- /dev/null +++ b/platforms/web/src/universal.session.ts @@ -0,0 +1,200 @@ +import { + EmbeddedCheckoutProtocol, + INTERNAL_ERROR_CODE, + INTERNAL_ERROR_MESSAGE, + type WindowOpenRequest, + type WindowOpenResult, +} from "@shopify/checkout-kit-protocol"; + +import type { Logger } from "./logger"; +import { + isMalformedUniversalCheckoutProtocolEnvelope, + parseUniversalCheckoutProtocolBatch, + UNIVERSAL_CHECKOUT_PROTOCOL_VERSION, + type JsonRpcResponse, + type UniversalCheckoutRequest, +} from "./universal.protocol"; +import { UniversalCheckoutReducer } from "./universal.reducer"; +import type { + UniversalCheckout, + UniversalCheckoutError, + UniversalCheckoutErrorEventDetail, + UniversalCheckoutResourceEventDetail, +} from "./universal.types"; + +export class ShopifyUniversalCheckoutStartEvent extends CustomEvent { + declare type: "start"; + + constructor(detail: UniversalCheckoutResourceEventDetail) { + super("start", { detail, bubbles: true }); + } +} + +export class ShopifyUniversalCheckoutUpdateEvent extends CustomEvent { + declare type: "update"; + + constructor(detail: UniversalCheckoutResourceEventDetail) { + super("update", { detail, bubbles: true }); + } +} + +export class ShopifyUniversalCheckoutCompleteEvent extends CustomEvent { + declare type: "complete"; + + constructor(detail: UniversalCheckoutResourceEventDetail) { + super("complete", { detail, bubbles: true }); + } +} + +export class ShopifyUniversalCheckoutErrorEvent extends CustomEvent { + declare type: "error"; + + constructor(detail: UniversalCheckoutErrorEventDetail) { + // Checkout failures belong to the element, not global runtime error handlers. + super("error", { detail, bubbles: false }); + } +} + +interface UniversalCheckoutSessionOptions { + readonly target: EventTarget; + readonly logger: Logger; + readonly openWindow: (request: WindowOpenRequest) => WindowOpenResult; + /** Must bind both this session and its exact presentation generation. */ + readonly isActive: () => boolean; + readonly recordDecodeError: (method: string, failureType: "envelope" | "params") => void; + readonly recordTerminalError: () => void; + readonly recordStart: () => void; + readonly recordNavigationFailure: () => void; +} + +/** One Universal Checkout presentation and its independent protocol state. */ +export class UniversalCheckoutSession { + readonly #reducer = new UniversalCheckoutReducer(); + readonly #options: UniversalCheckoutSessionOptions; + #checkout?: UniversalCheckout; + #error?: UniversalCheckoutError; + + constructor(options: UniversalCheckoutSessionOptions) { + this.#options = options; + } + + get checkout(): UniversalCheckout | undefined { + return this.#checkout; + } + + get error(): UniversalCheckoutError | undefined { + return this.#error; + } + + /** Called only after the element has validated the active source and origin. */ + handleMessage(event: MessageEvent): void { + if (!this.#options.isActive()) return; + const batch = parseUniversalCheckoutProtocolBatch(event.data); + if (!batch) { + if (isMalformedUniversalCheckoutProtocolEnvelope(event.data)) { + this.#options.logger.error("dropped malformed Universal Checkout protocol batch"); + this.#options.recordDecodeError("unknown", "envelope"); + } + return; + } + + const { + target, + logger, + isActive, + recordDecodeError, + recordTerminalError, + recordStart, + recordNavigationFailure, + } = this.#options; + for (const entry of batch.invalidEntries) { + const revision = entry.revision === undefined ? "" : ` at revision ${entry.revision}`; + logger.error( + `dropped ${entry.method} member ${entry.index}${revision}: ${entry.field} ${entry.reason}`, + ); + recordDecodeError(entry.method, "params"); + } + + const responses: JsonRpcResponse[] = []; + for (const request of batch.requests) { + if (!isActive()) return; + responses.push(this.#responseFor(request)); + } + if (responses.length > 0 && event.source && isActive()) { + try { + // Both values were checked against the active presentation by the + // element. Do not retarget a response to a newly opened presentation. + (event.source as WindowProxy).postMessage(responses, event.origin); + } catch { + logger.error("failed to send a Universal Checkout protocol response"); + } + } + + if (!isActive()) return; + const { checkout, error, events, acceptedTerminalErrors } = this.#reducer.reduce( + batch.notifications, + ); + // Commit the entire accepted batch before the first listener runs. + this.#checkout = checkout; + this.#error = error; + for (let index = 0; index < acceptedTerminalErrors; index += 1) recordTerminalError(); + + // End the initial navigation at the first accepted start or session-wide + // failure, before a host listener can close or replace this presentation. + const navigationOutcome = events.find( + (lifecycle) => + lifecycle.type === "start" || + (lifecycle.type === "error" && lifecycle.detail.some(({ scope }) => scope === "session")), + ); + if (navigationOutcome?.type === "start") recordStart(); + else if (navigationOutcome?.type === "error") recordNavigationFailure(); + + for (const { type, detail } of events) { + if (!isActive()) break; + switch (type) { + case "start": + target.dispatchEvent(new ShopifyUniversalCheckoutStartEvent(detail)); + break; + case "update": + target.dispatchEvent(new ShopifyUniversalCheckoutUpdateEvent(detail)); + break; + case "complete": + target.dispatchEvent(new ShopifyUniversalCheckoutCompleteEvent(detail)); + break; + case "error": + target.dispatchEvent(new ShopifyUniversalCheckoutErrorEvent(detail)); + break; + } + } + } + + #responseFor(request: UniversalCheckoutRequest): JsonRpcResponse { + switch (request.kind) { + case "reject": + return request.response; + case "ready": + return { + jsonrpc: "2.0", + id: request.id, + result: { ucp: { version: UNIVERSAL_CHECKOUT_PROTOCOL_VERSION, status: "success" } }, + }; + case "windowOpen": + try { + return { + jsonrpc: "2.0", + id: request.id, + result: EmbeddedCheckoutProtocol.Event.windowOpen.encode( + this.#options.openWindow(request.request), + ), + }; + } catch { + this.#options.logger.error("failed to handle a delegated window request"); + return { + jsonrpc: "2.0", + id: request.id, + error: { code: INTERNAL_ERROR_CODE, message: INTERNAL_ERROR_MESSAGE }, + }; + } + } + } +} diff --git a/platforms/web/src/universal.test.ts b/platforms/web/src/universal.test.ts new file mode 100644 index 000000000..533e54609 --- /dev/null +++ b/platforms/web/src/universal.test.ts @@ -0,0 +1,1174 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { version } from "../package.json"; + +import provisionalBatches from "./__fixtures__/provisional-universal-checkout-batches.json"; +import { ShopifyCheckout } from "./checkout"; +import { mockTelemetry } from "./telemetry.test-helpers"; +import * as universal from "./universal"; +import type { ShopifyUniversalCheckout, UniversalCheckoutResourceEventDetail } from "./universal"; + +const UC_SRC = "https://shop.example.com/checkouts/uc/abc123?key=k"; +const CN_SRC = "https://shop.example.com/checkouts/cn/abc123?key=k"; +const SESSION_ID = "gid://shopify/UniversalCheckoutSession/123"; +const FIRST_SHOP_ID = "gid://shopify/Shop/123"; +const SECOND_SHOP_ID = "gid://shopify/Shop/456"; + +interface WireResourceParams { + readonly context: { session_id: string; revision: number; shop_id: string }; + readonly checkout: { + readonly id: string; + readonly line_items: unknown; + readonly order?: { id: string; permalink_url: string }; + readonly [key: string]: unknown; + }; +} + +function clone(value: Value): Value { + return structuredClone(value); +} + +function publicContext({ context }: Pick) { + return { sessionId: context.session_id, revision: context.revision, shopId: context.shop_id }; +} + +/** The public projection of a fixture checkout: camelCase keys, no top-level `ucp`. */ +function publicSnapshot(checkout: WireResourceParams["checkout"]) { + const { ucp: _, line_items: lineItems, order, ...snapshot } = checkout; + return { + ...snapshot, + lineItems, + ...(order && { order: { id: order.id, permalinkUrl: order.permalink_url } }), + }; +} + +function resourceDetail(params: WireResourceParams) { + return { context: publicContext(params), checkout: publicSnapshot(params.checkout) }; +} + +function createMockWindow() { + return { + addEventListener: vi.fn(), + close: vi.fn(), + closed: false, + focus: vi.fn(), + postMessage: vi.fn(), + } as unknown as Window; +} + +function renderUniversalCheckout( + attributes: Record = {}, +): ShopifyUniversalCheckout { + const checkout = document.createElement("shopify-universal-checkout"); + checkout.setAttribute("src", UC_SRC); + for (const [key, value] of Object.entries(attributes)) { + checkout.setAttribute(key, value); + } + document.body.appendChild(checkout); + return checkout; +} + +function openPopupUniversalCheckout(attributes: Record = {}) { + const checkout = renderUniversalCheckout({ target: "popup", ...attributes }); + const mockCheckoutWindow = createMockWindow(); + const windowOpenSpy = vi.spyOn(window, "open").mockReturnValue(mockCheckoutWindow); + vi.spyOn(HTMLDialogElement.prototype, "showModal").mockImplementation(() => {}); + vi.spyOn(HTMLDialogElement.prototype, "close").mockImplementation(() => {}); + checkout.open(); + return { + checkout, + mockCheckoutWindow, + windowOpenSpy, + send: (batch: unknown, options?: { source?: MessageEventSource | null; origin?: string }) => + window.dispatchEvent( + new MessageEvent("message", { + data: batch, + origin: options?.origin ?? new URL(checkout.src).origin, + source: options && "source" in options ? options.source : mockCheckoutWindow, + }), + ), + }; +} + +function startSession() { + const opened = openPopupUniversalCheckout(); + opened.send(provisionalBatches.start); + return opened; +} + +describe("@shopify/checkout-kit/universal", () => { + afterEach(() => { + vi.restoreAllMocks(); + document.body.innerHTML = ""; + }); + + it("exports and registers a concrete sibling of ShopifyCheckout", () => { + expect(customElements.get("shopify-universal-checkout")).toBe( + universal.ShopifyUniversalCheckout, + ); + expect( + ShopifyCheckout.prototype.isPrototypeOf(universal.ShopifyUniversalCheckout.prototype), + ).toBe(false); + expect(Object.getPrototypeOf(universal.ShopifyUniversalCheckout.prototype)).toBe( + Object.getPrototypeOf(ShopifyCheckout.prototype), + ); + }); + + it("negotiates the provisional Universal Checkout protocol version", () => { + const { windowOpenSpy } = openPopupUniversalCheckout(); + + const openedUrl = new URL(windowOpenSpy.mock.calls[0]![0] as string); + expect(openedUrl.searchParams.get("ec_version")).toBe("2026-08-25"); + expect(openedUrl.searchParams.get("ec_delegate")).toBe("window.open"); + expect(openedUrl.searchParams.get("ck_version")).toBe(version); + }); + + it("answers a ready request batch with one matching response batch", () => { + const { checkout, mockCheckoutWindow, send } = openPopupUniversalCheckout(); + + send(provisionalBatches.ready.request); + + expect(mockCheckoutWindow.postMessage).toHaveBeenCalledExactlyOnceWith( + provisionalBatches.ready.response, + new URL(checkout.src).origin, + ); + }); + + it("dispatches one start event for a contiguous batch and exposes every checkout", () => { + const { checkout, send } = openPopupUniversalCheckout(); + send(provisionalBatches.ready.request); + const startSpy = vi.fn(); + const wireEventSpy = vi.fn(); + checkout.addEventListener("start", startSpy); + (checkout as HTMLElement).addEventListener("ec.start", wireEventSpy); + + send(provisionalBatches.start); + + expect(startSpy).toHaveBeenCalledOnce(); + expect(startSpy.mock.calls[0]![0].detail).toEqual( + provisionalBatches.start.map(({ params }) => resourceDetail(params)), + ); + expect(wireEventSpy).not.toHaveBeenCalled(); + expect(checkout.checkout).toEqual({ + sessionId: SESSION_ID, + revision: 1, + resources: provisionalBatches.start.map(({ params }) => ({ + id: params.checkout.id, + shopId: params.context.shop_id, + checkout: publicSnapshot(params.checkout), + })), + }); + expect(startSpy.mock.calls[0]![0].detail[0].checkout).toBe( + checkout.checkout?.resources[0]?.checkout, + ); + }); + + it("omits only top-level protocol metadata from checkout snapshots", () => { + const { checkout, send } = openPopupUniversalCheckout(); + const start = clone(provisionalBatches.start[0]!); + Object.assign(start.params.checkout, { extensions: { ucp: { merchant_value: true } } }); + const startSpy = vi.fn(); + checkout.addEventListener("start", startSpy); + + send([start]); + + const snapshot = startSpy.mock.calls[0]![0].detail[0].checkout; + expect(snapshot).not.toHaveProperty("ucp"); + expect(snapshot.extensions).toEqual({ ucp: { merchant_value: true } }); + }); + + it("dispatches update only for checkouts whose snapshot changed", () => { + const { checkout, send } = startSession(); + const unaffectedResource = checkout.checkout?.resources[1]; + const updateSpy = vi.fn(); + checkout.addEventListener("update", updateSpy); + + send(provisionalBatches.update); + + expect(updateSpy).toHaveBeenCalledOnce(); + expect(updateSpy.mock.calls[0]![0].detail).toEqual([ + resourceDetail(provisionalBatches.update[0]!.params), + ]); + expect(checkout.checkout?.revision).toBe(2); + expect(checkout.checkout?.resources[1]).toBe(unaffectedResource); + }); + + it("ignores unsupported notifications and suppresses unchanged snapshots", () => { + const { checkout, send } = startSession(); + const update = provisionalBatches.update[0]!; + const totalsChange = { ...clone(update), method: "ec.totals.change" }; + const metadataOnlyChange = { + ...clone(provisionalBatches.start[1]!), + method: "ec.messages.change", + }; + metadataOnlyChange.params.context.revision = 2; + metadataOnlyChange.params.checkout.ucp.capabilities = { discount: [] }; + const updateSpy = vi.fn(); + checkout.addEventListener("update", updateSpy); + + send([update, totalsChange, metadataOnlyChange, clone(update)]); + + expect(updateSpy).toHaveBeenCalledOnce(); + expect(updateSpy.mock.calls[0]![0].detail).toEqual([resourceDetail(update.params)]); + }); + + it("preserves A-B-A wire order and commits the aggregate before the first listener", () => { + const { checkout, send } = openPopupUniversalCheckout(); + const firstStart = clone(provisionalBatches.start[0]!); + const firstUpdate = clone(provisionalBatches.update[0]!); + firstUpdate.params.context.revision = 1; + const secondStart = clone(provisionalBatches.start[1]!); + const observed: Array<{ + type: string; + shopIds: string[]; + detailStatuses: string[]; + aggregateStatuses: string[] | undefined; + }> = []; + const record = (event: { type: string; detail: UniversalCheckoutResourceEventDetail }) => { + observed.push({ + type: event.type, + shopIds: event.detail.map(({ context }) => context.shopId), + detailStatuses: event.detail.map(({ checkout: snapshot }) => snapshot.status), + aggregateStatuses: checkout.checkout?.resources.map((resource) => resource.checkout.status), + }); + }; + checkout.addEventListener("start", record); + checkout.addEventListener("update", record); + + send([firstStart, firstUpdate, secondStart]); + + expect(observed).toEqual([ + { + type: "start", + shopIds: [FIRST_SHOP_ID], + detailStatuses: ["incomplete"], + aggregateStatuses: ["ready_for_complete", "incomplete"], + }, + { + type: "update", + shopIds: [FIRST_SHOP_ID], + detailStatuses: ["ready_for_complete"], + aggregateStatuses: ["ready_for_complete", "incomplete"], + }, + { + type: "start", + shopIds: [SECOND_SHOP_ID], + detailStatuses: ["incomplete"], + aggregateStatuses: ["ready_for_complete", "incomplete"], + }, + ]); + expect(checkout.checkout?.revision).toBe(1); + }); + + it("dispatches the explicit complete batch even when a completed update was unchanged", () => { + const { checkout, send } = startSession(); + send([{ ...clone(provisionalBatches.complete[0]!), method: "ec.update" }]); + const completeSpy = vi.fn(); + checkout.addEventListener("complete", completeSpy); + + send(provisionalBatches.complete); + + expect(completeSpy).toHaveBeenCalledOnce(); + expect(completeSpy.mock.calls[0]![0].detail).toEqual( + provisionalBatches.complete.map(({ params }) => resourceDetail(params)), + ); + expect(checkout.checkout?.resources.map(({ checkout: snapshot }) => snapshot.status)).toEqual([ + "completed", + "completed", + ]); + }); + + it("keeps same-revision complete members but drops later groups in a terminal batch", () => { + const { checkout, send } = startSession(); + const firstComplete = clone(provisionalBatches.complete[0]!); + const secondComplete = clone(provisionalBatches.complete[1]!); + const lateUpdate = clone(provisionalBatches.update[0]!); + lateUpdate.params.context.shop_id = SECOND_SHOP_ID; + lateUpdate.params.context.revision = 4; + lateUpdate.params.checkout.id = "gid://shopify/Checkout/456"; + lateUpdate.params.checkout.currency = "CAD"; + const lateError = clone(provisionalBatches.error[0]!); + lateError.params.context.revision = 5; + const lateStart = clone(provisionalBatches.start[0]!); + lateStart.params.context.revision = 6; + lateStart.params.checkout.id = "gid://shopify/Checkout/reopened"; + const completeSpy = vi.fn(); + const updateSpy = vi.fn(); + const errorSpy = vi.fn(); + const startSpy = vi.fn(); + checkout.addEventListener("complete", completeSpy); + checkout.addEventListener("update", updateSpy); + checkout.addEventListener("error", errorSpy); + checkout.addEventListener("start", startSpy); + + send([firstComplete, lateUpdate, secondComplete, lateError, lateStart]); + + expect(completeSpy).toHaveBeenCalledOnce(); + expect(completeSpy.mock.calls[0]![0].detail).toEqual([ + resourceDetail(firstComplete.params), + resourceDetail(secondComplete.params), + ]); + expect(updateSpy).not.toHaveBeenCalled(); + expect(errorSpy).not.toHaveBeenCalled(); + expect(startSpy).not.toHaveBeenCalled(); + expect(checkout.checkout?.revision).toBe(3); + expect(checkout.checkout?.resources.map(({ checkout: snapshot }) => snapshot.status)).toEqual([ + "completed", + "completed", + ]); + expect(checkout.error).toBeUndefined(); + }); + + it("continues delivering healthy shop updates after another shop completes in an update", () => { + const { checkout, send } = startSession(); + const firstCompleted = { ...clone(provisionalBatches.complete[0]!), method: "ec.update" }; + firstCompleted.params.context.revision = 2; + send([firstCompleted]); + const otherUpdate = clone(provisionalBatches.update[0]!); + otherUpdate.params.context.shop_id = SECOND_SHOP_ID; + otherUpdate.params.checkout.id = "gid://shopify/Checkout/456"; + otherUpdate.params.context.revision = 3; + const updateSpy = vi.fn(); + checkout.addEventListener("update", updateSpy); + + send([otherUpdate]); + + expect(updateSpy).toHaveBeenCalledOnce(); + expect( + updateSpy.mock.calls[0]![0].detail.map( + ({ context }: { context: { shopId: string } }) => context.shopId, + ), + ).toEqual([SECOND_SHOP_ID]); + expect(checkout.checkout?.resources[0]?.checkout.status).toBe("completed"); + }); + + it("ignores notifications after the explicit session complete", () => { + const { checkout, send } = startSession(); + send(provisionalBatches.complete); + const updateSpy = vi.fn(); + checkout.addEventListener("update", updateSpy); + const lateUpdate = clone(provisionalBatches.update[0]!); + lateUpdate.params.context.revision = 4; + lateUpdate.params.checkout.currency = "CAD"; + + send([lateUpdate]); + + expect(updateSpy).not.toHaveBeenCalled(); + expect(checkout.checkout?.revision).toBe(3); + expect(checkout.checkout?.resources[0]?.checkout.status).toBe("completed"); + }); + + it("delivers a repeated start notification as an update", () => { + const { checkout, send } = startSession(); + const restart = clone(provisionalBatches.start[0]!); + restart.params.context.revision = 2; + restart.params.checkout.currency = "CAD"; + const startSpy = vi.fn(); + const updateSpy = vi.fn(); + checkout.addEventListener("start", startSpy); + checkout.addEventListener("update", updateSpy); + + send([restart]); + + expect(startSpy).not.toHaveBeenCalled(); + expect(updateSpy).toHaveBeenCalledOnce(); + expect(updateSpy.mock.calls[0]![0].detail).toEqual([resourceDetail(restart.params)]); + }); + + it("dispatches a checkout error without failing the rest of the session", () => { + const { checkout, send } = startSession(); + const firstError = provisionalBatches.error[0]!; + const failure = { code: "unknown", message: "Universal Checkout is unavailable." }; + const errorSpy = vi.fn(); + const closeSpy = vi.fn(); + const updateSpy = vi.fn(); + checkout.addEventListener("error", errorSpy); + checkout.addEventListener("close", closeSpy); + checkout.addEventListener("update", updateSpy); + const otherUpdate = clone(provisionalBatches.update[0]!); + otherUpdate.params.context.shop_id = SECOND_SHOP_ID; + otherUpdate.params.checkout.id = "gid://shopify/Checkout/456"; + otherUpdate.params.context.revision = 5; + const failedUpdate = clone(provisionalBatches.update[0]!); + failedUpdate.params.context.revision = 5; + + send([firstError]); + send([failedUpdate, otherUpdate]); + + expect(errorSpy).toHaveBeenCalledOnce(); + expect(errorSpy.mock.calls[0]![0].detail).toEqual([ + { context: publicContext(firstError.params), scope: "resource", error: failure }, + ]); + expect(checkout.error).toEqual({ + sessionId: SESSION_ID, + revision: 4, + errors: [ + { + context: publicContext(firstError.params), + shopId: FIRST_SHOP_ID, + scope: "resource", + error: failure, + }, + ], + }); + expect(updateSpy).toHaveBeenCalledOnce(); + expect( + updateSpy.mock.calls[0]![0].detail.map( + ({ context }: { context: { shopId: string } }) => context.shopId, + ), + ).toEqual([SECOND_SHOP_ID]); + expect(closeSpy).not.toHaveBeenCalled(); + }); + + it("keeps a completed shop when a sibling aborts between update and complete", () => { + const { checkout, send } = startSession(); + const completedUpdate = { ...clone(provisionalBatches.complete[1]!), method: "ec.update" }; + completedUpdate.params.context.revision = 2; + const completed = clone(provisionalBatches.complete[1]!); + completed.params.context.revision = 2; + const aborted = { + jsonrpc: "2.0", + method: "ec.error", + params: { + context: { session_id: SESSION_ID, revision: 2, shop_id: FIRST_SHOP_ID }, + error: { + ucp: { version: "2026-08-25", status: "error" }, + messages: [ + { + type: "error", + code: "currency_mismatch", + content: "The shops use different currencies.", + content_type: "plain", + severity: "unrecoverable", + }, + ], + }, + }, + }; + const events: string[] = []; + checkout.addEventListener("update", () => events.push("update")); + checkout.addEventListener("error", () => events.push("error")); + checkout.addEventListener("complete", () => events.push("complete")); + + send([completedUpdate, aborted, completed]); + + expect(events).toEqual(["update", "error", "complete"]); + expect(checkout.checkout?.resources.map(({ checkout: resource }) => resource.status)).toEqual([ + "incomplete", + "completed", + ]); + expect(checkout.error?.errors).toEqual([ + { + context: { sessionId: SESSION_ID, revision: 2, shopId: FIRST_SHOP_ID }, + shopId: FIRST_SHOP_ID, + scope: "resource", + error: { code: "unknown", message: "The shops use different currencies." }, + }, + ]); + + const laterUpdate = clone(provisionalBatches.update[0]!); + laterUpdate.params.context.revision = 3; + send([laterUpdate]); + expect(events).toEqual(["update", "error", "complete"]); + }); + + it("normalizes only recognized checkout-origin error codes like the native SDKs", () => { + const { checkout, send } = startSession(); + const firstError = clone(provisionalBatches.error[0]!); + const secondError = clone(provisionalBatches.error[1]!); + firstError.params.error.messages[0]!.code = "CaRt_ExPiReD"; + secondError.params.error.messages[0]!.code = "SDK_ERROR"; + const errorSpy = vi.fn(); + checkout.addEventListener("error", errorSpy); + + send([firstError, secondError]); + + expect(errorSpy).toHaveBeenCalledOnce(); + expect( + errorSpy.mock.calls[0]![0].detail.map(({ error }: { error: { code: string } }) => error.code), + ).toEqual(["cart_expired", "unknown"]); + expect(checkout.error?.errors.map(({ error }) => error.code)).toEqual([ + "cart_expired", + "unknown", + ]); + }); + + it("preserves checkout error reasons when message severity is omitted", () => { + const { checkout, send } = startSession(); + const wireError = clone(provisionalBatches.error[0]!); + const { severity: _, ...message } = wireError.params.error.messages[0]!; + const onError = vi.fn(); + checkout.addEventListener("error", onError); + + send([ + { + ...wireError, + params: { + ...wireError.params, + error: { + ...wireError.params.error, + messages: [{ ...message, code: "CaRt_ExPiReD", content: "Cart expired" }], + }, + }, + }, + ]); + + const failure = { code: "cart_expired", message: "Cart expired" }; + expect(onError).toHaveBeenCalledOnce(); + expect(onError.mock.calls[0]![0].detail[0].error).toEqual(failure); + expect(checkout.error?.errors[0]?.error).toEqual(failure); + }); + + it("orders later notifications after duplicate errors without redispatching or recounting them", () => { + const recordErrorSpy = vi.spyOn(mockTelemetry(), "recordError"); + const { checkout, send } = startSession(); + const wireError = clone(provisionalBatches.error[0]!); + const newerDuplicate = clone(wireError); + newerDuplicate.params.context.revision = 10; + const onError = vi.fn(); + const onStart = vi.fn(); + const onUpdate = vi.fn(); + checkout.addEventListener("error", onError); + checkout.addEventListener("start", onStart); + checkout.addEventListener("update", onUpdate); + + send([wireError]); + send([newerDuplicate]); + + const delayedReplacement = clone(provisionalBatches.start[0]!); + delayedReplacement.params.context.revision = 5; + delayedReplacement.params.checkout.id = "checkout-replacement"; + const delayedHealthyUpdate = clone(provisionalBatches.update[0]!); + delayedHealthyUpdate.params.context.shop_id = SECOND_SHOP_ID; + delayedHealthyUpdate.params.checkout.id = provisionalBatches.start[1]!.params.checkout.id; + delayedHealthyUpdate.params.context.revision = 5; + send([delayedReplacement, delayedHealthyUpdate]); + + expect(checkout.error?.revision).toBe(10); + expect(checkout.error?.errors).toHaveLength(1); + expect(checkout.checkout?.resources[0]?.id).toBe( + provisionalBatches.start[0]!.params.checkout.id, + ); + expect(checkout.checkout?.revision).toBe(1); + expect(onError).toHaveBeenCalledOnce(); + expect(onStart).not.toHaveBeenCalled(); + expect(onUpdate).not.toHaveBeenCalled(); + expect( + recordErrorSpy.mock.calls.filter(([metric]) => metric.code === "terminal_error"), + ).toHaveLength(1); + + const currentHealthyUpdate = clone(delayedHealthyUpdate); + currentHealthyUpdate.params.context.revision = 11; + send([currentHealthyUpdate]); + expect(onUpdate).toHaveBeenCalledOnce(); + expect(checkout.checkout?.revision).toBe(11); + }); + + it("records one terminal-error metric per accepted wire member, not per grouped event", () => { + const recordErrorSpy = vi.spyOn(mockTelemetry(), "recordError"); + const errorLogSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const { checkout, send } = startSession(); + const [firstError, secondError] = provisionalBatches.error; + const errorSpy = vi.fn(); + checkout.addEventListener("error", errorSpy); + + send([firstError, firstError, secondError]); + + expect(errorSpy).toHaveBeenCalledOnce(); + expect(errorSpy.mock.calls[0]![0].detail).toHaveLength(2); + const terminalCalls = () => + recordErrorSpy.mock.calls.filter(([metric]) => metric.code === "terminal_error"); + expect(terminalCalls()).toHaveLength(2); + + // Replayed, malformed, and foreign-session members are not accepted. + send([firstError, secondError]); + send([{ ...firstError, id: { invalid: true } }]); + const foreignError = clone(secondError!); + foreignError.params.context.session_id = "gid://shopify/UniversalCheckoutSession/other"; + send([foreignError]); + + expect(errorSpy).toHaveBeenCalledOnce(); + expect(terminalCalls()).toHaveLength(2); + expect(errorLogSpy).toHaveBeenCalledOnce(); + expect(checkout.error?.errors).toHaveLength(2); + }); + + it("delivers errors to the element without triggering global error handlers", () => { + const { checkout, send } = startSession(); + const onError = vi.fn(); + const onDocumentError = vi.fn(); + const onWindowError = vi.fn(); + const onGlobalError = vi.fn(); + const originalOnError = window.onerror; + checkout.addEventListener("error", onError); + document.addEventListener("error", onDocumentError); + window.addEventListener("error", onWindowError); + // oxlint-disable-next-line unicorn/prefer-add-event-listener -- Exercise error-reporting hooks. + window.onerror = onGlobalError; + try { + send(provisionalBatches.error); + expect(onError).toHaveBeenCalledOnce(); + expect(onDocumentError).not.toHaveBeenCalled(); + expect(onWindowError).not.toHaveBeenCalled(); + expect(onGlobalError).not.toHaveBeenCalled(); + } finally { + document.removeEventListener("error", onDocumentError); + window.removeEventListener("error", onWindowError); + // oxlint-disable-next-line unicorn/prefer-add-event-listener -- Restore the previous hook. + window.onerror = originalOnError; + } + }); + + it("records failed navigation for a session error before checkout starts", () => { + const durationSpy = vi.spyOn(mockTelemetry(), "recordNavigationDuration"); + const { checkout, send } = openPopupUniversalCheckout(); + const sessionError = clone(provisionalBatches.error[0]!); + Reflect.deleteProperty(sessionError.params.context, "shop_id"); + Object.assign(sessionError.params.context, { scope: "session" }); + const errorSpy = vi.fn(); + checkout.addEventListener("error", errorSpy); + + send([sessionError]); + + expect(errorSpy).toHaveBeenCalledOnce(); + expect(errorSpy.mock.calls[0]![0].detail[0].scope).toBe("session"); + expect(durationSpy).toHaveBeenCalledExactlyOnceWith({ + milliseconds: expect.any(Number), + result: "failure", + preloaded: false, + }); + + send([sessionError]); + expect(durationSpy).toHaveBeenCalledOnce(); + }); + + it("keeps navigation pending after a resource error and succeeds on a later start", () => { + const durationSpy = vi.spyOn(mockTelemetry(), "recordNavigationDuration"); + const { send } = openPopupUniversalCheckout(); + send([provisionalBatches.error[0]!]); + expect(durationSpy).not.toHaveBeenCalled(); + + const otherShopStart = clone(provisionalBatches.start[1]!); + otherShopStart.params.context.revision = 5; + send([otherShopStart]); + + expect(durationSpy).toHaveBeenCalledExactlyOnceWith({ + milliseconds: expect.any(Number), + result: "success", + preloaded: false, + }); + }); + + it("records success when a start precedes a session error in the same batch", () => { + const durationSpy = vi.spyOn(mockTelemetry(), "recordNavigationDuration"); + const { send } = openPopupUniversalCheckout(); + const sessionError = clone(provisionalBatches.error[0]!); + Reflect.deleteProperty(sessionError.params.context, "shop_id"); + Object.assign(sessionError.params.context, { scope: "session" }); + sessionError.params.context.revision = 1; + + send([provisionalBatches.start[0]!, sessionError]); + + expect(durationSpy).toHaveBeenCalledExactlyOnceWith({ + milliseconds: expect.any(Number), + result: "success", + preloaded: false, + }); + }); + + it("treats structured-cloned undefined optional fields as absent", () => { + const { checkout, send } = openPopupUniversalCheckout(); + const start = structuredClone(provisionalBatches.start[0]!); + Object.assign(start.params.checkout, { order: undefined, fulfillment: undefined }); + expect(Object.hasOwn(start.params.checkout, "order")).toBe(true); + expect(Object.hasOwn(start.params.checkout, "fulfillment")).toBe(true); + const startSpy = vi.fn(); + checkout.addEventListener("start", startSpy); + + send([start]); + + expect(startSpy).toHaveBeenCalledOnce(); + const snapshot = startSpy.mock.calls[0]![0].detail[0].checkout; + expect(snapshot).not.toHaveProperty("order"); + expect(snapshot).not.toHaveProperty("fulfillment"); + expect(checkout.checkout?.resources[0]?.checkout).toBe(snapshot); + }); + + it("treats an own undefined notification id as absent but rejects an invalid id", () => { + const decodeSpy = vi.spyOn(mockTelemetry(), "recordProtocolDecodeError"); + const errorLogSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const { checkout, send } = openPopupUniversalCheckout(); + const valid = { ...clone(provisionalBatches.start[0]!), id: undefined }; + const invalid = { ...clone(provisionalBatches.start[1]!), id: { invalid: true } }; + const startSpy = vi.fn(); + checkout.addEventListener("start", startSpy); + + const batch = structuredClone([valid, invalid]); + expect(Object.hasOwn(batch[0]!, "id")).toBe(true); + send(batch); + + expect(startSpy).toHaveBeenCalledOnce(); + expect(startSpy.mock.calls[0]![0].detail).toEqual([resourceDetail(valid.params)]); + expect(checkout.checkout?.resources.map(({ shopId }) => shopId)).toEqual([FIRST_SHOP_ID]); + expect(errorLogSpy).toHaveBeenCalledExactlyOnceWith(expect.stringContaining("id invalid_type")); + expect(decodeSpy).toHaveBeenCalledExactlyOnceWith({ + method: "ec.start", + failureType: "params", + }); + }); + + it("attributes malformed Universal Checkout updates to ec.update telemetry", () => { + const decodeSpy = vi.spyOn(mockTelemetry(), "recordProtocolDecodeError"); + vi.spyOn(console, "error").mockImplementation(() => {}); + const { send } = startSession(); + const malformedUpdate = clone(provisionalBatches.update[0]!); + Object.assign(malformedUpdate.params.checkout, { totals: undefined }); + + send([malformedUpdate]); + + expect(decodeSpy).toHaveBeenCalledExactlyOnceWith({ + method: "ec.update", + failureType: "params", + }); + }); + + it("logs and records one safe decode failure per bad member while delivering valid siblings", () => { + const decodeSpy = vi.spyOn(mockTelemetry(), "recordProtocolDecodeError"); + const errorLogSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const { checkout, send } = openPopupUniversalCheckout(); + const missingTotals = clone(provisionalBatches.start[1]!); + const malformedTotals = { + ...missingTotals, + params: { + ...missingTotals.params, + checkout: { ...missingTotals.params.checkout, totals: undefined }, + }, + }; + expect(Object.hasOwn(malformedTotals.params.checkout, "totals")).toBe(true); + const malformedOrder = clone(provisionalBatches.start[1]!); + Object.assign(malformedOrder.params.checkout, { + order: { id: undefined, permalink_url: "https://shop.example.com/orders/synthetic" }, + }); + const startSpy = vi.fn(); + checkout.addEventListener("start", startSpy); + + send([provisionalBatches.start[0], malformedTotals, malformedOrder]); + + expect(checkout.logLevel).toBe("error"); + expect(startSpy).toHaveBeenCalledOnce(); + expect(startSpy.mock.calls[0]![0].detail).toEqual([ + resourceDetail(provisionalBatches.start[0]!.params), + ]); + expect(checkout.checkout?.resources.map(({ shopId }) => shopId)).toEqual([FIRST_SHOP_ID]); + expect(errorLogSpy).toHaveBeenCalledTimes(2); + expect(errorLogSpy).toHaveBeenNthCalledWith( + 1, + expect.stringContaining("Checkout.totals missing_required"), + ); + expect(errorLogSpy).toHaveBeenNthCalledWith( + 2, + expect.stringContaining("Checkout.order.id missing_required"), + ); + expect(decodeSpy).toHaveBeenCalledTimes(2); + expect(decodeSpy).toHaveBeenNthCalledWith(1, { + method: "ec.start", + failureType: "params", + }); + expect(decodeSpy).toHaveBeenNthCalledWith(2, { + method: "ec.start", + failureType: "params", + }); + }); + + it("drops a nested order field with the wrong scalar type without exposing its value", () => { + const decodeSpy = vi.spyOn(mockTelemetry(), "recordProtocolDecodeError"); + const errorLogSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const { checkout, send } = openPopupUniversalCheckout(); + const malformedOrder = clone(provisionalBatches.start[1]!); + const privateValue = "private-marker-only-for-test"; + Object.assign(malformedOrder.params.checkout, { + order: { id: "gid://shopify/Order/synthetic", permalink_url: { raw: privateValue } }, + }); + const startSpy = vi.fn(); + checkout.addEventListener("start", startSpy); + + send([provisionalBatches.start[0], malformedOrder]); + + expect(startSpy).toHaveBeenCalledOnce(); + expect(startSpy.mock.calls[0]![0].detail).toEqual([ + resourceDetail(provisionalBatches.start[0]!.params), + ]); + expect(checkout.checkout?.resources.map(({ shopId }) => shopId)).toEqual([FIRST_SHOP_ID]); + expect(errorLogSpy).toHaveBeenCalledExactlyOnceWith( + expect.stringContaining("Checkout.order.permalink_url invalid_type"), + ); + expect(errorLogSpy.mock.calls.flat().join(" ")).not.toContain(privateValue); + expect(decodeSpy).toHaveBeenCalledExactlyOnceWith({ + method: "ec.start", + failureType: "params", + }); + }); + + it("rejects non-array required checkout fields without losing valid siblings", () => { + const decodeSpy = vi.spyOn(mockTelemetry(), "recordProtocolDecodeError"); + const errorLogSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const { checkout, send } = openPopupUniversalCheckout(); + const invalidFields = ["line_items", "links", "totals"] as const; + const invalidEntries = invalidFields.map((field) => { + const entry = clone(provisionalBatches.start[1]!); + Object.assign(entry.params.checkout, { [field]: null }); + return entry; + }); + const startSpy = vi.fn(); + checkout.addEventListener("start", startSpy); + + send([provisionalBatches.start[0], ...invalidEntries]); + + expect(startSpy).toHaveBeenCalledOnce(); + expect(startSpy.mock.calls[0]![0].detail).toEqual([ + resourceDetail(provisionalBatches.start[0]!.params), + ]); + expect(checkout.checkout?.resources.map(({ shopId }) => shopId)).toEqual([FIRST_SHOP_ID]); + expect(errorLogSpy).toHaveBeenCalledTimes(invalidFields.length); + expect(decodeSpy).toHaveBeenCalledTimes(invalidFields.length); + invalidFields.forEach((field, index) => { + expect(errorLogSpy).toHaveBeenNthCalledWith( + index + 1, + expect.stringContaining(`Checkout.${field} invalid_type`), + ); + expect(decodeSpy).toHaveBeenNthCalledWith(index + 1, { + method: "ec.start", + failureType: "params", + }); + }); + }); + + it("turns a malformed terminal error for a known shop into a scoped SDK failure", () => { + const decodeSpy = vi.spyOn(mockTelemetry(), "recordProtocolDecodeError"); + const errorLogSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const { checkout, send } = startSession(); + const wireError = clone(provisionalBatches.error[0]!); + const { messages: _, ...errorWithoutMessages } = wireError.params.error; + const malformed = { + ...wireError, + params: { ...wireError.params, error: errorWithoutMessages }, + }; + const healthyUpdate = clone(provisionalBatches.update[0]!); + healthyUpdate.params.context.shop_id = SECOND_SHOP_ID; + healthyUpdate.params.context.revision = 5; + healthyUpdate.params.checkout.id = "gid://shopify/Checkout/456"; + const errorSpy = vi.fn(); + const updateSpy = vi.fn(); + checkout.addEventListener("error", errorSpy); + checkout.addEventListener("update", updateSpy); + + send([malformed, healthyUpdate]); + + expect(errorSpy).toHaveBeenCalledOnce(); + expect(errorSpy.mock.calls[0]![0].detail).toEqual([ + { + context: publicContext(wireError.params), + scope: "resource", + error: { code: "sdk_error", message: expect.any(String) }, + }, + ]); + expect(checkout.error?.errors).toEqual([ + { + context: publicContext(wireError.params), + shopId: FIRST_SHOP_ID, + scope: "resource", + error: { code: "sdk_error", message: expect.any(String) }, + }, + ]); + expect(updateSpy).toHaveBeenCalledOnce(); + expect(updateSpy.mock.calls[0]![0].detail[0].context.shopId).toBe(SECOND_SHOP_ID); + expect(checkout.checkout?.resources[0]?.checkout.status).toBe("incomplete"); + expect(checkout.checkout?.resources[1]?.checkout.status).toBe("ready_for_complete"); + expect(errorLogSpy).toHaveBeenCalledOnce(); + expect(errorLogSpy).toHaveBeenCalledWith( + expect.stringContaining("ErrorResponse.messages missing_required"), + ); + expect(decodeSpy).toHaveBeenCalledExactlyOnceWith({ + method: "ec.error", + failureType: "params", + }); + }); + + it("drops an error with no trusted resource or explicit session scope", () => { + const decodeSpy = vi.spyOn(mockTelemetry(), "recordProtocolDecodeError"); + const errorLogSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const { checkout, send } = startSession(); + const wireError = provisionalBatches.error[0]!; + const ambiguousError = { + ...wireError, + params: { + ...wireError.params, + context: { session_id: SESSION_ID, revision: 4 }, + }, + }; + const healthyUpdate = clone(provisionalBatches.update[0]!); + healthyUpdate.params.context.shop_id = SECOND_SHOP_ID; + healthyUpdate.params.context.revision = 4; + healthyUpdate.params.checkout.id = "gid://shopify/Checkout/456"; + const errorSpy = vi.fn(); + const updateSpy = vi.fn(); + checkout.addEventListener("error", errorSpy); + checkout.addEventListener("update", updateSpy); + + send([ambiguousError, healthyUpdate]); + + expect(errorSpy).not.toHaveBeenCalled(); + expect(checkout.error).toBeUndefined(); + expect(updateSpy).toHaveBeenCalledOnce(); + expect(updateSpy.mock.calls[0]![0].detail[0].context.shopId).toBe(SECOND_SHOP_ID); + expect(errorLogSpy).toHaveBeenCalledExactlyOnceWith( + expect.stringContaining("context.shop_id missing_required"), + ); + expect(decodeSpy).toHaveBeenCalledExactlyOnceWith({ + method: "ec.error", + failureType: "params", + }); + }); + + it("accepts an explicitly scoped session error and stops later notifications in and after its batch", () => { + const { checkout, send } = startSession(); + const wireError = provisionalBatches.error[0]!; + const sessionError = { + ...wireError, + params: { + ...wireError.params, + context: { session_id: SESSION_ID, revision: 4, scope: "session" }, + }, + }; + const failure = { code: "unknown", message: "Universal Checkout is unavailable." }; + const context = { sessionId: SESSION_ID, revision: 4 }; + const errorSpy = vi.fn(); + const updateSpy = vi.fn(); + checkout.addEventListener("error", errorSpy); + checkout.addEventListener("update", updateSpy); + + const lateUpdate = clone(provisionalBatches.update[0]!); + lateUpdate.params.context.revision = 5; + send([sessionError, lateUpdate]); + send([lateUpdate]); + + expect(errorSpy).toHaveBeenCalledOnce(); + expect(errorSpy.mock.calls[0]![0].detail).toEqual([ + { context, scope: "session", error: failure }, + ]); + expect(checkout.error?.errors).toEqual([{ context, scope: "session", error: failure }]); + expect(updateSpy).not.toHaveBeenCalled(); + expect(checkout.checkout?.revision).toBe(1); + }); + + it("answers unsupported and malformed requests with JSON-RPC errors", () => { + const { checkout, mockCheckoutWindow, send } = openPopupUniversalCheckout(); + + send([ + { jsonrpc: "2.0", id: "ready_invalid", method: "ec.ready", params: {} }, + { jsonrpc: "2.0", id: "credential_1", method: "ec.payment.credential_request", params: {} }, + { jsonrpc: "2.0", method: "ec.buyer.change", params: {} }, + ]); + + expect(mockCheckoutWindow.postMessage).toHaveBeenCalledExactlyOnceWith( + [ + { jsonrpc: "2.0", id: "ready_invalid", error: { code: -32602, message: "Invalid params" } }, + { + jsonrpc: "2.0", + id: "credential_1", + error: { code: -32601, message: "Method not found" }, + }, + ], + new URL(checkout.src).origin, + ); + }); + + it("opens window.open requests and answers with the standard result", () => { + const { checkout, mockCheckoutWindow, send, windowOpenSpy } = openPopupUniversalCheckout(); + + send([ + { + jsonrpc: "2.0", + id: "open_1", + method: "ec.window.open_request", + params: { url: "https://shop.example.com/policies/refund-policy" }, + }, + ]); + + expect(windowOpenSpy).toHaveBeenLastCalledWith( + "https://shop.example.com/policies/refund-policy", + "_blank", + "noopener", + ); + expect(mockCheckoutWindow.postMessage).toHaveBeenCalledExactlyOnceWith( + [ + { + jsonrpc: "2.0", + id: "open_1", + result: { ucp: { status: "success", version: "2026-08-25" } }, + }, + ], + new URL(checkout.src).origin, + ); + }); + + it("keeps event details and aggregate snapshots immutable across later updates", () => { + const { checkout, send } = openPopupUniversalCheckout(); + const start = structuredClone(provisionalBatches.start); + const startSpy = vi.fn(); + checkout.addEventListener("start", startSpy); + + send(start); + + const firstDetail = startSpy.mock.calls[0]![0].detail; + const firstAggregate = checkout.checkout; + expect(Object.isFrozen(firstDetail)).toBe(true); + expect(Object.isFrozen(firstDetail[0].context)).toBe(true); + expect(Object.isFrozen(firstDetail[0].checkout)).toBe(true); + expect(Object.isFrozen(firstDetail[0].checkout.lineItems)).toBe(true); + expect(Object.isFrozen(firstAggregate)).toBe(true); + expect(Object.isFrozen(firstAggregate?.resources)).toBe(true); + Object.assign(start[0]!.params.checkout, { status: "completed" }); + + send(provisionalBatches.update); + + expect(firstDetail[0].checkout.status).toBe("incomplete"); + expect(firstAggregate?.resources[0]?.checkout.status).toBe("incomplete"); + expect(checkout.checkout?.resources[0]?.checkout.status).toBe("ready_for_complete"); + }); + + it("dispatches close with null detail when the presentation closes", () => { + const { checkout } = openPopupUniversalCheckout(); + const closeSpy = vi.fn(); + checkout.addEventListener("close", closeSpy); + + checkout.close(); + + expect(closeSpy).toHaveBeenCalledOnce(); + expect((closeSpy.mock.calls[0]![0] as CustomEvent).detail).toBeNull(); + }); + + it("ignores old messages after close and binds a reopened session to its new window", () => { + const { + checkout, + mockCheckoutWindow: firstWindow, + windowOpenSpy, + send, + } = openPopupUniversalCheckout(); + send(provisionalBatches.start); + checkout.close(); + + send(provisionalBatches.update); + expect(checkout.checkout?.revision).toBe(1); + const secondWindow = createMockWindow(); + windowOpenSpy.mockReturnValue(secondWindow); + checkout.open(); + expect(checkout.checkout).toBeUndefined(); + + send(provisionalBatches.ready.request, { source: firstWindow }); + send(provisionalBatches.start, { source: firstWindow }); + expect(firstWindow.postMessage).not.toHaveBeenCalled(); + expect(checkout.checkout).toBeUndefined(); + + send(provisionalBatches.ready.request, { source: secondWindow }); + send(provisionalBatches.start, { source: secondWindow }); + expect(secondWindow.postMessage).toHaveBeenCalledExactlyOnceWith( + provisionalBatches.ready.response, + new URL(checkout.src).origin, + ); + expect(checkout.checkout?.resources).toHaveLength(2); + }); + + it("reports malformed protocol envelopes without exposing payloads or logging unrelated traffic", () => { + const decodeSpy = vi.spyOn(mockTelemetry(), "recordProtocolDecodeError"); + const errorLogSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const { checkout, send } = openPopupUniversalCheckout(); + + send(provisionalBatches.start[0]); + send([]); + send({ jsonrpc: "2.0", id: "response-example", result: {} }); + send({ kind: "unrelated" }); + send("unrelated"); + + expect(checkout.checkout).toBeUndefined(); + expect(errorLogSpy.mock.calls).toEqual( + Array.from({ length: 3 }, () => [ + ": dropped malformed Universal Checkout protocol batch", + ]), + ); + expect(decodeSpy).toHaveBeenCalledTimes(3); + for (const [metric] of decodeSpy.mock.calls) { + expect(metric).toEqual({ method: "unknown", failureType: "envelope" }); + } + }); + + const envelopeOptOutCases: { + name: string; + attributes: Record; + logs: number; + metrics: number; + }[] = [ + { name: "console logging", attributes: { "log-level": "none" }, logs: 0, metrics: 1 }, + { name: "telemetry", attributes: { telemetry: "false" }, logs: 1, metrics: 0 }, + ]; + it.each(envelopeOptOutCases)( + "can disable $name independently for malformed envelopes", + ({ attributes, logs, metrics }) => { + const decodeSpy = vi.spyOn(mockTelemetry(), "recordProtocolDecodeError"); + const errorLogSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const { send } = openPopupUniversalCheckout(attributes); + + send(provisionalBatches.start[0]); + + expect(errorLogSpy).toHaveBeenCalledTimes(logs); + expect(decodeSpy).toHaveBeenCalledTimes(metrics); + }, + ); + + it.each([ + ["a different window", { source: createMockWindow() }], + ["a null sender", { source: null }], + ["another HTTPS origin", { origin: "https://untrusted.example.com" }], + ["a non-HTTPS origin", { origin: "http://shop.example.com" }], + ])("drops a batch from %s", (_, options) => { + const { checkout, send } = openPopupUniversalCheckout(); + + send(provisionalBatches.start, options); + + expect(checkout.checkout).toBeUndefined(); + }); + + describe("source validation", () => { + it("warns at warn log level when src is not a uc checkout URL", () => { + const checkout = renderUniversalCheckout({ src: CN_SRC, "log-level": "warn" }); + vi.spyOn(window, "open").mockReturnValue(createMockWindow()); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + checkout.open(); + + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining("expected a Universal Checkout continuation URL"), + ); + }); + + it("does not warn when src is a uc checkout URL", () => { + const checkout = renderUniversalCheckout({ "log-level": "warn" }); + vi.spyOn(window, "open").mockReturnValue(createMockWindow()); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + checkout.open(); + + expect(warnSpy).not.toHaveBeenCalled(); + }); + + it("does not warn about source at the default error log level", () => { + const checkout = renderUniversalCheckout({ src: CN_SRC }); + vi.spyOn(window, "open").mockReturnValue(createMockWindow()); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + checkout.open(); + + expect(warnSpy).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/platforms/web/src/universal.ts b/platforms/web/src/universal.ts new file mode 100644 index 000000000..fc1a51912 --- /dev/null +++ b/platforms/web/src/universal.ts @@ -0,0 +1,502 @@ +/* eslint ssr-friendly/no-dom-globals-in-module-scope: off */ + +import type { + CheckoutAppearance, + CheckoutMethods, + CheckoutTarget, + LogLevel, + MessageRejectedDetail, + TypedEventListener, +} from "./checkout.types"; +import { + applyCheckoutTargetClass, + attachCheckoutShadow, + checkoutSourceURL, + handleWindowOpenRequest, + isCheckoutMessageFromPresentation, + openCheckoutPresentation, + removeCheckoutTargetClass, + validateCheckoutMessageOrigin, + type CheckoutPresentation, +} from "./internal/checkout-element"; +import { coerceLogLevel, Logger } from "./logger"; +import { createTelemetry, telemetryProtocolMethod, type CheckoutKitTelemetry } from "./telemetry"; +import { isUniversalCheckoutUrl, UNIVERSAL_CHECKOUT_PROTOCOL_VERSION } from "./universal.protocol"; +import { + UniversalCheckoutSession, + type ShopifyUniversalCheckoutCompleteEvent, + type ShopifyUniversalCheckoutStartEvent, + type ShopifyUniversalCheckoutUpdateEvent, + ShopifyUniversalCheckoutErrorEvent, +} from "./universal.session"; +import type { + UniversalCheckout, + UniversalCheckoutError, + UniversalCheckoutFailure, +} from "./universal.types"; + +declare const CHECKOUT_KIT_PACKAGE_VERSION: string; + +const CHECKOUT_APPEARANCES = new Map([ + ["app:light", { colorScheme: "light", branding: "app" }], + ["app:dark", { colorScheme: "dark", branding: "app" }], + ["app:automatic", { colorScheme: "automatic", branding: "app" }], + ["storefront", { colorScheme: "web_default", branding: "shop" }], +]); + +/** + * A Universal Checkout element. Checkout Kit answers the batched `ec.ready` + * handshake, validates every entry of each JSON-RPC batch, commits the batch to + * the session state, then dispatches ordered arrays of accepted notifications. + * + * @attribute src - A Universal Checkout continuation URL. + * @attribute target - Where checkout is presented (auto, popup, new tab, or a named window). + * @attribute appearance - Checkout appearance preference. + * @attribute log-level - Console logging verbosity (debug, warn, error, or none). + * @attribute telemetry - Set to false to disable anonymous diagnostic metrics. + * @attribute allowed-origins - Extra trusted message origins, separated by spaces or commas. + * + * @event start - One or more child checkouts became interactive. + * @event update - One or more full child snapshots changed. + * @event complete - The session's explicit completion notification. + * @event error - One or more resource or session failures. + * @event close - The presentation closed without a checkout outcome; detail is null. + */ +export class ShopifyUniversalCheckout extends HTMLElement implements CheckoutMethods { + static observedAttributes = ["src", "target", "appearance", "telemetry"] as const; + + constructor() { + super(); + attachCheckoutShadow(this); + } + + #session?: UniversalCheckoutSession; + #presentation?: CheckoutPresentation; + #checkoutProtocolController?: AbortController; + #logger = new Logger("", () => this.logLevel); + #telemetryClient?: CheckoutKitTelemetry; + #navigationStartedAt?: number; + #localError?: UniversalCheckoutError; + + get #recorder(): CheckoutKitTelemetry | undefined { + if (!this.telemetry) return undefined; + return (this.#telemetryClient ??= createTelemetry()); + } + + get src(): string { + return this.getAttribute("src") ?? ""; + } + + set src(value: string | undefined) { + this.#setAttribute("src", value); + } + + get target(): CheckoutTarget | string { + return this.getAttribute("target") ?? "auto"; + } + + set target(value: CheckoutTarget | string | undefined) { + this.#setAttribute("target", value); + } + + get logLevel(): LogLevel { + return coerceLogLevel(this.getAttribute("log-level")); + } + + set logLevel(value: LogLevel | undefined) { + this.#setAttribute("log-level", value); + } + + get telemetry(): boolean { + return this.getAttribute("telemetry")?.toLowerCase() !== "false"; + } + + set telemetry(value: boolean | undefined) { + if (value === undefined || value === null) { + this.removeAttribute("telemetry"); + return; + } + const input: unknown = value; + const enabled = typeof input === "string" ? input.toLowerCase() !== "false" : Boolean(input); + this.setAttribute("telemetry", String(enabled)); + } + + get appearance(): CheckoutAppearance | string { + return this.getAttribute("appearance") ?? "storefront"; + } + + set appearance(value: CheckoutAppearance | string | undefined) { + this.#setAttribute("appearance", value); + } + + get allowedOrigins(): string[] { + const attr = this.getAttribute("allowed-origins"); + if (!attr) return []; + return attr.split(/[\s,]+/).filter(Boolean); + } + + set allowedOrigins(value: string[] | string | undefined) { + if (value == null) { + this.removeAttribute("allowed-origins"); + return; + } + this.#setAttribute("allowed-origins", Array.isArray(value) ? value.join(" ") : value); + } + + onMessageRejected?: (detail: MessageRejectedDetail) => void; + + /** Every checkout in the open session, after the latest applied batch. */ + get checkout(): UniversalCheckout | undefined { + return this.#session?.checkout; + } + + /** The error of every failed checkout in the open session. */ + get error(): UniversalCheckoutError | undefined { + return this.#localError ?? this.#session?.error; + } + + open(): void { + const src = this.#srcAsURL({ warnInvalidAppearance: true })?.href; + if (!src) { + this.#logger.error("src property is empty or invalid, cannot open checkout"); + this.#recorder?.recordError({ + category: "navigation", + stage: "initialization", + code: "invalid_url", + retryable: false, + isRetry: false, + }); + this.#emitLocalFailure({ code: "sdk_error", message: "Checkout URL is invalid." }); + return; + } + + this.#warnOnNonUniversalSource(); + this.close(); + this.#localError = undefined; + let presentation: CheckoutPresentation | undefined; + const session: UniversalCheckoutSession = new UniversalCheckoutSession({ + target: this, + logger: this.#logger, + openWindow: (request) => + handleWindowOpenRequest(request, UNIVERSAL_CHECKOUT_PROTOCOL_VERSION, this.#logger), + isActive: (): boolean => + this.#session === session && + this.#presentation === presentation && + presentation?.isActive() === true, + recordDecodeError: (method, failureType) => + this.#recorder?.recordProtocolDecodeError({ + method: telemetryProtocolMethod(method), + failureType, + }), + recordTerminalError: () => + this.#recorder?.recordError({ + category: "protocol", + stage: "message", + code: "terminal_error", + retryable: false, + isRetry: false, + }), + recordStart: () => this.#recordNavigationDuration("success"), + recordNavigationFailure: () => this.#recordNavigationDuration("failure"), + }); + this.#session = session; + this.#navigationStartedAt = performance.now(); + try { + presentation = openCheckoutPresentation({ + element: this, + src, + target: this.target, + onUnsafeTarget: (unsafeTarget) => { + this.#logger.warn( + `target="${unsafeTarget}" would navigate the current page; falling back to "auto"`, + ); + }, + onClose: () => { + if (this.#presentation !== presentation) return; + this.#presentation = undefined; + this.#navigationStartedAt = undefined; + this.dispatchEvent(new ShopifyUniversalCheckoutCloseEvent()); + }, + }); + this.#presentation = presentation; + } catch { + this.#navigationStartedAt = undefined; + this.#recorder?.recordError({ + category: "navigation", + stage: "presentation", + code: "blocked", + retryable: false, + isRetry: false, + }); + this.#emitLocalFailure({ code: "sdk_error", message: "Checkout could not open." }); + return; + } + + if (!presentation.checkoutWindow) { + this.#navigationStartedAt = undefined; + this.#recorder?.recordError({ + category: "navigation", + stage: "presentation", + code: "blocked", + retryable: false, + isRetry: false, + }); + this.#emitLocalFailure({ code: "sdk_error", message: "Checkout popup was blocked." }); + presentation.close(); + } + } + + close(): void { + this.#presentation?.close(); + } + + override focus(): void { + this.#presentation?.focus(); + } + + connectedCallback(): void { + this.#recorder?.start(); + applyCheckoutTargetClass(this, this.target); + this.#initCheckoutProtocol(); + } + + disconnectedCallback(): void { + this.#checkoutProtocolController?.abort(); + this.#checkoutProtocolController = undefined; + this.close(); + const telemetryClient = this.#telemetryClient; + this.#telemetryClient = undefined; + if (telemetryClient) void telemetryClient.shutdown({ keepalive: true }); + } + + attributeChangedCallback( + name: (typeof ShopifyUniversalCheckout.observedAttributes)[number], + oldValue: string | null, + newValue: string | null, + ): void { + if (oldValue === newValue) return; + + if (name === "target") { + if (this.#presentation) this.close(); + removeCheckoutTargetClass(this, oldValue); + applyCheckoutTargetClass(this, this.target); + } else if (name === "src") { + if (this.#presentation) this.close(); + } else if (name === "telemetry") { + if (this.telemetry) { + if (this.isConnected) this.#recorder?.start(); + } else { + this.#navigationStartedAt = undefined; + const telemetryClient = this.#telemetryClient; + this.#telemetryClient = undefined; + if (telemetryClient) void telemetryClient.shutdown({ discardPending: true }); + } + } + } + + override addEventListener( + type: "start", + listener: TypedEventListener | null, + options?: boolean | AddEventListenerOptions, + ): void; + + override addEventListener( + type: "update", + listener: TypedEventListener | null, + options?: boolean | AddEventListenerOptions, + ): void; + + override addEventListener( + type: "complete", + listener: TypedEventListener | null, + options?: boolean | AddEventListenerOptions, + ): void; + + override addEventListener( + type: "error", + listener: TypedEventListener | null, + options?: boolean | AddEventListenerOptions, + ): void; + + override addEventListener( + type: "close", + listener: TypedEventListener | null, + options?: boolean | AddEventListenerOptions, + ): void; + + override addEventListener( + type: K, + listener: TypedEventListener | null, + options?: boolean | AddEventListenerOptions, + ): void; + + override addEventListener( + type: string, + listener: EventListenerOrEventListenerObject | null, + options?: boolean | AddEventListenerOptions, + ): void; + + override addEventListener( + type: string, + listener: EventListenerOrEventListenerObject | null, + options?: boolean | AddEventListenerOptions, + ): void { + if (listener === null) return; + super.addEventListener(type, listener, options); + } + + #srcAsURL({ warnInvalidAppearance = false } = {}): URL | undefined { + const url = checkoutSourceURL(this.src); + if (!url) return; + + url.searchParams.delete("ec_auth"); + url.searchParams.delete("ec_color_scheme"); + url.searchParams.delete("ck_branding"); + url.searchParams.set("ec_version", UNIVERSAL_CHECKOUT_PROTOCOL_VERSION); + url.searchParams.set("ec_delegate", "window.open"); + const appearance = this.appearance; + const appearanceParams = CHECKOUT_APPEARANCES.get(appearance); + if (!appearanceParams && appearance !== "" && warnInvalidAppearance) { + this.#logger.warn(`appearance="${appearance}" is not supported and will be ignored`); + } + if (appearanceParams) { + url.searchParams.set("ec_color_scheme", appearanceParams.colorScheme); + url.searchParams.set("ck_branding", appearanceParams.branding); + } + url.searchParams.set("ck_version", CHECKOUT_KIT_PACKAGE_VERSION); + return url; + } + + #recordNavigationDuration(result: "success" | "failure"): void { + const startedAt = this.#navigationStartedAt; + if (startedAt === undefined) return; + this.#navigationStartedAt = undefined; + this.#recorder?.recordNavigationDuration({ + milliseconds: performance.now() - startedAt, + result, + preloaded: false, + }); + } + + #emitLocalFailure(failure: UniversalCheckoutFailure): void { + const entry = Object.freeze({ scope: "session" as const, error: Object.freeze(failure) }); + const detail = Object.freeze([entry]); + this.#localError = Object.freeze({ errors: detail }); + this.dispatchEvent(new ShopifyUniversalCheckoutErrorEvent(detail)); + } + + #setAttribute(name: string, value: string | boolean | undefined): void { + if (value === true) { + this.setAttribute(name, ""); + } else if (value != null && value !== false) { + this.setAttribute(name, value); + } else { + this.removeAttribute(name); + } + } + + #initCheckoutProtocol(): void { + this.#checkoutProtocolController?.abort(); + this.#checkoutProtocolController = new AbortController(); + window.addEventListener("message", this.#handleMessage, { + signal: this.#checkoutProtocolController.signal, + }); + window.addEventListener( + "pagehide", + () => void this.#telemetryClient?.flush({ keepalive: true }), + { signal: this.#checkoutProtocolController.signal }, + ); + } + + #handleMessage = (event: MessageEvent): void => { + if (!isCheckoutMessageFromPresentation(event, this.#presentation)) return; + + try { + validateCheckoutMessageOrigin(event, this.#srcAsURL(), this.allowedOrigins, (message) => { + this.#logger.warn(message); + }); + } catch (error) { + this.#rejectMessage(event, error); + return; + } + + this.#session?.handleMessage(event); + }; + + #rejectMessage(event: MessageEvent, error: unknown): void { + const reason = error instanceof Error ? error.message : String(error); + if (this.onMessageRejected) { + try { + this.onMessageRejected({ origin: event.origin, data: event.data, reason }); + } catch (callbackError) { + this.#logger.error( + "onMessageRejected callback threw", + callbackError instanceof Error ? callbackError.message : String(callbackError), + ); + } + return; + } + this.#logger.warn(reason); + } + + #warnOnNonUniversalSource(): void { + let url: URL; + try { + url = new URL(this.src); + } catch { + return; + } + if (!isUniversalCheckoutUrl(url)) { + this.#logger.warn("expected a Universal Checkout continuation URL"); + } + } +} + +export class ShopifyUniversalCheckoutCloseEvent extends CustomEvent { + declare type: "close"; + + constructor() { + super("close", { bubbles: true }); + } +} + +declare global { + interface HTMLElementTagNameMap { + "shopify-universal-checkout": ShopifyUniversalCheckout; + } +} + +if (!customElements.get("shopify-universal-checkout")) { + customElements.define("shopify-universal-checkout", ShopifyUniversalCheckout); +} + +export { + ShopifyUniversalCheckoutCompleteEvent, + ShopifyUniversalCheckoutErrorEvent, + ShopifyUniversalCheckoutStartEvent, + ShopifyUniversalCheckoutUpdateEvent, +} from "./universal.session"; + +// Public configuration types shared with the standard Web entry. +export type { + CheckoutAppearance, + CheckoutTarget, + LogLevel, + MessageRejectedDetail, +} from "./checkout.types"; + +export type { + UniversalCheckout, + UniversalCheckoutContext, + UniversalCheckoutError, + UniversalCheckoutErrorCode, + UniversalCheckoutErrorContext, + UniversalCheckoutErrorEventDetail, + UniversalCheckoutFailure, + UniversalCheckoutResourceEventEntry, + UniversalCheckoutErrorEventEntry, + UniversalCheckoutResource, + UniversalCheckoutResourceError, + UniversalCheckoutResourceEventDetail, + UniversalCheckoutSnapshot, +} from "./universal.types"; diff --git a/platforms/web/src/universal.types.test.ts b/platforms/web/src/universal.types.test.ts new file mode 100644 index 000000000..a9d9e88a2 --- /dev/null +++ b/platforms/web/src/universal.types.test.ts @@ -0,0 +1,101 @@ +import type { Checkout as ProtocolCheckout } from "@shopify/checkout-kit-protocol"; + +import { describe, expectTypeOf, it } from "vitest"; + +import type { Checkout } from "./checkout.types"; +import type { + ShopifyUniversalCheckout, + ShopifyUniversalCheckoutCloseEvent, + ShopifyUniversalCheckoutCompleteEvent, + ShopifyUniversalCheckoutErrorEvent, + ShopifyUniversalCheckoutStartEvent, + ShopifyUniversalCheckoutUpdateEvent, + UniversalCheckoutContext, + UniversalCheckoutErrorEventDetail, + UniversalCheckoutFailure, + UniversalCheckoutResourceEventDetail, + UniversalCheckoutSnapshot, +} from "./universal"; + +describe("Universal Checkout public types", () => { + it("keeps typed checkout fields while rejecting protocol metadata", () => { + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf< + Checkout["status"] | "unknown" + >(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().not.toExtend(); + }); + + it("preserves native and custom listeners alongside typed lifecycle events", () => { + const checkout = document.createElement("shopify-universal-checkout"); + + checkout.addEventListener("click", (event) => { + expectTypeOf(event).toEqualTypeOf(); + }); + checkout.addEventListener("keydown", (event) => { + expectTypeOf(event).toEqualTypeOf(); + }); + + const customType: string = "sample:refresh"; + checkout.addEventListener(customType, (event) => { + expectTypeOf(event).toEqualTypeOf(); + }); + checkout.addEventListener(customType, { + handleEvent(event) { + expectTypeOf(event).toEqualTypeOf(); + }, + }); + checkout.addEventListener(customType, null); + + checkout.addEventListener("start", (event) => { + expectTypeOf(event).toEqualTypeOf(); + }); + checkout.addEventListener("update", (event) => { + expectTypeOf(event).toEqualTypeOf(); + }); + checkout.addEventListener("complete", (event) => { + expectTypeOf(event).toEqualTypeOf(); + }); + checkout.addEventListener("error", (event) => { + expectTypeOf(event).toEqualTypeOf(); + }); + checkout.addEventListener("close", (event) => { + expectTypeOf(event).toEqualTypeOf(); + }); + }); + + it("uses the public snapshot, context, and error types for events and element state", () => { + type StateSnapshot = NonNullable< + ShopifyUniversalCheckout["checkout"] + >["resources"][number]["checkout"]; + type StateError = NonNullable["errors"][number]["error"]; + + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf< + ShopifyUniversalCheckoutStartEvent["detail"] + >().toEqualTypeOf(); + expectTypeOf< + ShopifyUniversalCheckoutErrorEvent["detail"] + >().toEqualTypeOf(); + expectTypeOf< + ShopifyUniversalCheckoutStartEvent["detail"][number]["checkout"] + >().toEqualTypeOf(); + expectTypeOf< + ShopifyUniversalCheckoutUpdateEvent["detail"][number]["checkout"] + >().toEqualTypeOf(); + expectTypeOf< + ShopifyUniversalCheckoutCompleteEvent["detail"][number]["checkout"] + >().toEqualTypeOf(); + expectTypeOf< + ShopifyUniversalCheckoutUpdateEvent["detail"][number]["context"] + >().toEqualTypeOf(); + expectTypeOf< + ShopifyUniversalCheckoutErrorEvent["detail"][number]["error"] + >().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf< + "resource" | "session" + >(); + }); +}); diff --git a/platforms/web/src/universal.types.ts b/platforms/web/src/universal.types.ts new file mode 100644 index 000000000..d0d0e283a --- /dev/null +++ b/platforms/web/src/universal.types.ts @@ -0,0 +1,91 @@ +import type { Checkout } from "./checkout.types"; + +export type UniversalCheckoutEventType = "start" | "update" | "complete"; + +/** + * A child checkout snapshot. Checkout Kit omits the top-level `ucp` protocol + * metadata and keeps every other checkout field, including `status`. + */ +export type UniversalCheckoutSnapshot = { + readonly [Key in keyof Checkout as Key extends "ucp" ? never : Key]: Key extends "status" + ? Checkout[Key] | "unknown" + : Checkout[Key]; +} & { readonly ucp?: never }; + +/** Identifies the session revision and checkout resource a notification describes. */ +export interface UniversalCheckoutContext { + readonly sessionId: string; + readonly revision: number; + readonly shopId: string; +} + +/** One resource entry in a public lifecycle batch. */ +export interface UniversalCheckoutResourceEventEntry { + readonly context: UniversalCheckoutContext; + readonly checkout: UniversalCheckoutSnapshot; +} + +/** Each public event contains the accepted entries of one contiguous lifecycle batch. */ +export type UniversalCheckoutResourceEventDetail = readonly UniversalCheckoutResourceEventEntry[]; + +/** Session errors have no shop ID; resource errors retain their resource context. */ +export interface UniversalCheckoutErrorContext { + readonly sessionId: string; + readonly revision: number; + readonly shopId?: string; +} + +/** Stable Checkout Kit error codes. Unrecognized checkout codes become `unknown`. */ +export type UniversalCheckoutErrorCode = + | "storefront_password_required" + | "customer_account_required" + | "cart_expired" + | "cart_completed" + | "invalid_cart" + | "sdk_error" + | "unknown"; + +/** A Kit-owned failure, independent of the checkout protocol's error response. */ +export interface UniversalCheckoutFailure { + readonly code: UniversalCheckoutErrorCode; + readonly message: string; + readonly httpStatusCode?: number; +} + +/** One resource or session error in a public lifecycle batch. */ +export interface UniversalCheckoutErrorEventEntry { + /** Absent for failures before a session can be established, such as a blocked popup. */ + readonly context?: UniversalCheckoutErrorContext; + readonly scope: "resource" | "session"; + readonly error: UniversalCheckoutFailure; +} + +export type UniversalCheckoutErrorEventDetail = readonly UniversalCheckoutErrorEventEntry[]; + +/** One checkout in the session; its status is `checkout.status`. */ +export interface UniversalCheckoutResource { + readonly id: string; + readonly shopId: string; + readonly checkout: UniversalCheckoutSnapshot; +} + +/** Every checkout in the session after the latest applied batch. */ +export interface UniversalCheckout { + readonly sessionId: string; + readonly revision: number; + readonly resources: readonly UniversalCheckoutResource[]; +} + +export interface UniversalCheckoutResourceError { + readonly context?: UniversalCheckoutErrorContext; + readonly shopId?: string; + readonly scope: "resource" | "session"; + readonly error: UniversalCheckoutFailure; +} + +/** The error of every failed checkout in the session. */ +export interface UniversalCheckoutError { + readonly sessionId?: string; + readonly revision?: number; + readonly errors: readonly UniversalCheckoutResourceError[]; +} diff --git a/platforms/web/vite.config.ts b/platforms/web/vite.config.ts index 4b4a9c65f..da6f19ed2 100644 --- a/platforms/web/vite.config.ts +++ b/platforms/web/vite.config.ts @@ -32,9 +32,12 @@ export default defineConfig({ emptyOutDir: true, outDir: fromRoot('dist'), lib: { - entry: fromRoot('src/index.ts'), + entry: { + index: fromRoot('src/index.ts'), + universal: fromRoot('src/universal.ts'), + }, formats: ['es'], - fileName: () => 'index.js', + fileName: (_, entryName) => `${entryName}.js`, }, rollupOptions: { // Zero runtime deps — bundle everything reachable from src/index.ts. diff --git a/telemetry/contract/metrics.md b/telemetry/contract/metrics.md index de4418183..9211a5408 100644 --- a/telemetry/contract/metrics.md +++ b/telemetry/contract/metrics.md @@ -49,7 +49,8 @@ Monotonic delta counter for ECP messages that cannot be decoded. Attributes: -- `method`: a supported ECP method or `unknown` +- `method`: a supported ECP method (including Universal Checkout's + full-snapshot `ec.update`) or `unknown` - `failure_type`: `envelope`, `params`, `serialization`, or `unknown` The raw message and decoder error are never recorded. diff --git a/telemetry/languages/typescript/src/protocol-method.ts b/telemetry/languages/typescript/src/protocol-method.ts index 6509c6dd7..a3b9075a4 100644 --- a/telemetry/languages/typescript/src/protocol-method.ts +++ b/telemetry/languages/typescript/src/protocol-method.ts @@ -2,8 +2,12 @@ import {embeddedCheckoutMethods} from '@shopify/checkout-kit-protocol'; import type {TelemetryProtocolMethod} from './types'; +// Universal Checkout's full-snapshot update is not in the generated +// single-checkout protocol catalog yet. Keep the additional label bounded. +const universalCheckoutMethods: ReadonlySet = new Set(['ec.update']); + export function toProtocolMethod(method: string): TelemetryProtocolMethod { - return embeddedCheckoutMethods.has(method) + return embeddedCheckoutMethods.has(method) || universalCheckoutMethods.has(method) ? (method as TelemetryProtocolMethod) : 'unknown'; } diff --git a/telemetry/languages/typescript/src/types.ts b/telemetry/languages/typescript/src/types.ts index 7bbee0cc4..098a6deb8 100644 --- a/telemetry/languages/typescript/src/types.ts +++ b/telemetry/languages/typescript/src/types.ts @@ -32,6 +32,7 @@ export type TelemetryErrorCode = export type TelemetryProtocolMethod = | CheckoutProtocolCatalogMethod | CheckoutProtocolRequestMethod + | 'ec.update' | 'unknown'; export type TelemetryDecodeFailureType = diff --git a/telemetry/languages/typescript/test/client.test.ts b/telemetry/languages/typescript/test/client.test.ts index 3275cbcc5..9e8a5da87 100644 --- a/telemetry/languages/typescript/test/client.test.ts +++ b/telemetry/languages/typescript/test/client.test.ts @@ -167,6 +167,25 @@ describe('CheckoutKitTelemetry', () => { expect(fetch.mock.calls[0]![1].body).toContain('ec.buyer.change'); }); + it('records Universal Checkout updates with a bounded method label', async () => { + const fetch = vi.fn().mockResolvedValue({ok: true}); + const telemetry = createCheckoutKitTelemetryForTesting({ + sdkVersion: '1.2.3', + fetch, + now: () => BigInt(1), + }); + + telemetry.recordProtocolDecodeError({ + method: 'ec.update', + failureType: 'params', + }); + await telemetry.flush(); + + const body = fetch.mock.calls[0]![1].body as string; + expect(body).toContain('ec.update'); + expect(body).not.toContain('unknown'); + }); + it('awaits an in-flight export during shutdown', async () => { let resolveFetch: ((value: {ok: boolean}) => void) | undefined; const fetch = vi.fn().mockImplementation(