From e985bc755eb2044d180f7763669ed0466cdbefc3 Mon Sep 17 00:00:00 2001 From: Jhonatan Gonzalez Date: Wed, 7 Aug 2024 11:08:35 +1000 Subject: [PATCH] [NO CHANGELOG][Checkout Widget] Refactor post message handler (#2077) --- .../sdk/src/postMessageHandler/index.ts | 1 + .../postMessageEventTypes.ts | 15 ++ .../postMessageHandler/postMessageHandler.ts | 158 ++++++++++++------ .../ConnectLoader/ConnectLoader.tsx | 5 +- .../src/widgets/checkout/CheckoutWidget.tsx | 20 +-- .../checkout/context/CheckoutContext.ts | 24 +-- .../context/CheckoutContextProvider.tsx | 17 +- .../widgets/checkout/context/ProviderRelay.ts | 44 +++-- .../src/widgets/checkout/utils/config.ts | 4 + .../checkout/views/CheckoutAppIframe.tsx | 116 +++++++++---- .../src/components/ui/checkout/checkout.tsx | 6 + 11 files changed, 274 insertions(+), 136 deletions(-) create mode 100644 packages/checkout/sdk/src/postMessageHandler/postMessageEventTypes.ts create mode 100644 packages/checkout/widgets-lib/src/widgets/checkout/utils/config.ts diff --git a/packages/checkout/sdk/src/postMessageHandler/index.ts b/packages/checkout/sdk/src/postMessageHandler/index.ts index c6d180d178..7c61a87d34 100644 --- a/packages/checkout/sdk/src/postMessageHandler/index.ts +++ b/packages/checkout/sdk/src/postMessageHandler/index.ts @@ -1 +1,2 @@ export * from './postMessageHandler'; +export * from './postMessageEventTypes'; diff --git a/packages/checkout/sdk/src/postMessageHandler/postMessageEventTypes.ts b/packages/checkout/sdk/src/postMessageHandler/postMessageEventTypes.ts new file mode 100644 index 0000000000..a34e0f6d74 --- /dev/null +++ b/packages/checkout/sdk/src/postMessageHandler/postMessageEventTypes.ts @@ -0,0 +1,15 @@ +export enum PostMessageHandlerEventType { + SYN = 'IMTBL_POST_MESSAGE_SYN', + ACK = 'IMTBL_POST_MESSAGE_ACK', + PROVIDER_RELAY = 'IMTBL_PROVIDER_RELAY', + EIP_6963_EVENT = 'IMTBL_EIP_6963_EVENT', + WIDGET_EVENT = 'IMTBL_CHECKOUT_WIDGET_EVENT', +} + +export type PostMessageProviderRelayData = any; + +export type PostMessageEIP6963Data = any; + +export type PostMessagePayload = + | PostMessageProviderRelayData + | PostMessageEIP6963Data; diff --git a/packages/checkout/sdk/src/postMessageHandler/postMessageHandler.ts b/packages/checkout/sdk/src/postMessageHandler/postMessageHandler.ts index 0142aa6509..7a295faf37 100644 --- a/packages/checkout/sdk/src/postMessageHandler/postMessageHandler.ts +++ b/packages/checkout/sdk/src/postMessageHandler/postMessageHandler.ts @@ -1,96 +1,148 @@ +import { + PostMessageHandlerEventType, + PostMessagePayload, +} from './postMessageEventTypes'; + export type PostMessageHandlerConfiguration = { targetOrigin: string; - eventTarget: MinimalEventTargetInterface; - eventSource?: MinimalEventSourceInterface; + eventTarget: WindowProxy; + eventSource?: WindowProxy; }; -// todo put these in a types file -export enum PostMessageHandlerEventType { - PROVIDER_RELAY = 'IMTBL_PROVIDER_RELAY', - EIP_6963_EVENT = 'IMTBL_EIP_6963_EVENT', - WIDGET_EVENT = 'IMTBL_CHECKOUT_WIDGET_EVENT', -} - -export type PostMessageProviderRelayData = any; - -export type PostMessageEIP6963Data = any; - -export type PostMessagePayaload = - | PostMessageProviderRelayData - | PostMessageEIP6963Data; export type PostMessageData = { type: PostMessageHandlerEventType; - payload: PostMessagePayaload; + payload: PostMessagePayload; }; -export interface MinimalEventSourceInterface { - addEventListener( - eventType: 'message', - handler: (message: MessageEvent) => void - ): void; - removeEventListener( - eventType: 'message', - handler: (message: MessageEvent) => void - ): void; -} +export class PostMessageHandler { + private init: boolean = false; -export interface MinimalEventTargetInterface { - postMessage(message: any, targetOrigin?: string): void; -} + private haveSyn: boolean = false; -export class PostMessageHandler { - private eventHandlers: Map void> = new Map(); + private subscribers: Array<(message: PostMessageData) => void> = []; + + private queue: PostMessageData[] = []; private targetOrigin!: string; - private eventTarget!: MinimalEventTargetInterface; + private eventTarget!: WindowProxy; + + private eventSource!: WindowProxy; + + private logger: (...args: any[]) => void; - private eventSource!: MinimalEventSourceInterface; + static isSynOrAck = (type: PostMessageHandlerEventType): boolean => type === PostMessageHandlerEventType.SYN + || type === PostMessageHandlerEventType.ACK; constructor({ targetOrigin, eventTarget, eventSource = window, }: PostMessageHandlerConfiguration) { - this.handleMessage = this.handleMessage.bind(this); + if (!targetOrigin) { + throw new Error('targetOrigin is required'); + } + + if (!eventTarget) { + throw new Error('eventTarget is required'); + } + + if (typeof eventTarget.postMessage !== 'function') { + throw new Error( + 'eventTarget.postMessage is not a function. This class should only be instantiated in a Window.', + ); + } + this.targetOrigin = targetOrigin; this.eventSource = eventSource; this.eventTarget = eventTarget; - this.eventHandlers = new Map(); + this.logger = () => {}; - this.eventSource.addEventListener('message', this.handleMessage); + this.eventSource.addEventListener('message', this.onMessage); + this.handshake(); } - public sendMessage(type: PostMessageHandlerEventType, payload: any) { + public setLogger(logger: any) { + this.logger = logger; + } + + private handshake = (): void => { + this.postMessage(PostMessageHandlerEventType.SYN, null); + }; + + private onMessage = (event: MessageEvent): void => { + if (event.origin !== this.targetOrigin) return; + + if (this.init) { + this.handleMessage(event); + } else if (event.data?.type === PostMessageHandlerEventType.SYN) { + this.haveSyn = true; + this.postMessage(PostMessageHandlerEventType.ACK, null); + } else if (event.data?.type === PostMessageHandlerEventType.ACK) { + this.init = true; + if (!this.haveSyn) { + this.postMessage(PostMessageHandlerEventType.ACK, null); + } + this.flushQueue(); + } + }; + + private flushQueue(): void { + while (this.queue.length > 0) { + const message = this.queue.shift(); + + if (message) { + this.logger('Flush message:', message); + this.send(message.type, message.payload); + } + } + } + + private postMessage(type: PostMessageHandlerEventType, payload: any): void { const message: PostMessageData = { type, payload }; this.eventTarget.postMessage(message, this.targetOrigin); + + if (!PostMessageHandler.isSynOrAck(type)) { + this.logger('Send message:', { type, payload }); + } } - public addEventHandler( - type: PostMessageHandlerEventType, - handler: (data: any) => void, - ): void { - this.eventHandlers.set(type, handler); + public send(type: PostMessageHandlerEventType, payload: any): void { + if (this.init || PostMessageHandler.isSynOrAck(type)) { + this.postMessage(type, payload); + return; + } + + this.logger('Queue message:', { type, payload }); + this.queue.push({ type, payload }); } - public removeEventHandler(type: PostMessageHandlerEventType): void { - this.eventHandlers.delete(type); + public subscribe(handler: (message: PostMessageData) => void): () => void { + this.subscribers.push(handler); + + return () => { + this.unsubscribe(handler); + }; } - private handleMessage(event: MessageEvent) { - if (event.origin !== this.targetOrigin) { - return; + private unsubscribe(handler: (message: PostMessageData) => void): void { + const index = this.subscribers.indexOf(handler); + if (index !== -1) { + this.subscribers.splice(index, 1); } + } + private handleMessage = (event: MessageEvent) => { const message: PostMessageData = event.data; - const handler = this.eventHandlers.get(message.type); - if (handler) { - handler(message.payload); + if (!PostMessageHandler.isSynOrAck(message.type)) { + this.logger('Received message:', message); } - } + + this.subscribers.forEach((handler) => handler(message)); + }; public destroy() { - this.eventSource.removeEventListener('message', this.handleMessage); + this.eventSource.removeEventListener('message', this.onMessage); } } diff --git a/packages/checkout/widgets-lib/src/components/ConnectLoader/ConnectLoader.tsx b/packages/checkout/widgets-lib/src/components/ConnectLoader/ConnectLoader.tsx index 6bc2a5578b..a1dba1dcee 100644 --- a/packages/checkout/widgets-lib/src/components/ConnectLoader/ConnectLoader.tsx +++ b/packages/checkout/widgets-lib/src/components/ConnectLoader/ConnectLoader.tsx @@ -1,4 +1,5 @@ import { Web3Provider } from '@ethersproject/providers'; +import { useTranslation } from 'react-i18next'; import { ChainId, Checkout, @@ -54,6 +55,8 @@ export function ConnectLoader({ web3Provider, } = params; + const { t } = useTranslation(); + const [connectLoaderState, connectLoaderDispatch] = useReducer( connectLoaderReducer, { ...initialConnectLoaderState, checkout }, // set checkout instance here @@ -259,7 +262,7 @@ export function ConnectLoader({ }, }); }} - actionText="Try Again" + actionText={t('views.ERROR_VIEW.actionText')} /> )} diff --git a/packages/checkout/widgets-lib/src/widgets/checkout/CheckoutWidget.tsx b/packages/checkout/widgets-lib/src/widgets/checkout/CheckoutWidget.tsx index 3e10fa61c7..36386b6953 100644 --- a/packages/checkout/widgets-lib/src/widgets/checkout/CheckoutWidget.tsx +++ b/packages/checkout/widgets-lib/src/widgets/checkout/CheckoutWidget.tsx @@ -1,11 +1,10 @@ -import { useEffect, useMemo, useReducer } from 'react'; +import { useMemo, useReducer } from 'react'; import { Checkout, CheckoutWidgetConfiguration, CheckoutWidgetParams, } from '@imtbl/checkout-sdk'; import { - CheckoutActions, checkoutReducer, initialCheckoutState, } from './context/CheckoutContext'; @@ -23,7 +22,7 @@ export default function CheckoutWidget(props: CheckoutWidgetInputs) { const { config, checkout, params } = props; const { environment, publishableKey } = checkout.config; - const [, iframeUrl] = useMemo(() => { + const [, iframeURL] = useMemo(() => { if (!publishableKey) return ['', '']; return getIframeURL(params, config, environment, publishableKey); }, [params, config, environment, publishableKey]); @@ -34,23 +33,12 @@ export default function CheckoutWidget(props: CheckoutWidgetInputs) { ); const checkoutReducerValues = useMemo( () => ({ - checkoutState: { ...checkoutState, iframeUrl, checkout }, + checkoutState: { ...checkoutState, iframeURL, checkout }, checkoutDispatch, }), - [checkoutState, checkoutDispatch, iframeUrl, checkout], + [checkoutState, checkoutDispatch, iframeURL, checkout], ); - useEffect(() => { - if (iframeUrl === undefined) return; - - checkoutDispatch({ - payload: { - type: CheckoutActions.SET_IFRAME_URL, - iframeUrl, - }, - }); - }, [iframeUrl]); - return ( diff --git a/packages/checkout/widgets-lib/src/widgets/checkout/context/CheckoutContext.ts b/packages/checkout/widgets-lib/src/widgets/checkout/context/CheckoutContext.ts index 38c61843e8..2123414d89 100644 --- a/packages/checkout/widgets-lib/src/widgets/checkout/context/CheckoutContext.ts +++ b/packages/checkout/widgets-lib/src/widgets/checkout/context/CheckoutContext.ts @@ -10,8 +10,8 @@ export interface CheckoutState { checkout: Checkout | null; provider: Web3Provider | undefined; passport: Passport | undefined; - iframeUrl: string | undefined; - checkoutAppIframe: Window | undefined; + iframeURL: string | undefined; + iframeContentWindow: Window | undefined; postMessageHandler: PostMessageHandler | undefined; providerRelay: ProviderRelay | undefined; walletProviderName: WalletProviderName | null; @@ -23,8 +23,8 @@ export const initialCheckoutState: CheckoutState = { checkout: null, provider: undefined, passport: undefined, - iframeUrl: undefined, - checkoutAppIframe: undefined, + iframeURL: undefined, + iframeContentWindow: undefined, postMessageHandler: undefined, providerRelay: undefined, walletProviderInfo: null, @@ -44,9 +44,9 @@ export interface CheckoutAction { type ActionPayload = | SetCheckoutPayload | SetProviderPayload - | SetIframeUrlPayload + | SetIframeURLPayload | SetPostMessageHandlerPayload - | SetCheckoutAppIframePayload + | SetIframeContentWindowPayload | SetProviderRelayPayload | SetPassportPayload | SetProviderNamePayload @@ -74,14 +74,14 @@ export interface SetProviderPayload { provider: Web3Provider; } -export interface SetIframeUrlPayload { +export interface SetIframeURLPayload { type: CheckoutActions.SET_IFRAME_URL; - iframeUrl: string; + iframeURL: string; } -export interface SetCheckoutAppIframePayload { +export interface SetIframeContentWindowPayload { type: CheckoutActions.SET_CHECKOUT_APP_IFRAME; - checkoutAppIframe: Window; + iframeContentWindow: Window; } export interface SetPostMessageHandlerPayload { @@ -142,12 +142,12 @@ export const checkoutReducer: Reducer = ( case CheckoutActions.SET_IFRAME_URL: return { ...state, - iframeUrl: action.payload.iframeUrl, + iframeURL: action.payload.iframeURL, }; case CheckoutActions.SET_CHECKOUT_APP_IFRAME: return { ...state, - checkoutAppIframe: action.payload.checkoutAppIframe, + iframeContentWindow: action.payload.iframeContentWindow, }; case CheckoutActions.SET_POST_MESSAGE_HANDLER: return { diff --git a/packages/checkout/widgets-lib/src/widgets/checkout/context/CheckoutContextProvider.tsx b/packages/checkout/widgets-lib/src/widgets/checkout/context/CheckoutContextProvider.tsx index 82280d97e7..995142d102 100644 --- a/packages/checkout/widgets-lib/src/widgets/checkout/context/CheckoutContextProvider.tsx +++ b/packages/checkout/widgets-lib/src/widgets/checkout/context/CheckoutContextProvider.tsx @@ -25,17 +25,22 @@ export function CheckoutContextProvider({ const { checkout, provider, - checkoutAppIframe, + iframeContentWindow, postMessageHandler, - iframeUrl, + iframeURL, } = checkoutState; useEffect(() => { - if (!checkoutAppIframe || !checkout || !iframeUrl) return; + if (!iframeContentWindow || !checkout || !iframeURL) return; const postMessageHandlerInstance = new PostMessageHandler({ - targetOrigin: new URL(iframeUrl).origin, - eventTarget: checkoutAppIframe, + targetOrigin: new URL(iframeURL).origin, + eventTarget: iframeContentWindow, + }); + + // TODO: remove logger after done with development + postMessageHandlerInstance.setLogger((...args: any[]) => { + console.log("🔔 PARENT – ", ...args); // eslint-disable-line }); checkoutDispatch({ @@ -44,7 +49,7 @@ export function CheckoutContextProvider({ postMessageHandler: postMessageHandlerInstance, }, }); - }, [checkoutAppIframe, checkout, iframeUrl]); + }, [iframeContentWindow, checkout, iframeURL]); useEffect(() => { if (!provider || !postMessageHandler) return undefined; diff --git a/packages/checkout/widgets-lib/src/widgets/checkout/context/ProviderRelay.ts b/packages/checkout/widgets-lib/src/widgets/checkout/context/ProviderRelay.ts index bc5e3f622b..6d9e09ab00 100644 --- a/packages/checkout/widgets-lib/src/widgets/checkout/context/ProviderRelay.ts +++ b/packages/checkout/widgets-lib/src/widgets/checkout/context/ProviderRelay.ts @@ -1,11 +1,13 @@ // TODO ProviderRelay should live in a different folder import { Web3Provider } from '@ethersproject/providers'; -import { PostMessageHandler, PostMessageHandlerEventType } from '@imtbl/checkout-sdk'; +import { + PostMessageData, + PostMessageHandler, + PostMessageHandlerEventType, +} from '@imtbl/checkout-sdk'; export class ProviderRelay { - // TODO maybe make this a singleton instance? only want one at a time - private provider: Web3Provider; private postMessageHandler: PostMessageHandler; @@ -14,22 +16,30 @@ export class ProviderRelay { this.provider = provider; this.postMessageHandler = postMessageHandler; - postMessageHandler.addEventHandler(PostMessageHandlerEventType.PROVIDER_RELAY, this.onMessage); + postMessageHandler.subscribe(this.onMessage); } - private onMessage = (payload: any) => { - if (this.provider.provider.request) { - this.provider.provider - .request({ method: payload.method, params: payload.params }) - .then((resp) => { - const formattedResponse = { - id: payload.id, - jsonrpc: '2.0', - result: resp, - }; - - this.postMessageHandler.sendMessage(PostMessageHandlerEventType.PROVIDER_RELAY, formattedResponse); - }); + private onMessage = ({ type, payload }: PostMessageData) => { + if (type !== PostMessageHandlerEventType.PROVIDER_RELAY) return; + + if (!this.provider.provider.request) { + throw new Error('Provider does not support request method'); } + + this.provider.provider + .request({ method: payload.method, params: payload.params }) + .then((resp) => { + const formattedResponse = { + id: payload.id, + jsonrpc: '2.0', + result: resp, + }; + + // Relay the response back to proxied provider + this.postMessageHandler.send( + PostMessageHandlerEventType.PROVIDER_RELAY, + formattedResponse, + ); + }); }; } diff --git a/packages/checkout/widgets-lib/src/widgets/checkout/utils/config.ts b/packages/checkout/widgets-lib/src/widgets/checkout/utils/config.ts new file mode 100644 index 0000000000..19e9703eb3 --- /dev/null +++ b/packages/checkout/widgets-lib/src/widgets/checkout/utils/config.ts @@ -0,0 +1,4 @@ +/** + * The timeout in milliseconds for the iframe to be initialized. + */ +export const IFRAME_INIT_TIMEOUT_MS = 10000; diff --git a/packages/checkout/widgets-lib/src/widgets/checkout/views/CheckoutAppIframe.tsx b/packages/checkout/widgets-lib/src/widgets/checkout/views/CheckoutAppIframe.tsx index 8f6668d3e9..b74f061938 100644 --- a/packages/checkout/widgets-lib/src/widgets/checkout/views/CheckoutAppIframe.tsx +++ b/packages/checkout/widgets-lib/src/widgets/checkout/views/CheckoutAppIframe.tsx @@ -1,5 +1,8 @@ import { Box } from '@biom3/react'; -import { useContext, useEffect, useRef } from 'react'; +import { useTranslation } from 'react-i18next'; +import { + useContext, useEffect, useRef, useState, +} from 'react'; import { CheckoutEventType, IMTBLWidgetEvents, @@ -11,6 +14,9 @@ import { CheckoutActions } from '../context/CheckoutContext'; import { useCheckoutContext } from '../context/CheckoutContextProvider'; import { sendCheckoutEvent } from '../CheckoutWidgetEvents'; import { EventTargetContext } from '../../../context/event-target-context/EventTargetContext'; +import { LoadingView } from '../../../views/loading/LoadingView'; +import { ErrorView } from '../../../views/error/ErrorView'; +import { IFRAME_INIT_TIMEOUT_MS } from '../utils/config'; export interface LoadingHandoverProps { text: string; @@ -19,68 +25,116 @@ export interface LoadingHandoverProps { inputValue?: number; } export function CheckoutAppIframe() { - const [checkoutState, checkoutDispatch] = useCheckoutContext(); - const { iframeUrl, postMessageHandler } = checkoutState; + const { t } = useTranslation(); const iframeRef = useRef(null); + const timeoutRef = useRef(null); + const [loadingError, setLoadingError] = useState(false); + const [initialised, setInitialised] = useState(false); + const [ + { iframeURL, postMessageHandler, iframeContentWindow }, + checkoutDispatch, + ] = useCheckoutContext(); + + const loading = !iframeURL || !iframeContentWindow || !initialised; const { eventTargetState: { eventTarget }, } = useContext(EventTargetContext); const onIframeLoad = () => { - if (!iframeRef.current?.contentWindow) { + const iframe = iframeRef.current; + if (!iframe?.contentWindow) { return; } checkoutDispatch({ payload: { type: CheckoutActions.SET_CHECKOUT_APP_IFRAME, - checkoutAppIframe: iframeRef.current.contentWindow, + iframeContentWindow: iframe.contentWindow, }, }); }; useEffect(() => { - if (postMessageHandler === undefined) return () => {}; + if (!postMessageHandler) return undefined; - postMessageHandler.addEventHandler( - PostMessageHandlerEventType.WIDGET_EVENT, - (event: { + // subscribe to widget events + postMessageHandler.subscribe(({ type, payload }) => { + // FIXME: improve typing + const event: { type: IMTBLWidgetEvents.IMTBL_CHECKOUT_WIDGET_EVENT; detail: { type: CheckoutEventType; - data: WidgetEventData[WidgetType.CHECKOUT][keyof WidgetEventData[WidgetType.CHECKOUT]] + data: WidgetEventData[WidgetType.CHECKOUT][keyof WidgetEventData[WidgetType.CHECKOUT]]; }; - }) => { - sendCheckoutEvent(eventTarget, event.detail); - }, - ); + } = payload as any; + + if (type !== PostMessageHandlerEventType.WIDGET_EVENT) return; + + // forward events + sendCheckoutEvent(eventTarget, event.detail); + + // check if the widget has been initialised + if (event.detail.type === CheckoutEventType.INITIALISED) { + setInitialised(true); + clearTimeout(timeoutRef.current!); + } + }); + + // check if loaded correctly + timeoutRef.current = setTimeout(() => { + if (!initialised) { + setLoadingError(true); + clearTimeout(timeoutRef.current!); + } + }, IFRAME_INIT_TIMEOUT_MS); + return () => { postMessageHandler.destroy(); + clearTimeout(timeoutRef.current!); }; }, [postMessageHandler]); - if (!iframeUrl) { - return null; + if (loadingError) { + return ( + { + sendCheckoutEvent(eventTarget, { + type: CheckoutEventType.CLOSE, + data: {}, + }); + }} + onActionClick={() => { + setLoadingError(false); + iframeContentWindow?.location.reload(); + }} + actionText={t('views.ERROR_VIEW.actionText')} + /> + ); } return ( - + {loading && } + {iframeURL && ( + + )} + sx={{ + w: '100%', + h: '100%', + border: 'none', + boxShadow: 'none', + }} /> )} - sx={{ - w: '100%', - h: '100%', - border: 'none', - boxShadow: 'none', - }} - /> + ); } diff --git a/packages/checkout/widgets-sample-app/src/components/ui/checkout/checkout.tsx b/packages/checkout/widgets-sample-app/src/components/ui/checkout/checkout.tsx index 00a62404c0..c04201c999 100644 --- a/packages/checkout/widgets-sample-app/src/components/ui/checkout/checkout.tsx +++ b/packages/checkout/widgets-sample-app/src/components/ui/checkout/checkout.tsx @@ -57,6 +57,12 @@ function CheckoutUI() { useEffect(() => { passport.connectEvm(); + checkoutWidget.mount("checkout", { + flow: CheckoutFlowType.SWAP, + amount: "0.1", + fromTokenAddress: "0x3B2d8A1931736Fc321C24864BceEe981B11c3c57", // usdc + toTokenAddress: "native", + }); }, []); useEffect(() => {