diff --git a/packages/libs/sdk-mixins/src/mixins/staticResourcesMixin/staticResourcesMixin.ts b/packages/libs/sdk-mixins/src/mixins/staticResourcesMixin/staticResourcesMixin.ts index 676156433..54d7c282c 100644 --- a/packages/libs/sdk-mixins/src/mixins/staticResourcesMixin/staticResourcesMixin.ts +++ b/packages/libs/sdk-mixins/src/mixins/staticResourcesMixin/staticResourcesMixin.ts @@ -1,32 +1,15 @@ -import { pathJoin, compose, createSingletonMixin } from '@descope/sdk-helpers'; +import { compose, createSingletonMixin, pathJoin } from '@descope/sdk-helpers'; +import { baseUrlMixin } from '../baseUrlMixin'; import { loggerMixin } from '../loggerMixin'; +import { projectIdMixin } from '../projectIdMixin'; import { ASSETS_FOLDER, BASE_CONTENT_URL, OVERRIDE_CONTENT_URL, } from './constants'; -import { projectIdMixin } from '../projectIdMixin'; -import { baseUrlMixin } from '../baseUrlMixin'; type Format = 'text' | 'json'; -export function getResourceUrl({ - projectId, - filename, - assetsFolder = ASSETS_FOLDER, - baseUrl, -}: { - projectId: string; - filename: string; - assetsFolder?: string; - baseUrl?: string; -}) { - const url = new URL(OVERRIDE_CONTENT_URL || baseUrl || BASE_CONTENT_URL); - url.pathname = pathJoin(url.pathname, projectId, assetsFolder, filename); - - return url.toString(); -} - export const staticResourcesMixin = createSingletonMixin( (superclass: T) => { const BaseClass = compose( @@ -36,29 +19,76 @@ export const staticResourcesMixin = createSingletonMixin( )(superclass); return class StaticResourcesMixinClass extends BaseClass { + preferredBaseURL?: string; + async fetchStaticResource( filename: string, format: F, + assetsFolder = ASSETS_FOLDER, ): Promise<{ body: F extends 'json' ? Record : string; headers: Record; }> { - const resourceUrl = getResourceUrl({ - projectId: this.projectId, - filename, - baseUrl: this.baseStaticUrl, - }); - const res = await fetch(resourceUrl, { cache: 'default' }); - if (!res.ok) { - this.logger.error( - `Error fetching URL ${resourceUrl} [${res.status}]`, + const fetchResourceFromBaseURL = async (baseUrl: string) => { + // Compute the base URL to fetch the resource from + // This allows overriding the base URL for static resources + const computedBaseUrl = new URL( + OVERRIDE_CONTENT_URL || + this.preferredBaseURL || + baseUrl || + BASE_CONTENT_URL, + ); + + const resourceUrl = new URL( + pathJoin( + computedBaseUrl.pathname, + this.projectId, + assetsFolder, + filename, + ), + computedBaseUrl, ); - } - return { - body: await res[format](), - headers: Object.fromEntries(res.headers.entries()), + const res = await fetch(resourceUrl, { cache: 'default' }); + if (!res.ok) { + this.logger.error( + `Error fetching URL ${resourceUrl} [${res.status}]`, + ); + } else { + if (!this.preferredBaseURL) { + this.logger.debug(`Fetched URL ${resourceUrl} [${res.status}]`); + this.logger.debug( + `Updating preferred base URL to ${computedBaseUrl.toString()}`, + ); + this.preferredBaseURL = computedBaseUrl.toString(); + } + } + return res; }; + + try { + // We prefer to fetch the resource from the base API URL + let res = await fetchResourceFromBaseURL( + new URL('/pages', this.baseUrl).toString(), + ); + if (!res.ok) { + // If the resource is not found in the base API URL, we try to fetch it from the static URL + res = await fetchResourceFromBaseURL(this.baseStaticUrl); + } + return { + body: await res[format](), + headers: Object.fromEntries(res.headers.entries()), + }; + } catch (e) { + this.logger.error( + `Error fetching static resource ${filename} from ${this.baseStaticUrl}`, + ); + const res = await fetchResourceFromBaseURL(this.baseStaticUrl); + return { + body: await res[format](), + headers: Object.fromEntries(res.headers.entries()), + }; + } } get baseStaticUrl() { diff --git a/packages/sdks/web-component/src/lib/descope-wc/BaseDescopeWc.ts b/packages/sdks/web-component/src/lib/descope-wc/BaseDescopeWc.ts index 91759a502..a1eb8ced5 100644 --- a/packages/sdks/web-component/src/lib/descope-wc/BaseDescopeWc.ts +++ b/packages/sdks/web-component/src/lib/descope-wc/BaseDescopeWc.ts @@ -1,6 +1,7 @@ -import createSdk from '@descope/web-js-sdk'; -import { themeMixin } from '@descope/sdk-mixins/themeMixin'; import { compose } from '@descope/sdk-helpers'; +import { staticResourcesMixin } from '@descope/sdk-mixins'; +import { themeMixin } from '@descope/sdk-mixins/themeMixin'; +import createSdk from '@descope/web-js-sdk'; import { CONFIG_FILENAME, ELEMENTS_TO_IGNORE_ENTER_KEY_ON, @@ -10,37 +11,39 @@ import { import { camelCase, clearRunIdsFromUrl, - fetchContent, - getContentUrl, getRunIdsFromUrl, handleUrlParams, State, withMemCache, } from '../helpers'; +import { + extractNestedAttribute, + transformFlowInputFormData, +} from '../helpers/flowInputs'; import { IsChanged } from '../helpers/state'; import { formMountMixin } from '../mixins'; import { AutoFocusOptions, DebuggerMessage, DebugState, - FlowState, - FlowStateUpdateFn, - SdkConfig, DescopeUI, - ProjectConfiguration, FlowConfig, + FlowState, + FlowStateUpdateFn, FlowStatus, + ProjectConfiguration, + SdkConfig, } from '../types'; import initTemplate from './initTemplate'; -import { - extractNestedAttribute, - transformFlowInputFormData, -} from '../helpers/flowInputs'; // this is replaced in build time declare const BUILD_VERSION: string; -const BaseClass = compose(themeMixin, formMountMixin)(HTMLElement); +const BaseClass = compose( + themeMixin, + formMountMixin, + staticResourcesMixin, +)(HTMLElement); // this base class is responsible for WC initialization class BaseDescopeWc extends BaseClass { @@ -298,14 +301,12 @@ class BaseDescopeWc extends BaseClass { } async #isPrevVerConfig() { - const prevVerConfigUrl = getContentUrl({ - projectId: this.projectId, - filename: CONFIG_FILENAME, - assetsFolder: PREV_VER_ASSETS_FOLDER, - baseUrl: this.baseStaticUrl, - }); try { - await fetchContent(prevVerConfigUrl, 'json'); + await this.fetchStaticResource( + CONFIG_FILENAME, + 'json', + PREV_VER_ASSETS_FOLDER, + ); return true; } catch (e) { return false; @@ -314,13 +315,11 @@ class BaseDescopeWc extends BaseClass { // we want to get the config only if we don't have it already getConfig = withMemCache(async () => { - const configUrl = getContentUrl({ - projectId: this.projectId, - filename: CONFIG_FILENAME, - baseUrl: this.baseStaticUrl, - }); try { - const { body, headers } = await fetchContent(configUrl, 'json'); + const { body, headers } = await this.fetchStaticResource( + CONFIG_FILENAME, + 'json', + ); return { projectConfig: body as ProjectConfiguration, executionContext: { geo: headers['x-geo'] }, diff --git a/packages/sdks/web-component/src/lib/descope-wc/DescopeWc.ts b/packages/sdks/web-component/src/lib/descope-wc/DescopeWc.ts index d0fc714ff..091f7762e 100644 --- a/packages/sdks/web-component/src/lib/descope-wc/DescopeWc.ts +++ b/packages/sdks/web-component/src/lib/descope-wc/DescopeWc.ts @@ -1,6 +1,6 @@ import { - ensureFingerprintIds, clearFingerprintData, + ensureFingerprintIds, } from '@descope/web-js-sdk'; import { CUSTOM_INTERACTIONS, @@ -15,30 +15,28 @@ import { URL_TOKEN_PARAM_NAME, } from '../constants'; import { - fetchContent, + clearPreviousExternalInputs, getAnimationDirection, - getContentUrl, getElementDescopeAttributes, + getFirstNonEmptyValue, + getUserLocale, handleAutoFocus, + handleReportValidityOnBlur, injectSamlIdpForm, isConditionalLoginSupported, - updateScreenFromScreenState, - updateTemplateFromScreenState, + leadingDebounce, setTOTPVariable, showFirstScreenOnExecutionInit, State, submitForm, - withMemCache, - getFirstNonEmptyValue, - leadingDebounce, - handleReportValidityOnBlur, - getUserLocale, - clearPreviousExternalInputs, timeoutPromise, + updateScreenFromScreenState, + updateTemplateFromScreenState, + withMemCache, } from '../helpers'; -import { calculateConditions, calculateCondition } from '../helpers/conditions'; -import { getLastAuth, setLastAuth } from '../helpers/lastAuth'; import { getABTestingKey } from '../helpers/abTestingKey'; +import { calculateCondition, calculateConditions } from '../helpers/conditions'; +import { getLastAuth, setLastAuth } from '../helpers/lastAuth'; import { IsChanged } from '../helpers/state'; import { disableWebauthnButtons, @@ -227,22 +225,22 @@ class DescopeWc extends BaseDescopeWc { return filenameWithLocale; } - async getPageContent(htmlUrl: string, htmlLocaleUrl: string) { - if (htmlLocaleUrl) { + async getPageContent(html: string, htmlLocale: string) { + if (htmlLocale) { // try first locale url, if can't get for some reason, fallback to the original html url (the one without locale) try { - const { body } = await fetchContent(htmlLocaleUrl, 'text'); + const { body } = await this.fetchStaticResource(htmlLocale, 'text'); return body; } catch (ex) { this.loggerWrapper.error( - `Failed to fetch flow page from ${htmlLocaleUrl}. Fallback to url ${htmlUrl}`, + `Failed to fetch flow page from ${htmlLocale}. Fallback to url ${html}`, ex, ); } } try { - const { body } = await fetchContent(htmlUrl, 'text'); + const { body } = await this.fetchStaticResource(html, 'text'); return body; } catch (ex) { this.loggerWrapper.error(`Failed to fetch flow page`, ex.message); @@ -586,18 +584,8 @@ class DescopeWc extends BaseDescopeWc { name: this.sdk.getLastUserDisplayName() || loginId, }, }, - htmlUrl: getContentUrl({ - projectId, - filename: `${readyScreenId}.html`, - baseUrl: this.baseStaticUrl, - }), - htmlLocaleUrl: - filenameWithLocale && - getContentUrl({ - projectId, - filename: filenameWithLocale, - baseUrl: this.baseStaticUrl, - }), + html: `${readyScreenId}.html`, + htmlLocale: filenameWithLocale, samlIdpUsername, oidcLoginHint, oidcPrompt, @@ -962,17 +950,11 @@ class DescopeWc extends BaseDescopeWc { } async onStepChange(currentState: StepState, prevState: StepState) { - const { - htmlUrl, - htmlLocaleUrl, - direction, - next, - screenState, - openInNewTabUrl, - } = currentState; + const { html, htmlLocale, direction, next, screenState, openInNewTabUrl } = + currentState; const stepTemplate = document.createElement('template'); - stepTemplate.innerHTML = await this.getPageContent(htmlUrl, htmlLocaleUrl); + stepTemplate.innerHTML = await this.getPageContent(html, htmlLocale); const clone = stepTemplate.content.cloneNode(true) as DocumentFragment; @@ -1025,7 +1007,7 @@ class DescopeWc extends BaseDescopeWc { this.rootElement.replaceChildren(clone); // If before html url was empty, we deduce its the first time a screen is shown - const isFirstScreen = !prevState.htmlUrl; + const isFirstScreen = !prevState.html; // we need to wait for all components to render before we can set its value setTimeout(() => { diff --git a/packages/sdks/web-component/src/lib/helpers/helpers.ts b/packages/sdks/web-component/src/lib/helpers/helpers.ts index 0a2cb1016..7e9774b0c 100644 --- a/packages/sdks/web-component/src/lib/helpers/helpers.ts +++ b/packages/sdks/web-component/src/lib/helpers/helpers.ts @@ -1,27 +1,24 @@ import { - ASSETS_FOLDER, - BASE_CONTENT_URL, + APPLICATION_SCOPES_PARAM_NAME, DESCOPE_ATTRIBUTE_PREFIX, - URL_CODE_PARAM_NAME, - URL_ERR_PARAM_NAME, - URL_RUN_IDS_PARAM_NAME, - URL_TOKEN_PARAM_NAME, - URL_REDIRECT_AUTH_CHALLENGE_PARAM_NAME, - URL_REDIRECT_AUTH_CALLBACK_PARAM_NAME, - URL_REDIRECT_AUTH_BACKUP_CALLBACK_PARAM_NAME, - URL_REDIRECT_AUTH_INITIATOR_PARAM_NAME, + DESCOPE_IDP_INITIATED_PARAM_NAME, + OIDC_ERROR_REDIRECT_URI_PARAM_NAME, OIDC_IDP_STATE_ID_PARAM_NAME, + OIDC_LOGIN_HINT_PARAM_NAME, + OIDC_PROMPT_PARAM_NAME, SAML_IDP_STATE_ID_PARAM_NAME, SAML_IDP_USERNAME_PARAM_NAME, SSO_APP_ID_PARAM_NAME, - OIDC_LOGIN_HINT_PARAM_NAME, - DESCOPE_IDP_INITIATED_PARAM_NAME, - OVERRIDE_CONTENT_URL, - OIDC_PROMPT_PARAM_NAME, - OIDC_ERROR_REDIRECT_URI_PARAM_NAME, THIRD_PARTY_APP_ID_PARAM_NAME, THIRD_PARTY_APP_STATE_ID_PARAM_NAME, - APPLICATION_SCOPES_PARAM_NAME, + URL_CODE_PARAM_NAME, + URL_ERR_PARAM_NAME, + URL_REDIRECT_AUTH_BACKUP_CALLBACK_PARAM_NAME, + URL_REDIRECT_AUTH_CALLBACK_PARAM_NAME, + URL_REDIRECT_AUTH_CHALLENGE_PARAM_NAME, + URL_REDIRECT_AUTH_INITIATOR_PARAM_NAME, + URL_RUN_IDS_PARAM_NAME, + URL_TOKEN_PARAM_NAME, } from '../constants'; import { AutoFocusOptions, Direction, Locale, SSOQueryParams } from '../types'; @@ -57,43 +54,6 @@ function resetUrlParam(paramName: string) { } } -export async function fetchContent( - url: string, - returnType: T, -): Promise<{ - body: T extends 'json' ? Record : string; - headers: Record; -}> { - const res = await fetch(url, { cache: 'default' }); - if (!res.ok) { - throw Error(`Error fetching URL ${url} [${res.status}]`); - } - - return { - body: await res[returnType || 'text'](), - headers: Object.fromEntries(res.headers.entries()), - }; -} - -const pathJoin = (...paths: string[]) => paths.join('/').replace(/\/+/g, '/'); // preventing duplicate separators - -export function getContentUrl({ - projectId, - filename, - assetsFolder = ASSETS_FOLDER, - baseUrl, -}: { - projectId: string; - filename: string; - assetsFolder?: string; - baseUrl?: string; -}) { - const url = new URL(OVERRIDE_CONTENT_URL || baseUrl || BASE_CONTENT_URL); - url.pathname = pathJoin(url.pathname, projectId, assetsFolder, filename); - - return url.toString(); -} - export function getAnimationDirection( currentIdxStr: string, prevIdxStr: string, diff --git a/packages/sdks/web-component/src/lib/types.ts b/packages/sdks/web-component/src/lib/types.ts index f151b68ed..fe90d16a1 100644 --- a/packages/sdks/web-component/src/lib/types.ts +++ b/packages/sdks/web-component/src/lib/types.ts @@ -91,8 +91,8 @@ export type FlowState = { export type StepState = { screenState: ScreenState; - htmlUrl: string; - htmlLocaleUrl: string; + html: string; + htmlLocale: string; next: NextFn; direction: Direction | undefined; samlIdpUsername: string; diff --git a/packages/sdks/web-component/test/helpers/index.test.ts b/packages/sdks/web-component/test/helpers/index.test.ts index 558888749..bea1a96ac 100644 --- a/packages/sdks/web-component/test/helpers/index.test.ts +++ b/packages/sdks/web-component/test/helpers/index.test.ts @@ -3,7 +3,6 @@ import { URL_RUN_IDS_PARAM_NAME } from '../../src/lib/constants'; import { dragElement } from '../../src/lib/helpers'; import { clearRunIdsFromUrl, - fetchContent, getAnimationDirection, getRunIdsFromUrl, handleAutoFocus, @@ -16,44 +15,6 @@ const mockFetch = jest.fn(); global.fetch = mockFetch; describe('helpers', () => { - describe('fetchContent', () => { - it('should throw an error when got error response code', () => { - mockFetch.mockReturnValueOnce( - Promise.resolve({ - ok: false, - }), - ); - - expect(fetchContent('url', 'text')).rejects.toThrow(); - }); - it('should return the response text', () => { - mockFetch.mockReturnValueOnce( - Promise.resolve({ - ok: true, - text: () => 'text', - headers: new Headers({ h: '1' }), - }), - ); - - expect(fetchContent('url', 'text')).resolves.toMatchObject({ - body: 'text', - headers: { h: '1' }, - }); - }); - it('should cache the response', () => { - mockFetch.mockReturnValueOnce( - Promise.resolve({ - ok: true, - text: () => 'text', - headers: new Headers({ h: '1' }), - }), - ); - fetchContent('url', 'text'); - expect(mockFetch).toHaveBeenCalledWith(expect.any(String), { - cache: 'default', - }); - }); - }); it('getRunIds should return the correct query param value', () => { Object.defineProperty(window, 'location', { writable: true,