From c253fe9ddb2a7527d8fdbd90954e3e188816d9f3 Mon Sep 17 00:00:00 2001 From: Jimmy Hardwick Date: Wed, 25 Sep 2024 15:00:22 +1000 Subject: [PATCH] [CHECKOUT SDK][CHECKOUT WIDGETS][CM-896]feat: Load latest compatible Widgets bundle (#2217) --- packages/checkout/sdk/src/index.ts | 1 + packages/checkout/sdk/src/sdk.ts | 75 +++++---- packages/checkout/sdk/src/types/config.ts | 12 +- .../checkout/sdk/src/widgets/load.test.ts | 10 +- packages/checkout/sdk/src/widgets/load.ts | 5 +- .../checkout/sdk/src/widgets/version.test.ts | 151 +++++++++++++++++- packages/checkout/sdk/src/widgets/version.ts | 47 ++++++ .../src/components/ui/checkout/checkout.tsx | 11 +- 8 files changed, 263 insertions(+), 49 deletions(-) diff --git a/packages/checkout/sdk/src/index.ts b/packages/checkout/sdk/src/index.ts index c22d528308..3cef2076e0 100644 --- a/packages/checkout/sdk/src/index.ts +++ b/packages/checkout/sdk/src/index.ts @@ -145,6 +145,7 @@ export type { WalletFilter, WalletInfo, SquidConfig, + CheckoutWidgetsVersionConfig, } from './types'; export { diff --git a/packages/checkout/sdk/src/sdk.ts b/packages/checkout/sdk/src/sdk.ts index 68889abb29..2992e50bbd 100644 --- a/packages/checkout/sdk/src/sdk.ts +++ b/packages/checkout/sdk/src/sdk.ts @@ -1,23 +1,30 @@ /* eslint-disable class-methods-use-this */ import { Web3Provider } from '@ethersproject/providers'; -import { ethers } from 'ethers'; import { Environment } from '@imtbl/config'; -import { Passport } from '@imtbl/passport'; import { track } from '@imtbl/metrics'; +import { Passport } from '@imtbl/passport'; +import { ethers } from 'ethers'; +import { HttpClient } from './api/http'; +import { AvailabilityService, availabilityService } from './availability'; import * as balances from './balances'; -import * as tokens from './tokens'; +import { CheckoutConfiguration } from './config'; import * as connect from './connect'; -import * as provider from './provider'; -import * as wallet from './wallet'; -import * as network from './network'; -import * as transaction from './transaction'; -import { handleProviderError } from './transaction'; +import { CheckoutError, CheckoutErrorType } from './errors'; +import { FiatRampService, FiatRampWidgetParams } from './fiatRamp'; import * as gasEstimatorService from './gasEstimate'; +import * as network from './network'; +import * as provider from './provider'; +import { InjectedProvidersManager } from './provider/injectedProvidersManager'; +import { createReadOnlyProviders } from './readOnlyProviders/readOnlyProvider'; +import * as smartCheckout from './smartCheckout'; import * as buy from './smartCheckout/buy'; import * as cancel from './smartCheckout/cancel'; +import { getItemRequirementsFromRequirements } from './smartCheckout/itemRequirements'; import * as sell from './smartCheckout/sell'; -import * as smartCheckout from './smartCheckout'; import * as swap from './swap'; +import * as tokens from './tokens'; +import * as transaction from './transaction'; +import { handleProviderError } from './transaction'; import { AddNetworkParams, BuyParams, @@ -27,6 +34,7 @@ import { CheckConnectionParams, CheckConnectionResult, CheckoutModuleConfiguration, + CheckoutWidgetsVersionConfig, ConnectParams, ConnectResult, CreateProviderParams, @@ -61,23 +69,16 @@ import { TokenInfo, ValidateProviderOptions, } from './types'; -import { CheckoutConfiguration } from './config'; -import { createReadOnlyProviders } from './readOnlyProviders/readOnlyProvider'; -import { SellParams } from './types/sell'; import { CancelParams } from './types/cancel'; -import { FiatRampService, FiatRampWidgetParams } from './fiatRamp'; -import { getItemRequirementsFromRequirements } from './smartCheckout/itemRequirements'; -import { CheckoutError, CheckoutErrorType } from './errors'; -import { AvailabilityService, availabilityService } from './availability'; -import { getWidgetsEsmUrl, loadUnresolvedBundle } from './widgets/load'; +import { SellParams } from './types/sell'; +import { SwapParams, SwapQuoteResult, SwapResult } from './types/swap'; import { WidgetsInit } from './types/widgets'; -import { HttpClient } from './api/http'; import { isMatchingAddress } from './utils/utils'; +import * as wallet from './wallet'; import { WidgetConfiguration } from './widgets/definitions/configurations'; -import { SemanticVersion } from './widgets/definitions/types'; -import { validateAndBuildVersion } from './widgets/version'; -import { InjectedProvidersManager } from './provider/injectedProvidersManager'; -import { SwapParams, SwapQuoteResult, SwapResult } from './types/swap'; +import { getWidgetsEsmUrl, loadUnresolvedBundle } from './widgets/load'; +import { determineWidgetsVersion, validateAndBuildVersion } from './widgets/version'; +import { globalPackageVersion } from './env'; const SANDBOX_CONFIGURATION = { baseConfig: { @@ -134,10 +135,27 @@ export class Checkout { const checkout = this; // Preload the configurations - await checkout.config.remote.getConfig(); + const versionConfig = ( + await checkout.config.remote.getConfig('checkoutWidgetsVersion') + ) as CheckoutWidgetsVersionConfig | undefined; + + // Determine the version of the widgets to load + const validatedBuildVersion = validateAndBuildVersion(init.version); + const initVersionProvided = init.version !== undefined; + const widgetsVersion = determineWidgetsVersion( + validatedBuildVersion, + initVersionProvided, + versionConfig, + ); + + track('checkout_sdk', 'widgets', { + sdkVersion: globalPackageVersion(), + validatedSdkVersion: validatedBuildVersion, + widgetsVersion, + }); try { - const factory = await this.loadEsModules(init.config, init.version); + const factory = await this.loadEsModules(init.config, widgetsVersion); return factory; } catch (err: any) { throw new CheckoutError( @@ -150,7 +168,7 @@ export class Checkout { private async loadUmdBundle( config: WidgetConfiguration, - version?: SemanticVersion, + validVersion: string, ) { const checkout = this; @@ -158,7 +176,6 @@ export class Checkout { (resolve, reject) => { try { const scriptId = 'immutable-checkout-widgets-bundle'; - const validVersion = validateAndBuildVersion(version); // Prevent the script to be loaded more than once // by checking the presence of the script and its version. @@ -225,11 +242,11 @@ export class Checkout { private async loadEsModules( config: WidgetConfiguration, - version?: SemanticVersion, + validVersion: string, ) { const checkout = this; try { - const cdnUrl = getWidgetsEsmUrl(version); + const cdnUrl = getWidgetsEsmUrl(validVersion); // WebpackIgnore comment required to prevent webpack modifying the import statement and // breaking the dynamic import in certain applications integrating checkout @@ -248,7 +265,7 @@ export class Checkout { } // Fallback to UMD bundle if esm bundle fails to load - return await checkout.loadUmdBundle(config, version); + return await checkout.loadUmdBundle(config, validVersion); } /** diff --git a/packages/checkout/sdk/src/types/config.ts b/packages/checkout/sdk/src/types/config.ts index bde7aac84c..8671aa476e 100644 --- a/packages/checkout/sdk/src/types/config.ts +++ b/packages/checkout/sdk/src/types/config.ts @@ -17,19 +17,19 @@ interface CheckoutFeatureConfiguration { * A type representing the on-ramp configurations for the checkout SDK. * @property {boolean} enable - To enable on-ramp feature in Checkout sdk. */ -export interface CheckoutOnRampConfiguration extends CheckoutFeatureConfiguration {} +export interface CheckoutOnRampConfiguration extends CheckoutFeatureConfiguration { } /** * A type representing the swap configurations for the checkout SDK. * @property {boolean} enable - To enable swap feature in Checkout sdk. */ -export interface CheckoutSwapConfiguration extends CheckoutFeatureConfiguration {} +export interface CheckoutSwapConfiguration extends CheckoutFeatureConfiguration { } /** * A type representing the bridge configurations for the checkout SDK. * @property {boolean} enable - To enable bridge feature in Checkout sdk. */ -export interface CheckoutBridgeConfiguration extends CheckoutFeatureConfiguration {} +export interface CheckoutBridgeConfiguration extends CheckoutFeatureConfiguration { } /** * A type representing checkout SDK configurations. @@ -81,6 +81,8 @@ export type RemoteConfiguration = { telemetry?: TelemetryConfig; /** Squid config. */ squid?: SquidConfig; + /** The checkout version info. */ + checkoutWidgetsVersion: CheckoutWidgetsVersionConfig; }; /** @@ -261,3 +263,7 @@ export type GasEstimateSwapTokenConfig = { export type ChainTokensConfig = { [key in ChainId]?: TokenInfo[]; }; + +export type CheckoutWidgetsVersionConfig = { + compatibleVersionMarkers: string[]; +}; diff --git a/packages/checkout/sdk/src/widgets/load.test.ts b/packages/checkout/sdk/src/widgets/load.test.ts index 44b330c562..afcaede08c 100644 --- a/packages/checkout/sdk/src/widgets/load.test.ts +++ b/packages/checkout/sdk/src/widgets/load.test.ts @@ -7,7 +7,7 @@ describe('load', () => { const scriptId = 'immutable-checkout-widgets-bundle'; beforeEach(() => { - jest.spyOn(console, 'warn').mockImplementation(() => {}); + jest.spyOn(console, 'warn').mockImplementation(() => { }); }); describe('load unresolved bundle', () => { @@ -24,17 +24,13 @@ describe('load', () => { describe('get widgets esm url', () => { it('should validate the versioning', () => { - expect(getWidgetsEsmUrl()).toEqual( + expect(getWidgetsEsmUrl(SDK_VERSION)).toEqual( `https://cdn.jsdelivr.net/npm/@imtbl/sdk@${SDK_VERSION}/dist/browser/checkout/widgets-esm.js`, ); }); it('should change version', () => { - expect(getWidgetsEsmUrl({ - major: 1, - minor: 2, - patch: 3, - })).toEqual( + expect(getWidgetsEsmUrl('1.2.3')).toEqual( 'https://cdn.jsdelivr.net/npm/@imtbl/sdk@1.2.3/dist/browser/checkout/widgets-esm.js', ); }); diff --git a/packages/checkout/sdk/src/widgets/load.ts b/packages/checkout/sdk/src/widgets/load.ts index 4711a497b8..328a07ef5f 100644 --- a/packages/checkout/sdk/src/widgets/load.ts +++ b/packages/checkout/sdk/src/widgets/load.ts @@ -1,6 +1,4 @@ import { useLocalBundle } from '../env'; -import { SemanticVersion } from './definitions/types'; -import { validateAndBuildVersion } from './version'; // Loads the checkout widgets bundle from the CDN and appends the script to the document head export function loadUnresolvedBundle( @@ -28,9 +26,8 @@ export function loadUnresolvedBundle( // Gets the CDN url for the split checkout widgets bundle export function getWidgetsEsmUrl( - version?: SemanticVersion, + validVersion: string, ): string { - const validVersion = validateAndBuildVersion(version); let cdnUrl = `https://cdn.jsdelivr.net/npm/@imtbl/sdk@${validVersion}/dist/browser/checkout/widgets-esm.js`; if (useLocalBundle()) cdnUrl = `http://${window.location.host}/lib/js/index.js`; return cdnUrl; diff --git a/packages/checkout/sdk/src/widgets/version.test.ts b/packages/checkout/sdk/src/widgets/version.test.ts index f213ea9e10..d8b066a982 100644 --- a/packages/checkout/sdk/src/widgets/version.test.ts +++ b/packages/checkout/sdk/src/widgets/version.test.ts @@ -1,17 +1,17 @@ import { SDK_VERSION_MARKER } from '../env'; +import { CheckoutWidgetsVersionConfig } from '../types'; import { SemanticVersion } from './definitions/types'; -import { validateAndBuildVersion } from './version'; +import { determineWidgetsVersion, validateAndBuildVersion } from './version'; describe('CheckoutWidgets', () => { const SDK_VERSION = SDK_VERSION_MARKER; beforeEach(() => { - jest.spyOn(console, 'warn').mockImplementation(() => {}); + jest.spyOn(console, 'warn').mockImplementation(() => { }); }); - describe('Versioning', () => { - const versionTestCases: - { + describe('Version Validation', () => { + const versionTestCases: { title: string, version: SemanticVersion | undefined, expectedVersion: string, @@ -202,4 +202,145 @@ describe('CheckoutWidgets', () => { }); }); }); + + describe('Determine Widget Version', () => { + const determineWidgetVersionTestCases: { + title: string, + expectedVersion: string, + validatedBuildVersion: string, + initVersionProvided: boolean, + checkoutVersionConfig?: CheckoutWidgetsVersionConfig, + }[] = [ + { + title: 'version is provided in widget init params', + expectedVersion: '1.0.0', + validatedBuildVersion: '1.0.0', + checkoutVersionConfig: undefined, + initVersionProvided: true, + }, + { + title: 'version is provided in widget init params and compatibleVersionMarkers are available', + expectedVersion: '1.0.0', + validatedBuildVersion: '1.0.0', + initVersionProvided: true, + checkoutVersionConfig: { compatibleVersionMarkers: ['2.0.0', '1.0.0'] }, + }, + { + title: 'there is a matching compatible version marker', + expectedVersion: '1.1.1', + validatedBuildVersion: '1.0.0', + initVersionProvided: false, + checkoutVersionConfig: { compatibleVersionMarkers: ['1.1.1'] }, + }, + { + title: 'there is a matching compatible version marker', + expectedVersion: '1.1.1', + validatedBuildVersion: '1.0.0', + initVersionProvided: false, + checkoutVersionConfig: { compatibleVersionMarkers: ['1.1.1', '2.0.0'] }, + }, + { + title: 'there is a matching compatible version marker', + expectedVersion: '1.1.1', + validatedBuildVersion: '1.1.1', + initVersionProvided: false, + checkoutVersionConfig: { compatibleVersionMarkers: ['1.1.1', '2.0.0'] }, + }, + { + title: 'there is a matching compatible version marker', + expectedVersion: '2.0.0', + validatedBuildVersion: '1.2.0', + initVersionProvided: false, + checkoutVersionConfig: { compatibleVersionMarkers: ['1.1.1', '2.0.0'] }, + }, + { + title: 'there is no matching compatible version marker, returning latest', + expectedVersion: 'latest', + validatedBuildVersion: '1.0.0', + initVersionProvided: false, + checkoutVersionConfig: { compatibleVersionMarkers: [] }, + }, + { + title: 'there is no matching compatible version marker, returning latest', + expectedVersion: 'latest', + validatedBuildVersion: '1.2.0', + initVersionProvided: false, + checkoutVersionConfig: { compatibleVersionMarkers: ['1.1.0'] }, + }, + { + title: 'all compatible version markers are invalid', + expectedVersion: 'latest', + validatedBuildVersion: '1.2.0', + initVersionProvided: false, + checkoutVersionConfig: { compatibleVersionMarkers: ['invalid', 'invalid'] }, + }, + { + title: 'there are invalid compatible version markers', + expectedVersion: 'latest', + validatedBuildVersion: '1.2.0', + initVersionProvided: false, + checkoutVersionConfig: { compatibleVersionMarkers: ['1.1.0', 'invalid'] }, + }, + { + title: 'there are invalid compatible version markers', + expectedVersion: '1.1.0', + validatedBuildVersion: '1.0.0', + initVersionProvided: false, + checkoutVersionConfig: { compatibleVersionMarkers: ['1.1.0', 'invalid'] }, + }, + { + title: 'there are invalid compatible version markers', + expectedVersion: '2.0.0', + validatedBuildVersion: '1.51.0', + initVersionProvided: false, + checkoutVersionConfig: { compatibleVersionMarkers: ['1.1.0', 'invalid', '2.0.0'] }, + }, + { + title: 'there are invalid compatible version markers', + expectedVersion: 'latest', + validatedBuildVersion: '2.1.0', + initVersionProvided: false, + checkoutVersionConfig: { compatibleVersionMarkers: ['1.1.0', 'invalid', '2.0.0'] }, + }, + { + title: 'the build version is an alpha', + expectedVersion: '1.2.0-alpha', + validatedBuildVersion: '1.2.0-alpha', + initVersionProvided: false, + checkoutVersionConfig: { compatibleVersionMarkers: ['2.0.0'] }, + }, + { + title: 'the build version is an alpha', + expectedVersion: '1.2.0-alpha', + validatedBuildVersion: '1.2.0-alpha', + initVersionProvided: false, + checkoutVersionConfig: { compatibleVersionMarkers: [] }, + }, + { + title: 'no version config provided', + expectedVersion: '1.2.0', + validatedBuildVersion: '1.2.0', + initVersionProvided: false, + checkoutVersionConfig: undefined, + }, + { + title: 'invalid version config provided', + expectedVersion: '1.2.0', + validatedBuildVersion: '1.2.0', + initVersionProvided: false, + checkoutVersionConfig: { compatibleVersionMarkers: undefined as unknown as string[] }, + }, + ]; + + determineWidgetVersionTestCases.forEach((testCase) => { + it(`should determine correct widget version when ${testCase.title}`, () => { + const widgetVersion = determineWidgetsVersion( + testCase.validatedBuildVersion, + testCase.initVersionProvided, + testCase?.checkoutVersionConfig, + ); + expect(widgetVersion).toEqual(testCase.expectedVersion); + }); + }); + }); }); diff --git a/packages/checkout/sdk/src/widgets/version.ts b/packages/checkout/sdk/src/widgets/version.ts index 5293983c1c..dab0fd7781 100644 --- a/packages/checkout/sdk/src/widgets/version.ts +++ b/packages/checkout/sdk/src/widgets/version.ts @@ -1,5 +1,7 @@ +import semver from 'semver'; import { globalPackageVersion } from '../env'; import { SemanticVersion } from './definitions/types'; +import { CheckoutWidgetsVersionConfig } from '../types'; /** * Validates and builds a version string based on the given SemanticVersion object. @@ -51,3 +53,48 @@ export function validateAndBuildVersion( return validatedVersion; } + +/** + * Returns the latest compatible version based on the provided checkout version config. + * If no compatible version markers are provided, it returns 'latest'. + */ +function latestCompatibleVersion( + validVersion: string, + compatibleVersionMarkers: string[], +) { + for (const comptabileVersionMarker of compatibleVersionMarkers) { + if (semver.valid(comptabileVersionMarker) && semver.lte(validVersion, comptabileVersionMarker)) { + return comptabileVersionMarker; + } + } + return 'latest'; +} + +/** + * Determines the version of the widgets to use based on the provided validated build version and checkout version config. + * If a version is provided in the widget init parameters, it uses that version. + * If the build version is an alpha, it uses that version. + * Defaults to 'latest' if no compatible version markers are found. + */ +export function determineWidgetsVersion( + validatedBuildVersion: string, + initVersionProvided: boolean, + versionConfig?: CheckoutWidgetsVersionConfig, +) { + // If version is provided in widget init parms, use that + if (initVersionProvided) { + return validatedBuildVersion; + } + + // If validated build version is an alpha, use that + if (validatedBuildVersion.includes('alpha')) { + return validatedBuildVersion; + } + + // If there's version config is invalid, default to use current build version + if (!versionConfig || !Array.isArray(versionConfig.compatibleVersionMarkers)) { + return validatedBuildVersion; + } + + return latestCompatibleVersion(validatedBuildVersion, versionConfig.compatibleVersionMarkers); +} 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 4a7df9d57a..df9653eea1 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 @@ -218,9 +218,18 @@ function CheckoutUI() { // ignore language or theme changes const widgetsFactory = useAsyncMemo( async () => new WidgetsFactory(checkoutSdk, { theme, language }), - [] + [checkoutSdk] ); + // setup widgets factory using a local widgets bundle, after building with build:local + // see packages/checkout/widgets-lib/README.md + // const widgetsFactory = useAsyncMemo( + // () => checkoutSdk?.widgets({ config: { theme, language } }), + // [checkoutSdk] + // ); + + + // know connected wallet type const isMetamask = web3Provider?.provider?.isMetaMask; const isPassport = (