diff --git a/platforms/react-native/modules/@shopify/checkout-kit-react-native/package.snapshot.json b/platforms/react-native/modules/@shopify/checkout-kit-react-native/package.snapshot.json index 65fa48efa..b1b48744f 100644 --- a/platforms/react-native/modules/@shopify/checkout-kit-react-native/package.snapshot.json +++ b/platforms/react-native/modules/@shopify/checkout-kit-react-native/package.snapshot.json @@ -108,6 +108,8 @@ "node_modules/@shopify/checkout-kit-protocol/src/protocol.ts", "node_modules/@shopify/checkout-kit-protocol/src/url.d.ts", "node_modules/@shopify/checkout-kit-protocol/src/url.ts", + "node_modules/@shopify/checkout-kit-protocol/src/window_open.d.ts", + "node_modules/@shopify/checkout-kit-protocol/src/window_open.ts", "package.json", "RNShopifyCheckoutKit.podspec", "src/components/AcceleratedCheckoutButtons.tsx", diff --git a/platforms/web/package.json b/platforms/web/package.json index 26f4d20d5..8bee8184f 100644 --- a/platforms/web/package.json +++ b/platforms/web/package.json @@ -77,6 +77,7 @@ }, "devDependencies": { "@custom-elements-manifest/analyzer": "^0.10.4", + "@shopify/checkout-kit-protocol": "workspace:*", "@types/node": "^22.10.0", "@vitest/coverage-v8": "^4.1.0", "happy-dom": "^20.8.9", diff --git a/platforms/web/package.snapshot.json b/platforms/web/package.snapshot.json index c77f1d263..4bda13232 100644 --- a/platforms/web/package.snapshot.json +++ b/platforms/web/package.snapshot.json @@ -7,7 +7,6 @@ "dist/index.d.ts", "dist/index.js", "dist/index.js.map", - "dist/ucp-embed-types.d.ts", "dist/utils.d.ts", "LICENSE", "package.json", @@ -17,6 +16,5 @@ "src/checkout.ts", "src/checkout.types.ts", "src/index.ts", - "src/ucp-embed-types.ts", "src/utils.ts" ] diff --git a/platforms/web/pnpm-lock.yaml b/platforms/web/pnpm-lock.yaml index 64a55550f..1f2c20eef 100644 --- a/platforms/web/pnpm-lock.yaml +++ b/platforms/web/pnpm-lock.yaml @@ -11,6 +11,9 @@ importers: '@custom-elements-manifest/analyzer': specifier: ^0.10.4 version: 0.10.10 + '@shopify/checkout-kit-protocol': + specifier: workspace:* + version: link:../../protocol/languages/typescript '@types/node': specifier: ^22.10.0 version: 22.19.18 @@ -42,6 +45,16 @@ importers: specifier: ^4.1.0 version: 4.1.8(@types/node@22.19.18)(@vitest/coverage-v8@4.1.8)(happy-dom@20.8.9)(vite@8.0.16(@types/node@22.19.18)(esbuild@0.28.1)) + ../../protocol/languages/typescript: + dependencies: + '@babel/runtime': + specifier: ^7.25.0 + version: 7.29.7 + devDependencies: + typescript: + specifier: ^5.9.2 + version: 5.9.3 + packages: '@babel/helper-string-parser@7.29.7': @@ -57,6 +70,10 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + '@babel/runtime@7.29.7': + resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==, tarball: https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz} + engines: {node: '>=6.9.0'} + '@babel/types@7.29.7': resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} engines: {node: '>=6.9.0'} @@ -1712,6 +1729,8 @@ snapshots: dependencies: '@babel/types': 7.29.7 + '@babel/runtime@7.29.7': {} + '@babel/types@7.29.7': dependencies: '@babel/helper-string-parser': 7.29.7 diff --git a/platforms/web/pnpm-workspace.yaml b/platforms/web/pnpm-workspace.yaml new file mode 100644 index 000000000..a9fd8205a --- /dev/null +++ b/platforms/web/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +packages: + - '../../protocol/languages/typescript' diff --git a/platforms/web/src/checkout.test.ts b/platforms/web/src/checkout.test.ts index f6bc1461e..13f595b81 100644 --- a/platforms/web/src/checkout.test.ts +++ b/platforms/web/src/checkout.test.ts @@ -1,14 +1,11 @@ import { afterEach, describe, expect, it, vi } from "vitest"; +import { EmbeddedCheckoutProtocol } from "@shopify/checkout-kit-protocol"; -import type { Checkout, CheckoutProtocolMessageMap, UcpErrorResponse } from "./checkout.types"; -import type { CheckoutMessageError } from "./ucp-embed-types"; +import type { CheckoutProtocolMessageMap, ErrorResponse, Message } from "./checkout.types"; import "./checkout-web-component"; -import { - DEFAULT_POPUP_WIDTH, - DEFAULT_POPUP_HEIGHT, - EMBED_PROTOCOL_VERSION, - CK_VERSION, -} from "./checkout"; +import { DEFAULT_POPUP_WIDTH, DEFAULT_POPUP_HEIGHT, CK_VERSION } from "./checkout"; + +const EMBED_PROTOCOL_VERSION = EmbeddedCheckoutProtocol.specVersion; import type { ShopifyCheckout } from "./checkout"; const POPUP_TARGETS = ["popup"] as const; @@ -740,10 +737,14 @@ describe("", () => { { delegate: [] }, { id: "ready-1", source: mockCheckoutWindow }, ); - await Promise.resolve(); + await flushProtocolDispatch(); expect(mockCheckoutWindow.postMessage).toHaveBeenCalledWith( - { jsonrpc: "2.0", id: "ready-1", result: {} }, + { + jsonrpc: "2.0", + id: "ready-1", + result: { ucp: { status: "success", version: EMBED_PROTOCOL_VERSION } }, + }, new URL(checkout.src).origin, ); expect(onReadySpy).not.toHaveBeenCalled(); @@ -764,7 +765,7 @@ describe("", () => { }); describe("unsupported protocol methods", () => { - it("posts method-not-found for unsupported requests", () => { + it("posts method-not-found for unsupported requests", async () => { const { checkout, mockCheckoutWindow } = openPopupCheckout(); const targetOrigin = new URL(checkout.src).origin; @@ -779,6 +780,8 @@ describe("", () => { { source: mockCheckoutWindow }, ); + await flushProtocolDispatch(); + expect(mockCheckoutWindow.postMessage).toHaveBeenCalledWith( { jsonrpc: "2.0", @@ -788,14 +791,46 @@ describe("", () => { message: "Method not found", }, }, - { targetOrigin }, + targetOrigin, ); }); - it("ignores unsupported requests with invalid request ids", () => { + it("posts method-not-found for unsupported requests with a null id", async () => { const { checkout, mockCheckoutWindow } = openPopupCheckout(); + const targetOrigin = new URL(checkout.src).origin; + + simulateRawMessageEvent( + checkout, + { + jsonrpc: "2.0", + method: "ep.cart.ready", + id: null, + params: {}, + }, + { source: mockCheckoutWindow }, + ); + + await flushProtocolDispatch(); - for (const id of [{}, null, true]) { + expect(mockCheckoutWindow.postMessage).toHaveBeenCalledWith( + { + jsonrpc: "2.0", + id: null, + error: { + code: -32601, + message: "Method not found", + }, + }, + targetOrigin, + ); + }); + + it("ignores unsupported requests with unusable request ids", () => { + const { checkout, mockCheckoutWindow } = openPopupCheckout(); + + // `{}` and `true` are not valid JSON-RPC ids, so the shared decoder + // drops them entirely. (`null` is valid — see the test above.) + for (const id of [{}, true]) { simulateRawMessageEvent( checkout, { @@ -853,7 +888,7 @@ describe("", () => { }); await listenForEvent; - expect(checkout.checkout).toBe(payload.checkout); + expect(checkout.checkout).toEqual(decodeCheckout(payload)); expect(onStartSpy).toHaveBeenCalledOnce(); }); }); @@ -870,7 +905,7 @@ describe("", () => { }); await listenForEvent; - expect(checkout.checkout).toBe(payload.checkout); + expect(checkout.checkout).toEqual(decodeCheckout(payload)); expect(onCompleteSpy).toHaveBeenCalledOnce(); }); }); @@ -887,7 +922,7 @@ describe("", () => { }); await listenForEvent; - expect(checkout.error).toStrictEqual(errorParams.error); + expect(checkout.error).toEqual(decodeError(errorParams)); expect(onErrorSpy).toHaveBeenCalledOnce(); }); @@ -908,7 +943,7 @@ describe("", () => { source: mockCheckoutWindow, }), ); - await Promise.resolve(); + await flushProtocolDispatch(); expect(checkout.error).toBeUndefined(); expect(onErrorSpy).not.toHaveBeenCalled(); @@ -926,19 +961,19 @@ describe("", () => { makeErrorParams({ severity: "unrecoverable" }), { source: mockCheckoutWindow }, ); - await Promise.resolve(); + await flushProtocolDispatch(); expect(errorOrder).toStrictEqual(["error", "close"]); }); - const NON_FATAL_SEVERITIES: ReadonlyArray = [ + const NON_FATAL_SEVERITIES: ReadonlyArray = [ "recoverable", "requires_buyer_input", "requires_buyer_review", ]; it.each(NON_FATAL_SEVERITIES)( "does not auto-close when severity is %s", - async (severity: CheckoutMessageError["severity"]) => { + async (severity: Message["severity"]) => { const { checkout, mockCheckoutWindow } = openPopupCheckout(); const closeSpy = vi.fn(); checkout.addEventListener("ec.close", closeSpy); @@ -946,7 +981,7 @@ describe("", () => { simulateProtocolMessageEvent(checkout, "ec.error", makeErrorParams({ severity }), { source: mockCheckoutWindow, }); - await Promise.resolve(); + await flushProtocolDispatch(); expect(closeSpy).not.toHaveBeenCalled(); }, @@ -965,7 +1000,7 @@ describe("", () => { }); await listenForEvent; - expect(checkout.checkout).toBe(payload.checkout); + expect(checkout.checkout).toEqual(decodeCheckout(payload)); expect(onLineItemsChangeSpy).toHaveBeenCalledOnce(); }); }); @@ -982,7 +1017,7 @@ describe("", () => { }); await listenForEvent; - expect(checkout.checkout).toBe(payload.checkout); + expect(checkout.checkout).toEqual(decodeCheckout(payload)); expect(onTotalsChangeSpy).toHaveBeenCalledOnce(); }); }); @@ -999,7 +1034,7 @@ describe("", () => { }); await listenForEvent; - expect(checkout.checkout).toBe(payload.checkout); + expect(checkout.checkout).toEqual(decodeCheckout(payload)); expect(onMessagesChangeSpy).toHaveBeenCalledOnce(); }); }); @@ -1017,7 +1052,7 @@ describe("", () => { await wait; const event = spy.mock.calls[0]![0] as CustomEvent; - expect(event.detail).toEqual({ checkout: payload.checkout }); + expect(event.detail).toEqual({ checkout: decodeCheckout(payload) }); }); it("ec.complete carries {checkout, order}", async () => { @@ -1036,7 +1071,8 @@ describe("", () => { await wait; const event = spy.mock.calls[0]![0] as CustomEvent; - expect(event.detail).toEqual({ checkout: payload.checkout, order }); + const decoded = decodeCheckout(payload); + expect(event.detail).toEqual({ checkout: decoded, order: decoded.order }); }); it("ec.error carries {error}", async () => { @@ -1051,7 +1087,7 @@ describe("", () => { await wait; const event = spy.mock.calls[0]![0] as CustomEvent; - expect(event.detail).toEqual({ error: errorParams.error }); + expect(event.detail).toEqual({ error: decodeError(errorParams) }); }); it("ec.line_items.change carries {lineItems, checkout}", async () => { @@ -1066,8 +1102,9 @@ describe("", () => { await wait; const event = spy.mock.calls[0]![0] as CustomEvent; - expect(event.detail.lineItems).toBe(payload.checkout.line_items); - expect(event.detail.checkout).toBe(payload.checkout); + const decoded = decodeCheckout(payload); + expect(event.detail.lineItems).toEqual(decoded.lineItems); + expect(event.detail.checkout).toEqual(decoded); }); it("ec.totals.change carries {totals, checkout}", async () => { @@ -1082,8 +1119,9 @@ describe("", () => { await wait; const event = spy.mock.calls[0]![0] as CustomEvent; - expect(event.detail.totals).toBe(payload.checkout.totals); - expect(event.detail.checkout).toBe(payload.checkout); + const decoded = decodeCheckout(payload); + expect(event.detail.totals).toEqual(decoded.totals); + expect(event.detail.checkout).toEqual(decoded); }); it("ec.messages.change carries {messages, checkout}", async () => { @@ -1098,8 +1136,9 @@ describe("", () => { await wait; const event = spy.mock.calls[0]![0] as CustomEvent; - expect(event.detail.messages).toEqual(payload.checkout.messages ?? []); - expect(event.detail.checkout).toBe(payload.checkout); + const decoded = decodeCheckout(payload); + expect(event.detail.messages).toEqual(decoded.messages ?? []); + expect(event.detail.checkout).toEqual(decoded); }); it("ec.close carries no detail", () => { @@ -1115,7 +1154,7 @@ describe("", () => { }); describe("ec.window.open_request", () => { - it("opens the requested url in a new tab with noopener when an id is present", () => { + it("opens the requested url in a new tab with noopener when an id is present", async () => { const { checkout, mockCheckoutWindow } = openPopupCheckout(); const windowOpenSpy = vi.spyOn(window, "open"); @@ -1125,6 +1164,7 @@ describe("", () => { { url: "https://example.com/return" }, { id: "open-1", source: mockCheckoutWindow }, ); + await flushProtocolDispatch(); expect(windowOpenSpy).toHaveBeenLastCalledWith( "https://example.com/return", @@ -1133,7 +1173,7 @@ describe("", () => { ); }); - it("posts a JSON-RPC response back to the source", () => { + it("posts a JSON-RPC response back to the source", async () => { const { checkout, mockCheckoutWindow } = openPopupCheckout(); vi.spyOn(window, "open").mockReturnValue(null); @@ -1143,10 +1183,15 @@ describe("", () => { { url: "https://example.com/return" }, { id: "open-resp", source: mockCheckoutWindow }, ); + await flushProtocolDispatch(); expect(mockCheckoutWindow.postMessage).toHaveBeenCalledWith( - { jsonrpc: "2.0", id: "open-resp", result: {} }, - { targetOrigin: new URL(checkout.src).origin }, + { + jsonrpc: "2.0", + id: "open-resp", + result: { ucp: { status: "success", version: EMBED_PROTOCOL_VERSION } }, + }, + new URL(checkout.src).origin, ); }); @@ -1169,31 +1214,40 @@ describe("", () => { expect(windowOpenSpy).toHaveBeenCalledOnce(); }); - it("posts JSON-RPC errors when params are missing or malformed", () => { + it("posts JSON-RPC errors when params are missing or malformed", async () => { const { checkout, mockCheckoutWindow } = openPopupCheckout(); const consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); simulateProtocolMessageEvent( checkout, "ec.window.open_request", - {} as CheckoutProtocolMessageMap["ec.window.open_request"], - { id: "open-missing", source: mockCheckoutWindow }, + {}, + { + id: "open-missing", + source: mockCheckoutWindow, + }, ); simulateProtocolMessageEvent( checkout, "ec.window.open_request", + { url: 42 }, { - url: 42, - } as unknown as CheckoutProtocolMessageMap["ec.window.open_request"], - { id: "open-malformed", source: mockCheckoutWindow }, + id: "open-malformed", + source: mockCheckoutWindow, + }, ); + await flushProtocolDispatch(); + const targetOrigin = new URL(checkout.src).origin; + // The shared client can't decode a request without a valid `url`, so + // the handler never runs. The host preserves the diagnostic warning, + // logging the raw message data that failed to decode. expect(consoleWarnSpy).toHaveBeenCalledWith( expect.stringContaining("ec.window.open_request received without a valid url"), - expect.objectContaining({ name: "ec.window.open_request" }), + expect.objectContaining({ method: "ec.window.open_request" }), ); expect(mockCheckoutWindow.postMessage).toHaveBeenCalledWith( { @@ -1201,10 +1255,10 @@ describe("", () => { id: "open-missing", error: { code: -32602, - message: "Invalid params: expected {url: string}", + message: "Invalid params", }, }, - { targetOrigin }, + targetOrigin, ); expect(mockCheckoutWindow.postMessage).toHaveBeenCalledWith( { @@ -1212,15 +1266,16 @@ describe("", () => { id: "open-malformed", error: { code: -32602, - message: "Invalid params: expected {url: string}", + message: "Invalid params", }, }, - { targetOrigin }, + targetOrigin, ); }); - it("posts a JSON-RPC error when the url string cannot be parsed", () => { + it("rejects the request when the url string cannot be parsed", async () => { const { checkout, mockCheckoutWindow } = openPopupCheckout(); + const consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); simulateProtocolMessageEvent( checkout, @@ -1229,22 +1284,28 @@ describe("", () => { { id: "open-bad-url", source: mockCheckoutWindow }, ); + await flushProtocolDispatch(); + + expect(consoleWarnSpy).toHaveBeenCalledWith( + expect.stringContaining("ec.window.open_request received without a valid url"), + expect.objectContaining({ url: "not a real url" }), + ); expect(mockCheckoutWindow.postMessage).toHaveBeenCalledWith( - { + expect.objectContaining({ jsonrpc: "2.0", id: "open-bad-url", - error: { - code: -32602, - message: "Invalid params: url is not a valid URL", - }, - }, - { targetOrigin: new URL(checkout.src).origin }, + result: expect.objectContaining({ + ucp: { status: "error", version: EMBED_PROTOCOL_VERSION }, + }), + }), + new URL(checkout.src).origin, ); }); - it("posts a JSON-RPC error when the url uses a non-https scheme", () => { + it("rejects the request when the url uses a non-https scheme", async () => { const { checkout, mockCheckoutWindow } = openPopupCheckout(); const windowOpenSpy = vi.spyOn(window, "open"); + const consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); simulateProtocolMessageEvent( checkout, @@ -1253,16 +1314,21 @@ describe("", () => { { id: "open-http", source: mockCheckoutWindow }, ); + await flushProtocolDispatch(); + + expect(consoleWarnSpy).toHaveBeenCalledWith( + expect.stringContaining("ec.window.open_request received without a valid url"), + expect.objectContaining({ url: "http://example.com/insecure" }), + ); expect(mockCheckoutWindow.postMessage).toHaveBeenCalledWith( - { + expect.objectContaining({ jsonrpc: "2.0", id: "open-http", - error: { - code: -32602, - message: "Invalid params: url must use https scheme", - }, - }, - { targetOrigin: new URL(checkout.src).origin }, + result: expect.objectContaining({ + ucp: { status: "error", version: EMBED_PROTOCOL_VERSION }, + }), + }), + new URL(checkout.src).origin, ); expect(windowOpenSpy).not.toHaveBeenCalledWith( "http://example.com/insecure", @@ -1283,10 +1349,10 @@ describe("", () => { source: mockCheckoutWindow, origin: "https://other.example.com", }); - await Promise.resolve(); + await flushProtocolDispatch(); expect(onStartSpy).toHaveBeenCalledOnce(); - expect(checkout.checkout).toBe(payload.checkout); + expect(checkout.checkout).toEqual(decodeCheckout(payload)); }); it("drops protocol messages when the source is not the checkout window", async () => { @@ -1302,7 +1368,7 @@ describe("", () => { // Right origin, wrong window. { source: otherWindow }, ); - await Promise.resolve(); + await flushProtocolDispatch(); expect(onStartSpy).not.toHaveBeenCalled(); expect(checkout.checkout).toBeUndefined(); @@ -1332,7 +1398,7 @@ describe("", () => { source: mockCheckoutWindow, }); window.dispatchEvent(event); - await Promise.resolve(); + await flushProtocolDispatch(); expect(onStartSpy).not.toHaveBeenCalled(); expect(checkout.checkout).toBeUndefined(); @@ -1347,7 +1413,7 @@ describe("", () => { source: mockCheckoutWindow, origin: "http://shop.example.com", }); - await Promise.resolve(); + await flushProtocolDispatch(); expect(onStartSpy).not.toHaveBeenCalled(); expect(checkout.checkout).toBeUndefined(); @@ -1362,7 +1428,7 @@ describe("", () => { source: mockCheckoutWindow, origin: "null", }); - await Promise.resolve(); + await flushProtocolDispatch(); expect(onStartSpy).not.toHaveBeenCalled(); expect(checkout.checkout).toBeUndefined(); @@ -1381,7 +1447,7 @@ describe("", () => { origin: new URL(checkout.src).origin, }), ); - await Promise.resolve(); + await flushProtocolDispatch(); expect(onStartSpy).not.toHaveBeenCalled(); expect(debugWarnSpy).not.toHaveBeenCalled(); @@ -1460,7 +1526,7 @@ describe("", () => { source: mockCheckoutWindow, origin: "http://shop.example.com", }); - await Promise.resolve(); + await flushProtocolDispatch(); expect(consoleWarnSpy).toHaveBeenCalledWith( expect.stringContaining("Dropped message from non-HTTPS origin"), @@ -1475,7 +1541,7 @@ describe("", () => { source: mockCheckoutWindow, origin: "http://shop.example.com", }); - await Promise.resolve(); + await flushProtocolDispatch(); expect(consoleWarnSpy).not.toHaveBeenCalled(); }); @@ -1524,7 +1590,7 @@ describe("", () => { simulateProtocolMessageEvent(checkout, "ec.start", makeCheckoutPayload(), { source: mockCheckoutWindow, }); - await Promise.resolve(); + await flushProtocolDispatch(); expect(onStartSpy).not.toHaveBeenCalled(); }); @@ -1537,7 +1603,7 @@ describe("", () => { simulateProtocolMessageEvent(checkout, "ec.start", makeCheckoutPayload(), { source: mockCheckoutWindow, }); - await Promise.resolve(); + await flushProtocolDispatch(); expect(onStartSpy).toHaveBeenCalledOnce(); const newParent = document.createElement("div"); @@ -1548,7 +1614,7 @@ describe("", () => { simulateProtocolMessageEvent(checkout, "ec.start", makeCheckoutPayload(), { source: mockCheckoutWindow, }); - await Promise.resolve(); + await flushProtocolDispatch(); expect(onStartSpy).toHaveBeenCalledTimes(2); }); @@ -1566,23 +1632,23 @@ describe("", () => { simulateProtocolMessageEvent(first.checkout, "ec.start", firstPayload, { source: first.mockCheckoutWindow, }); - await Promise.resolve(); + await flushProtocolDispatch(); expect(firstSpy).toHaveBeenCalledOnce(); expect(secondSpy).not.toHaveBeenCalled(); - expect(first.checkout.checkout).toBe(firstPayload.checkout); + expect(first.checkout.checkout).toEqual(decodeCheckout(firstPayload)); expect(second.checkout.checkout).toBeUndefined(); const secondPayload = makeCheckoutPayload(); simulateProtocolMessageEvent(second.checkout, "ec.start", secondPayload, { source: second.mockCheckoutWindow, }); - await Promise.resolve(); + await flushProtocolDispatch(); expect(firstSpy).toHaveBeenCalledOnce(); expect(secondSpy).toHaveBeenCalledOnce(); - expect(first.checkout.checkout).toBe(firstPayload.checkout); - expect(second.checkout.checkout).toBe(secondPayload.checkout); + expect(first.checkout.checkout).toEqual(decodeCheckout(firstPayload)); + expect(second.checkout.checkout).toEqual(decodeCheckout(secondPayload)); }); it("aborts the prior protocol listener controller when reattached to the DOM", () => { @@ -1608,10 +1674,10 @@ describe("", () => { * - `origin`: the origin of `checkout.src`. Override `origin` to test * that messages from non-HTTPS origins are dropped. */ -function simulateProtocolMessageEvent( +function simulateProtocolMessageEvent( checkout: ShopifyCheckout, - name: Message, - params: CheckoutProtocolMessageMap[Message], + name: keyof CheckoutProtocolMessageMap, + params: unknown, options?: { id?: string; source?: MessageEventSource | null; @@ -1642,6 +1708,10 @@ function simulateProtocolMessageEvent { + return new Promise((resolve) => setTimeout(resolve, 0)); +} + function simulateRawMessageEvent( checkout: ShopifyCheckout, data: unknown, @@ -1753,29 +1823,46 @@ function openPopupCheckout(): { return { checkout, mockCheckoutWindow }; } -function makeCheckoutPayload(overrides: Partial = {}): { - checkout: Checkout; +/** + * Decodes a wire (`snake_case`) `{checkout}` fixture the same way the shared + * client does, yielding the `camelCase` `Checkout` the component exposes. Use + * this for assertions since decoding produces a fresh object (no reference + * equality with the fixture). + */ +function decodeCheckout(payload: { checkout: unknown }) { + return EmbeddedCheckoutProtocol.Event.start.decode(payload); +} + +/** Wire → decoded `ErrorResponse`, mirroring the client's `ec.error` handling. */ +function decodeError(params: { error: unknown }) { + return EmbeddedCheckoutProtocol.Event.error.decode(params); +} + +/** + * Builds a minimal wire-format (`snake_case`) `{checkout}` payload that decodes + * cleanly through the shared client. Keys mirror the UCP JSON contract + * (`line_items`, `payment_handlers`), not the decoded `camelCase` shape. + */ +function makeCheckoutPayload(overrides: Record = {}): { + checkout: Record; } { return { checkout: { - ucp: { version: "2026-04-08" } as Checkout["ucp"], + ucp: { version: EMBED_PROTOCOL_VERSION, payment_handlers: {} }, id: "gid://shopify/Checkout/test", currency: "USD", line_items: [], totals: [], - payment: {} as Checkout["payment"], status: "incomplete", links: [], ...overrides, - } as Checkout, + }, }; } -function makeErrorPayload(overrides?: { - severity?: CheckoutMessageError["severity"]; -}): UcpErrorResponse { +function makeErrorPayload(overrides?: { severity?: Message["severity"] }): ErrorResponse { return { - ucp: { version: "2026-04-08", status: "error" }, + ucp: { version: EMBED_PROTOCOL_VERSION, status: "error" }, messages: [ { type: "error", @@ -1788,7 +1875,7 @@ function makeErrorPayload(overrides?: { } function makeErrorParams(overrides?: { - severity?: CheckoutMessageError["severity"]; + severity?: Message["severity"]; }): CheckoutProtocolMessageMap["ec.error"] { return { error: makeErrorPayload(overrides) }; } diff --git a/platforms/web/src/checkout.ts b/platforms/web/src/checkout.ts index e8a02f99c..727837112 100644 --- a/platforms/web/src/checkout.ts +++ b/platforms/web/src/checkout.ts @@ -1,26 +1,34 @@ +import { + EmbeddedCheckoutProtocol, + decodeProtocolMessage, + windowOpenSuccess, + windowOpenRejected, + type WindowOpenRequest, + type WindowOpenResult, +} from "@shopify/checkout-kit-protocol"; + import { createTemplate, html } from "./utils"; import type { CheckoutAttributes, CheckoutMethods, CheckoutProperties, - CheckoutProtocolMessageMap, CheckoutTarget, TypedEventListener, - CheckoutProtocolMessageData, Checkout, - CheckoutLineItem, - CheckoutMessage, - Total, + LineItem, + Message, + CheckoutTotal, OrderConfirmation, - UcpErrorResponse, + ErrorResponse, } from "./checkout.types"; import { STYLES } from "./checkout.styles"; export const DEFAULT_POPUP_WIDTH = 600; export const DEFAULT_POPUP_HEIGHT = 600; -export const EMBED_PROTOCOL_VERSION = "2026-04-08"; export const CK_VERSION = "4.0.0"; -const EMBED_DELEGATIONS: readonly string[] = ["window.open"]; + +const WINDOW_OPEN_INVALID_URL_WARNING = + ": ec.window.open_request received without a valid url"; const SHADOW_TEMPLATE = createTemplate(html`
@@ -92,7 +100,7 @@ export class ShopifyCheckout } #checkout?: Checkout; - #error?: UcpErrorResponse; + #error?: ErrorResponse; #checkoutWindow: WindowProxy | null = null; @@ -100,6 +108,8 @@ export class ShopifyCheckout #currentOpen: { controller: AbortController } | null = null; // Manages the global message event listener for checkout protocol communication #checkoutProtocolController: { controller: AbortController } | null = null; + // Shared protocol client that decodes messages and dispatches to handlers + #client!: EmbeddedCheckoutProtocol.Client; /* ------------------------------------------------------------ * Read/write properties (reflected with attributes) @@ -130,16 +140,12 @@ export class ShopifyCheckout } if (url.protocol !== "https:") return undefined; - // Drop ec_auth if present on src (e.g. prepared checkout URLs); this build - // does not support passing auth via query string. - url.searchParams.delete("ec_auth"); - - url.searchParams.set("ec_version", EMBED_PROTOCOL_VERSION); - if (EMBED_DELEGATIONS.length > 0) { - url.searchParams.set("ec_delegate", EMBED_DELEGATIONS.join(",")); - } - url.searchParams.set("ck_version", CK_VERSION); - return url; + const negotiatedUrl = EmbeddedCheckoutProtocol.url(url.toString(), { + delegations: [EmbeddedCheckoutProtocol.Delegations.windowOpen], + }); + const finalUrl = new URL(negotiatedUrl); + finalUrl.searchParams.set("ck_version", CK_VERSION); + return finalUrl; } /** @@ -213,7 +219,7 @@ export class ShopifyCheckout * console.error(messages[0]?.code, messages[0]?.content); * }); */ - get error(): UcpErrorResponse | undefined { + get error(): ErrorResponse | undefined { return this.#error; } @@ -469,15 +475,6 @@ export class ShopifyCheckout * ------------------------------------------------------------ */ - /** - * JSON-RPC request messages carry an `id`; notifications do not. - */ - #isRespondableRequest( - message: CheckoutProtocolMessage, - ): message is CheckoutProtocolMessage & { id: string } { - return message.id != null; - } - #validateMessageOrigin(event: MessageEvent) { if (!this.#srcAsURL()) { throw new Error("Dropped message because src is invalid or unset"); @@ -502,6 +499,7 @@ export class ShopifyCheckout // during DOM moves, leading to potentially accumulated event listeners. this.#checkoutProtocolController?.controller.abort(); + this.#client = this.#buildProtocolClient(); this.#checkoutProtocolController = { controller: new AbortController() }; window.addEventListener("message", this.#handleMessage, { signal: this.#checkoutProtocolController.controller.signal, @@ -521,43 +519,37 @@ export class ShopifyCheckout return; } - const message = CheckoutProtocolMessage.parse(event); - if (!message) { - respondToUnsupportedProtocolRequest(event); - return; - } - - // @see https://ucp.dev/2026-04-08/specification/embedded-checkout/ - - // Every notification that carries a checkout payload updates the cached value. - if (message.body != null && typeof message.body === "object" && "checkout" in message.body) { - this.#checkout = (message.body as { checkout: Checkout }).checkout; - } + void this.#dispatchProtocolMessage(JSON.stringify(event.data), event); + }; - switch (message.name) { - case "ec.ready": { - if (this.#isRespondableRequest(message) && message.source) { - (message.source as WindowProxy).postMessage( - { jsonrpc: "2.0" as const, id: message.id, result: {} }, - message.origin, - ); - } - break; - } - case "ec.start": { - const checkout = (message.body as CheckoutProtocolMessageMap["ec.start"]).checkout; + /** + * Builds the shared protocol client with a handler per embedded checkout + * notification/request. Handlers receive already-decoded payloads and map + * them onto the component's cached state and DOM events. + * + * @see https://ucp.dev/2026-04-08/specification/embedded-checkout/ + */ + #buildProtocolClient(): EmbeddedCheckoutProtocol.Client { + const { Event } = EmbeddedCheckoutProtocol; + + return new EmbeddedCheckoutProtocol.Client() + .on(Event.ready, () => ({ + ucp: { + status: "success", + version: EmbeddedCheckoutProtocol.specVersion, + }, + })) + .on(Event.start, (checkout) => { + this.#checkout = checkout; this.dispatchEvent(new ShopifyCheckoutStartEvent({ checkout })); - break; - } - case "ec.complete": { - const checkout = (message.body as CheckoutProtocolMessageMap["ec.complete"]).checkout; - // `order` is populated on `ec.complete` per ECP spec; fall back defensively. + }) + .on(Event.complete, (checkout) => { + this.#checkout = checkout; + // `order` is populated on `ec.complete` per ECP spec. const order = checkout.order as OrderConfirmation; this.dispatchEvent(new ShopifyCheckoutCompleteEvent({ checkout, order })); - break; - } - case "ec.error": { - const { error } = message.body as CheckoutProtocolMessageMap["ec.error"]; + }) + .on(Event.error, (error) => { this.#error = error; this.dispatchEvent(new ShopifyCheckoutErrorEvent({ error })); // Per UCP spec, `unrecoverable` means no valid resource exists to act on — @@ -565,112 +557,89 @@ export class ShopifyCheckout if (error.messages.some((m) => m.severity === "unrecoverable")) { this.close(); } - break; - } - case "ec.line_items.change": { - const checkout = (message.body as CheckoutProtocolMessageMap["ec.line_items.change"]) - .checkout; + }) + .on(Event.lineItemsChange, (checkout) => { + this.#checkout = checkout; this.dispatchEvent( new ShopifyCheckoutLineItemsChangeEvent({ checkout, - lineItems: checkout.line_items, + lineItems: checkout.lineItems, }), ); - break; - } - case "ec.totals.change": { - const checkout = (message.body as CheckoutProtocolMessageMap["ec.totals.change"]).checkout; + }) + .on(Event.totalsChange, (checkout) => { + this.#checkout = checkout; this.dispatchEvent( new ShopifyCheckoutTotalsChangeEvent({ checkout, totals: checkout.totals, }), ); - break; - } - case "ec.messages.change": { - const checkout = (message.body as CheckoutProtocolMessageMap["ec.messages.change"]) - .checkout; + }) + .on(Event.messagesChange, (checkout) => { + this.#checkout = checkout; this.dispatchEvent( new ShopifyCheckoutMessagesChangeEvent({ checkout, messages: checkout.messages ?? [], }), ); - break; - } - case "ec.window.open_request": { - if (!this.#isRespondableRequest(message)) break; - const body = message.body as - | CheckoutProtocolMessageMap["ec.window.open_request"] - | undefined; - if (!body || typeof body.url !== "string") { - // eslint-disable-next-line no-console - console.warn( - ": ec.window.open_request received without a valid url", - message, - ); - message.source?.postMessage( - { - jsonrpc: "2.0" as const, - id: message.id, - error: { - code: -32602, - message: "Invalid params: expected {url: string}", - }, - }, - { targetOrigin: message.origin }, - ); - break; - } - let targetUrl: URL; - try { - targetUrl = new URL(body.url); - } catch { - message.source?.postMessage( - { - jsonrpc: "2.0" as const, - id: message.id, - error: { - code: -32602, - message: "Invalid params: url is not a valid URL", - }, - }, - { targetOrigin: message.origin }, - ); - break; - } - if (targetUrl.protocol !== "https:") { - message.source?.postMessage( - { - jsonrpc: "2.0" as const, - id: message.id, - error: { - code: -32602, - message: "Invalid params: url must use https scheme", - }, - }, - { targetOrigin: message.origin }, - ); - break; - } - window.open(targetUrl.href, "_blank", "noopener"); - message.source?.postMessage( - { jsonrpc: "2.0" as const, id: message.id, result: {} }, - { targetOrigin: message.origin }, - ); - break; - } - default: { - // eslint-disable-next-line no-console - console.warn( - `: Unknown checkout protocol message received: ${message.name}`, - message, - ); - break; - } + }) + .on(Event.windowOpen, (request) => this.#handleWindowOpen(request)); + } + + /** + * Feeds a serialized JSON-RPC message through the protocol client and posts + * any response back to the checkout window. Responses only exist for + * requests (`ec.ready`, `ec.window.open_request`, unknown methods); + * notifications resolve to `undefined` and post nothing. + */ + async #dispatchProtocolMessage(serialized: string, event: MessageEvent): Promise { + const response = await this.#client.process(serialized); + if (response === undefined) return; + + const parsed = JSON.parse(response) as Record; + + // The client returns -32602 for a window.open request whose url is missing + // or not a string (the handler never runs). Preserve the host-side warning. + if ( + parsed.error !== undefined && + decodeProtocolMessage(serialized)?.method === EmbeddedCheckoutProtocol.Event.windowOpen.method + ) { + // eslint-disable-next-line no-console + console.warn(WINDOW_OPEN_INVALID_URL_WARNING, event.data); + } + + const { source } = event; + if (source) { + (source as WindowProxy).postMessage(parsed, event.origin); + } + } + + /** + * Handles an `ec.window.open_request` delegation: opens a validated `https:` + * URL in a new tab and returns a UCP result. Invalid or non-`https:` URLs + * are rejected (and warned about) rather than opened. + */ + #handleWindowOpen(request: WindowOpenRequest): WindowOpenResult { + let targetUrl: URL; + try { + targetUrl = new URL(request.url); + } catch { + // eslint-disable-next-line no-console + console.warn(WINDOW_OPEN_INVALID_URL_WARNING, request); + return windowOpenRejected("url is not a valid URL"); } - }; + + if (targetUrl.protocol !== "https:") { + // eslint-disable-next-line no-console + console.warn(WINDOW_OPEN_INVALID_URL_WARNING, request); + return windowOpenRejected("url must use https scheme"); + } + + window.open(targetUrl.href, "_blank", "noopener"); + return windowOpenSuccess(); + } /* ------------------------------------------------------------ * Lifecycle @@ -790,26 +759,26 @@ export interface ShopifyCheckoutCompleteEventDetail { export interface ShopifyCheckoutErrorEventDetail { /** Error payload from the ECP `ec.error` notification. */ - error: UcpErrorResponse; + error: ErrorResponse; } export interface ShopifyCheckoutLineItemsChangeEventDetail { /** Updated cart line items. */ - lineItems: readonly CheckoutLineItem[]; + lineItems: readonly LineItem[]; /** Full checkout snapshot for handlers that want broader context. */ checkout: Checkout; } export interface ShopifyCheckoutTotalsChangeEventDetail { /** Updated totals. */ - totals: readonly Total[]; + totals: readonly CheckoutTotal[]; /** Full checkout snapshot for handlers that want broader context. */ checkout: Checkout; } export interface ShopifyCheckoutMessagesChangeEventDetail { /** Updated checkout-level messages (warnings, errors, info). */ - messages: readonly CheckoutMessage[]; + messages: readonly Message[]; /** Full checkout snapshot for handlers that want broader context. */ checkout: Checkout; } @@ -874,109 +843,3 @@ export class ShopifyCheckoutMessagesChangeEvent extends CustomEvent { - static parse(event: MessageEvent): CheckoutProtocolMessage | undefined { - const { data, source, origin } = event; - if (!isCheckoutProtocolMessage(data)) return; - if (data.method === "ec.error" && !isEcErrorParams(data.params)) return; - return new CheckoutProtocolMessage(data, { source, origin }); - } - - readonly protocol: { readonly version: string }; - readonly name: MessageType; - readonly body: CheckoutProtocolMessageMap[MessageType]; - /** The JSON-RPC message ID (undefined for notifications) */ - readonly id?: string; - /** The source window to post responses to */ - readonly source: MessageEventSource | null; - /** The origin to use when posting responses */ - readonly origin: string; - - constructor( - { method, params, id }: CheckoutProtocolMessageData & { id?: string }, - { source, origin }: { source: MessageEventSource | null; origin: string }, - ) { - this.protocol = { version: "2026-04-08" }; - this.name = method; - this.body = params as CheckoutProtocolMessageMap[MessageType]; - this.id = id; - this.source = source; - this.origin = origin; - } -} - -function isEcErrorParams(params: unknown): params is CheckoutProtocolMessageMap["ec.error"] { - return params != null && typeof params === "object" && "error" in params; -} - -function respondToUnsupportedProtocolRequest(event: MessageEvent) { - const request = parseUnsupportedProtocolRequest(event.data); - if (!request) return; - - event.source?.postMessage( - { - jsonrpc: "2.0" as const, - id: request.id, - error: METHOD_NOT_FOUND_ERROR, - }, - { targetOrigin: event.origin }, - ); -} - -function parseUnsupportedProtocolRequest(data: unknown): { id: string | number } | undefined { - if ( - data == null || - typeof data !== "object" || - !("jsonrpc" in data) || - data.jsonrpc !== "2.0" || - !("method" in data) || - typeof data.method !== "string" || - CHECKOUT_PROTOCOL_MESSAGES.includes(data.method as keyof CheckoutProtocolMessageMap) || - !("id" in data) || - !isJsonRpcRequestId(data.id) - ) { - return; - } - - return { id: data.id }; -} - -function isJsonRpcRequestId(id: unknown): id is string | number { - if (typeof id === "string") return true; - if (typeof id === "number" && Number.isFinite(id)) return true; - return false; -} - -function isCheckoutProtocolMessage(data: unknown): data is CheckoutProtocolMessageData { - return ( - data != null && - typeof data === "object" && - "jsonrpc" in data && - data.jsonrpc === "2.0" && - "method" in data && - CHECKOUT_PROTOCOL_MESSAGES.includes(data.method as keyof CheckoutProtocolMessageMap) - ); -} diff --git a/platforms/web/src/checkout.types.ts b/platforms/web/src/checkout.types.ts index 39b42c763..587ba545a 100644 --- a/platforms/web/src/checkout.types.ts +++ b/platforms/web/src/checkout.types.ts @@ -1,15 +1,8 @@ // Types for this component are derived from the 2026-04-08 UCP embedded -// checkout protocol. Embed payload shapes live in `./ucp-embed-types.ts`. +// checkout protocol. Payload shapes come from the shared +// `@shopify/checkout-kit-protocol` package (decoded to camelCase). -import type { - Checkout, - CheckoutLineItem, - CheckoutMessage, - EcReadyParams, - OrderConfirmation, - Total, - UcpErrorResponse, -} from "./ucp-embed-types"; +import type { Checkout, ReadyRequest, ErrorResponse } from "@shopify/checkout-kit-protocol"; // This component should follow the custom element conventions set out here: // https://github.com/Shopify/ui-api-design/tree/main/codex. In particular, @@ -85,144 +78,17 @@ export interface CheckoutProperties { debug?: boolean | string; } -// Docs-friendly event interfaces. Each carries the same `detail` shape as the -// corresponding `CustomEvent` subclass exported from `./checkout.ts`. They -// exist so the generated API docs show what's on `event.detail` directly, -// without dragging in the full `CustomEvent`/`Event` documentation. -export interface CheckoutEvents { - /** - * Dispatched when checkout has started. - */ - "ec.start": CheckoutStartEvent; - - /** - * Dispatched when the checkout was successfully completed. - */ - "ec.complete": CheckoutCompleteEvent; - - /** - * Dispatched when the checkout overlay is closed, either due to user action or - * from calling the `close()` method. Synthetic — not part of the ECP wire protocol. - */ - "ec.close": CheckoutCloseEvent; - - /** - * Dispatched on a session-level fatal error. The host should tear down the - * embedded context. - */ - "ec.error": CheckoutErrorEvent; - - /** - * Dispatched when the cart line items change. - */ - "ec.line_items.change": CheckoutLineItemsChangeEvent; - - /** - * Dispatched when the totals change. - */ - "ec.totals.change": CheckoutTotalsChangeEvent; - - /** - * Dispatched when checkout messages (warnings, errors, info) change. - */ - "ec.messages.change": CheckoutMessagesChangeEvent; -} - -export interface CheckoutStartEvent { - type: "ec.start"; - detail: { - /** Initial checkout snapshot. */ - checkout: Checkout; - }; -} - -export interface CheckoutCompleteEvent { - type: "ec.complete"; - detail: { - /** Final checkout snapshot. */ - checkout: Checkout; - /** Order confirmation. */ - order: OrderConfirmation; - }; -} - -export interface CheckoutCloseEvent { - type: "ec.close"; - detail: undefined; -} - -export interface CheckoutErrorEvent { - type: "ec.error"; - detail: { - /** Error payload from the ECP `ec.error` notification. */ - error: UcpErrorResponse; - }; -} - -export interface CheckoutLineItemsChangeEvent { - type: "ec.line_items.change"; - detail: { - /** Updated cart line items. */ - lineItems: readonly CheckoutLineItem[]; - /** Full checkout snapshot for handlers that want broader context. */ - checkout: Checkout; - }; -} - -export interface CheckoutTotalsChangeEvent { - type: "ec.totals.change"; - detail: { - /** Updated totals. */ - totals: readonly Total[]; - /** Full checkout snapshot for handlers that want broader context. */ - checkout: Checkout; - }; -} - -export interface CheckoutMessagesChangeEvent { - type: "ec.messages.change"; - detail: { - /** Updated checkout-level messages (warnings, errors, info). */ - messages: readonly CheckoutMessage[]; - /** Full checkout snapshot for handlers that want broader context. */ - checkout: Checkout; - }; -} - export type TypedEventListener = | ((event: Event) => void) | { handleEvent(event: Event): void; }; -export type CheckoutElement = CheckoutMethods & CheckoutProperties & CheckoutEvents; - /* ------------------------------------------------------------ * Checkout Protocol * ------------------------------------------------------------ */ -/** - * A checkout protocol message as it is communicated via postMessage (JSON-RPC 2.0 format) - */ -export interface CheckoutProtocolMessageData< - T extends keyof CheckoutProtocolMessageMap = keyof CheckoutProtocolMessageMap, -> { - jsonrpc: "2.0"; - method: T; - params?: CheckoutProtocolMessageMap[T]; -} - -/** Common payload shape for messages that carry the full Checkout object. */ -interface CheckoutPayload { - checkout: Checkout; -} - -/** `ec.error` wraps the generated error response in the JSON-RPC `params.error` field. */ -export interface EcErrorParams { - error: UcpErrorResponse; -} - /** * Mapping of the 2026-04-08 ECP messages this component handles to their * wire-format payloads. Delegation methods (fulfillment.address_change_request, @@ -231,23 +97,23 @@ export interface EcErrorParams { * does not implement payment delegations. */ export interface CheckoutProtocolMessageMap { - "ec.ready": EcReadyParams; - "ec.start": CheckoutPayload; - "ec.complete": CheckoutPayload; - "ec.error": EcErrorParams; - "ec.line_items.change": CheckoutPayload; - "ec.totals.change": CheckoutPayload; - "ec.messages.change": CheckoutPayload; + "ec.ready": ReadyRequest; + "ec.start": { checkout: Checkout }; + "ec.complete": { checkout: Checkout }; + "ec.error": { error: ErrorResponse }; + "ec.line_items.change": { checkout: Checkout }; + "ec.totals.change": { checkout: Checkout }; + "ec.messages.change": { checkout: Checkout }; "ec.window.open_request": { url: string }; } export type { Buyer, Checkout, - CheckoutLineItem, - CheckoutMessage, - EcReadyParams, + LineItem, + Message, + ReadyRequest, OrderConfirmation, - Total, - UcpErrorResponse, -} from "./ucp-embed-types"; + CheckoutTotal, + ErrorResponse, +} from "@shopify/checkout-kit-protocol"; diff --git a/platforms/web/src/index.ts b/platforms/web/src/index.ts index 88db6e15a..51c62ceec 100644 --- a/platforms/web/src/index.ts +++ b/platforms/web/src/index.ts @@ -33,9 +33,9 @@ export type { CheckoutTarget } from "./checkout.types"; export type { Buyer, Checkout, - CheckoutLineItem, - CheckoutMessage, + LineItem, + Message, OrderConfirmation, - Total, - UcpErrorResponse, + CheckoutTotal, + ErrorResponse, } from "./checkout.types"; diff --git a/platforms/web/src/ucp-embed-types.ts b/platforms/web/src/ucp-embed-types.ts deleted file mode 100644 index 65d93ae65..000000000 --- a/platforms/web/src/ucp-embed-types.ts +++ /dev/null @@ -1,350 +0,0 @@ -/** - * UCP embedded-checkout wire payloads (`ec.*`, `checkout` resource, `ec.error`). - * Consolidated from checkout-web embed mapper / proposal-style shapes (2026-04-08). - */ - -// --- shared.ts (subset used by 2026-04-08 Checkout + EcReadyParams + messages) --- - -export type CheckoutStatus = - | "incomplete" - | "requires_escalation" - | "ready_for_complete" - | "complete_in_progress" - | "completed" - | "canceled"; - -export type TotalType = - | "items_discount" - | "subtotal" - | "discount" - | "fulfillment" - | "tax" - | "fee" - | "total"; - -export interface Total { - readonly type: TotalType; - readonly display_text?: string; - readonly amount: number; -} - -export interface CheckoutLineItemItem { - readonly id: string; - readonly title: string; - readonly price: number; - readonly image_url?: string; - readonly [key: string]: unknown; -} - -export interface CheckoutLineItem { - readonly id: string; - readonly item: CheckoutLineItemItem; - readonly quantity: number; - readonly totals: readonly Total[]; - readonly parent_id?: string; - readonly [key: string]: unknown; -} - -export type MessageContentType = "plain" | "markdown"; - -export type CheckoutMessageSeverity = - | "recoverable" - | "requires_buyer_input" - | "requires_buyer_review" - | "unrecoverable"; - -export interface CheckoutMessageError { - readonly type: "error"; - readonly code: string; - readonly path?: string; - readonly content: string; - readonly content_type?: MessageContentType; - readonly severity: CheckoutMessageSeverity; - readonly [key: string]: unknown; -} - -export interface CheckoutMessageWarning { - readonly type: "warning"; - readonly code: string; - readonly path?: string; - readonly content: string; - readonly content_type?: MessageContentType; - readonly [key: string]: unknown; -} - -export interface CheckoutMessageInfo { - readonly type: "info"; - readonly path?: string; - readonly code?: string; - readonly content: string; - readonly content_type?: MessageContentType; - readonly [key: string]: unknown; -} - -/** Resource-level messages on checkout (not the same as session `ec.error`). */ -export type CheckoutMessage = CheckoutMessageError | CheckoutMessageWarning | CheckoutMessageInfo; - -export type CheckoutLinkType = - | "privacy_policy" - | "terms_of_service" - | "refund_policy" - | "shipping_policy" - | "faq" - | (string & {}); - -export interface CheckoutLink { - readonly type: CheckoutLinkType; - readonly url: string; - readonly title?: string; - readonly [key: string]: unknown; -} - -export interface DiscountAllocation { - readonly path: string; - readonly amount: number; -} - -export interface AppliedDiscount { - readonly title: string; - readonly amount: number; - readonly code?: string; - readonly automatic?: boolean; - readonly method?: "each" | "across"; - readonly priority?: number; - readonly provisional?: boolean; - readonly eligibility?: string; - readonly allocations?: readonly DiscountAllocation[]; -} - -export interface CheckoutDiscounts { - readonly codes?: readonly string[]; - readonly applied?: readonly AppliedDiscount[]; -} - -export type FulfillmentMethodType = "shipping" | "pickup"; - -export interface FulfillmentOption { - readonly id: string; - readonly title: string; - readonly description?: string; - readonly carrier?: string; - readonly earliest_fulfillment_time?: string; - readonly latest_fulfillment_time?: string; - readonly totals: readonly Total[]; - readonly [key: string]: unknown; -} - -export interface FulfillmentGroup { - readonly id: string; - readonly line_item_ids: readonly string[]; - readonly options?: readonly FulfillmentOption[]; - readonly selected_option_id?: string; - readonly [key: string]: unknown; -} - -export interface FulfillmentAvailableMethod { - readonly type: FulfillmentMethodType; - readonly line_item_ids: readonly string[]; - readonly fulfillable_on: string | null; - readonly description?: string; - readonly [key: string]: unknown; -} - -/** `ec.ready` request params (handshake). */ -export interface EcReadyParams { - readonly delegate: readonly string[]; - readonly [key: string]: unknown; -} - -/** Populated on completed checkout (`checkout.order`). */ -export interface OrderConfirmation { - readonly id: string; - readonly permalink_url: string; - readonly [key: string]: unknown; -} - -// --- embed.ts (UcpPaymentRedemption only — rest is from mapper files) --- - -export interface UcpPaymentRedemption { - readonly source: string; - readonly amount: number; - readonly [key: string]: unknown; -} - -// --- proposal/types.ts (minimal payment + address for Payment + destinations) --- - -export interface UcpPostalAddress { - readonly extended_address?: string; - readonly street_address?: string; - readonly address_locality?: string; - readonly address_region?: string; - readonly address_country?: string; - readonly postal_code?: string; - readonly first_name?: string; - readonly last_name?: string; - readonly phone_number?: string; - readonly [key: string]: unknown; -} - -export interface UcpPaymentInstrumentDisplay { - readonly brand?: string; - readonly last_digits?: string; - readonly description?: string; - readonly card_art?: string; - readonly [key: string]: unknown; -} - -export type PaymentInstrument = UcpPaymentInstrument; - -export interface UcpPaymentInstrument { - readonly id: string; - readonly handler_id: string; - readonly type: string; - readonly selected?: boolean; - readonly display?: UcpPaymentInstrumentDisplay; - readonly cardholder_name?: string; - readonly expiry_month?: number; - readonly expiry_year?: number; - readonly credential?: Readonly>; - readonly billing_address?: UcpPostalAddress; - readonly [key: string]: unknown; -} - -// --- shared.ts: UcpMetadata base + service/capability (for checkout.ucp) --- - -export interface UcpPaymentHandler { - readonly id: string; - readonly name: string; - readonly version: string; - readonly spec?: string; - readonly schema?: string; - readonly config?: Readonly>; - readonly [key: string]: unknown; -} - -export interface UcpService { - readonly version: string; - readonly transport: "embedded"; - readonly schema?: string; - readonly endpoint?: string; - readonly config?: Readonly>; - readonly [key: string]: unknown; -} - -export interface UcpCapability { - readonly version: string; - readonly extends?: string | readonly string[]; - readonly spec?: string; - readonly schema?: string; - readonly config?: Readonly>; - readonly [key: string]: unknown; -} - -/** Base metadata (2026-01-23 legacy + 2026-04-08 services). */ -export interface UcpMetadataBase { - readonly version: string; - readonly transports?: { - readonly embedded: { - readonly schema: string; - readonly delegations: readonly string[]; - }; - }; - readonly services?: Readonly>; - readonly payment_handlers?: Readonly>; - readonly [key: string]: unknown; -} - -/** 2026-04-08: adds required `capabilities` registry. */ -export interface UcpMetadata extends UcpMetadataBase { - readonly capabilities: Readonly>; -} - -// --- 2026-04-08.ts (Checkout, ShopCash, UcpErrorResponse) --- - -export interface Buyer { - readonly email?: string; - readonly phone_number?: string; - readonly first_name?: string; - readonly last_name?: string; - readonly [key: string]: unknown; -} - -export interface Payment { - readonly instruments?: readonly PaymentInstrument[]; - readonly [key: string]: unknown; -} - -export interface ShippingDestination extends UcpPostalAddress { - readonly id: string; -} - -export interface RetailLocation { - readonly id: string; - readonly name: string; - readonly address?: UcpPostalAddress; - readonly [key: string]: unknown; -} - -export type FulfillmentDestination = ShippingDestination | RetailLocation; - -export interface FulfillmentMethod { - readonly id: string; - readonly type: FulfillmentMethodType; - readonly line_item_ids: readonly string[]; - readonly selected_destination_id?: string; - readonly destinations?: readonly FulfillmentDestination[]; - readonly groups?: readonly FulfillmentGroup[]; - readonly [key: string]: unknown; -} - -export interface Fulfillment { - readonly methods: readonly FulfillmentMethod[]; - readonly available_methods?: readonly FulfillmentAvailableMethod[]; - readonly [key: string]: unknown; -} - -/** Wire `checkout` object in `ec.*` notifications carrying full resource. */ -export interface Checkout { - readonly ucp: UcpMetadata; - readonly id: string; - readonly currency: string; - readonly line_items: readonly CheckoutLineItem[]; - readonly totals: readonly Total[]; - readonly buyer?: Buyer; - readonly payment?: Payment; - readonly status: CheckoutStatus; - readonly messages?: readonly CheckoutMessage[]; - readonly links: readonly CheckoutLink[]; - readonly continue_url?: string; - readonly expires_at?: string; - readonly fulfillment?: Fulfillment; - readonly order?: OrderConfirmation; - readonly redemptions?: readonly UcpPaymentRedemption[]; - readonly discounts?: CheckoutDiscounts; - readonly [key: string]: unknown; -} - -export interface ShopCash { - readonly balance: { - readonly status: "unavailable" | "pending" | "filled"; - readonly available_balance?: number; - }; - readonly expected_earnings?: number; -} - -export interface UcpEnvelope { - readonly version: string; - readonly status: "success" | "error"; - readonly [key: string]: unknown; -} - -export type UcpSuccessEnvelope = UcpEnvelope & { readonly status: "success" }; -export type UcpErrorEnvelope = UcpEnvelope & { readonly status: "error" }; - -/** Session-level fatal `ec.error` params / delegation error branch. */ -export interface UcpErrorResponse { - readonly ucp: UcpErrorEnvelope; - readonly messages: readonly CheckoutMessageError[]; - readonly continue_url?: string; - readonly [key: string]: unknown; -} diff --git a/platforms/web/vite.config.ts b/platforms/web/vite.config.ts index 1ca1a12d7..d8ae9f32e 100644 --- a/platforms/web/vite.config.ts +++ b/platforms/web/vite.config.ts @@ -22,7 +22,7 @@ export default defineConfig({ build: { target: 'es2022', sourcemap: true, - minify: false, + minify: true, emptyOutDir: true, outDir: fromRoot('dist'), lib: { diff --git a/protocol/languages/typescript/src/index.d.ts b/protocol/languages/typescript/src/index.d.ts index 401f52c38..5ea36daf2 100644 --- a/protocol/languages/typescript/src/index.d.ts +++ b/protocol/languages/typescript/src/index.d.ts @@ -4,4 +4,5 @@ export * from './protocol'; export { decodeProtocolMessage, encodeJSONRPCError, encodeJSONRPCResult, PARSE_ERROR_CODE, PARSE_ERROR_MESSAGE, INVALID_PARAMS_CODE, INVALID_PARAMS_MESSAGE, METHOD_NOT_FOUND_CODE, METHOD_NOT_FOUND_MESSAGE, INTERNAL_ERROR_CODE, INTERNAL_ERROR_MESSAGE, type JSONRPCID, type DecodedMessage, } from './codec'; export { checkoutProtocolCatalog, checkoutProtocolCatalogPayloadDecoders, checkoutProtocolRequestCatalog, embeddedCheckoutMethods, type CheckoutProtocolCatalogMethod, type CheckoutProtocolCatalogPayloads, type CheckoutProtocolCatalogPayloadDecoder, type CheckoutProtocolRequestMethod, type CheckoutProtocolRequestPayloads, type CheckoutProtocolRequestResults, type Delegation, } from './generated/ProtocolNotifications'; export { Client } from './client'; +export { windowOpenSuccess, windowOpenRejected } from './window_open'; export { EmbeddedCheckoutProtocol } from './embedded_checkout_protocol'; diff --git a/protocol/languages/typescript/src/index.ts b/protocol/languages/typescript/src/index.ts index 4514a196e..16b3c9773 100644 --- a/protocol/languages/typescript/src/index.ts +++ b/protocol/languages/typescript/src/index.ts @@ -30,4 +30,5 @@ export { type Delegation, } from './generated/ProtocolNotifications'; export {Client} from './client'; +export {windowOpenSuccess, windowOpenRejected} from './window_open'; export {EmbeddedCheckoutProtocol} from './embedded_checkout_protocol'; diff --git a/protocol/languages/typescript/src/window_open.d.ts b/protocol/languages/typescript/src/window_open.d.ts new file mode 100644 index 000000000..dab191cd2 --- /dev/null +++ b/protocol/languages/typescript/src/window_open.d.ts @@ -0,0 +1,3 @@ +import type { WindowOpenResult } from './generated/Models'; +export declare function windowOpenSuccess(version?: string): WindowOpenResult; +export declare function windowOpenRejected(reason?: string, version?: string): WindowOpenResult; diff --git a/protocol/languages/typescript/src/window_open.ts b/protocol/languages/typescript/src/window_open.ts new file mode 100644 index 000000000..7c1170f6d --- /dev/null +++ b/protocol/languages/typescript/src/window_open.ts @@ -0,0 +1,25 @@ +import {SPEC_VERSION} from './generated/ProtocolNotifications'; +import type {WindowOpenResult} from './generated/Models'; + +export function windowOpenSuccess( + version: string = SPEC_VERSION, +): WindowOpenResult { + return {ucp: {status: 'success', version}}; +} + +export function windowOpenRejected( + reason?: string, + version: string = SPEC_VERSION, +): WindowOpenResult { + return { + ucp: {status: 'error', version}, + messages: [ + { + code: 'window_open_rejected_error', + content: reason ?? 'Window open rejected', + severity: 'unrecoverable', + type: 'error', + }, + ], + }; +} diff --git a/protocol/scripts/window_open.test.mjs b/protocol/scripts/window_open.test.mjs new file mode 100644 index 000000000..57d3bdfe3 --- /dev/null +++ b/protocol/scripts/window_open.test.mjs @@ -0,0 +1,34 @@ +import {describe, test, expect} from 'vitest'; + +import {SPEC_VERSION} from '../languages/typescript/src/generated/ProtocolNotifications'; +import { + windowOpenSuccess, + windowOpenRejected, +} from '../languages/typescript/src/window_open'; + +describe('window.open result factories', () => { + test('success sets ucp status success and no messages', () => { + const result = windowOpenSuccess(); + expect(result.ucp.status).toBe('success'); + expect(result.ucp.version).toBe(SPEC_VERSION); + expect(result.messages).toBeUndefined(); + }); + + test('rejected sets ucp status error and one unrecoverable message', () => { + const result = windowOpenRejected('nope'); + expect(result.ucp.status).toBe('error'); + expect(result.messages).toHaveLength(1); + expect(result.messages?.[0]).toMatchObject({ + code: 'window_open_rejected_error', + content: 'nope', + severity: 'unrecoverable', + type: 'error', + }); + }); + + test('rejected falls back to default content', () => { + expect(windowOpenRejected().messages?.[0]?.content).toBe( + 'Window open rejected', + ); + }); +});