UNPKG

salsify-experiences-sdk

Version:

SDK to be used by commerce websites to implement product experiences.

362 lines (327 loc) 12.2 kB
import request, { Header } from '../utils/request' import SdkSettings from '../settings' import { Logger } from '../utils/logger' import { Context } from '../api' import { inBrowser } from '../utils/runtime' import { PerProductConfigCache, selectSource, PerProductConfig } from './perProductConfig' const iframeId = 'salsify-ec-iframe' const prodCdnOrigin = 'https://salsify-ecdn.com' const stagingCdnOrigin = 'https://staging.salsify-ecdn.com' const testEnvCdnPattern = /^https:\/\/.+.test\.salsify\.com/ const cdnPrefix = 'sdk' // The message types also used in enhanced-content-renderer for posting messages const messageTypes = { heightUpdateRequest: 'heightUpdateRequest', contextRequest: 'contextRequest', } const defaultSource = 'index.html' let attachedIframeEventListener = false function messageFromSalsifyCDN(event: MessageEvent): boolean { return event.origin === prodCdnOrigin || event.origin === stagingCdnOrigin || testEnvCdnPattern.test(event.origin) } const attachIframeResizeListener = (logger: Logger): void => { if (attachedIframeEventListener) { return } window.addEventListener('message', event => { if (!messageFromSalsifyCDN(event)) { return } // if `messageType` isn't defined, it means the EC is an older version and sent // a height update request without the type included, so we need to handle it if (event.data.messageType && event.data.messageType !== messageTypes.heightUpdateRequest) { return } const selector = `#${iframeId}` const iframe = document.querySelector(selector) as HTMLIFrameElement if (iframe) { iframe.height = event.data.height } else { logger.log('error', { errorContext: 'iframeResizeListener', errorType: 'dom', errorMessage: `Could not find iframe with selector ${selector}`, }) } }) attachedIframeEventListener = true } /** * MessageEventSource can be one of the following: WindowProxy, MessagePort, or ServiceWorker. * For some browsers, WindowProxy is not defined, and its not equal to Window. * The postMessage method for them has different method argument. * WindowProxy have a second argument for targetOrigin, while other two's second argment is WindowPostMessageOption Object. * Therefore, we will want to know what the source type is so we can call the postMessage method correctly. */ function messageEventSourceIsWindow(source: MessageEventSource): source is Window { return ( (!('MessagePort' in window) || !(source instanceof MessagePort)) && (!('ServiceWorker' in window) || !(source instanceof ServiceWorker)) && !!source.postMessage ) } /** @internal */ export const attachIframeContextListener = (context: Context): void => { window.addEventListener('message', event => { if (!event.source || !messageFromSalsifyCDN(event)) { return } if (event.data.messageType !== messageTypes.contextRequest) { return } // If event has channel ports, post the messages back via the ports. if (event.ports.length) { event.ports.forEach(port => { port.postMessage(context) }) } else if (messageEventSourceIsWindow(event.source)) { event.source.postMessage(context, event.origin) } else { event.source.postMessage(context) } }) } /** @internal */ export interface EnhancedContentApiOptions { beforeRender?: (productId: string, idType?: string) => void afterRender?: (productId: string, idType?: string) => void } /** @internal */ export interface EcRenderConfig { productId: string idType: string content: PerProductConfig['content'] | null allContentExists: boolean source: string | null sourceExists: boolean } /** * The Enhanced Content API. * * This API is used to check for the existence of and to render Enhanced Content. */ export default class EnhancedContentApi { #perProductConfigCache: PerProductConfigCache #existsCache = new Map<string, boolean>() #lastRenderConfig?: EcRenderConfig #settings: SdkSettings #context: Context #logger: Logger #options?: EnhancedContentApiOptions /** @internal */ public constructor(settings: SdkSettings, context: Context, logger: Logger, options?: EnhancedContentApiOptions) { this.#settings = settings this.#context = context this.#logger = logger this.#options = options this.#perProductConfigCache = new PerProductConfigCache(logger) } /** * Checks if Enhaned Content exists for the given combination of `productId` and `idType` * * This is an asynchronous request and uses a promise-based API. You must wait for the promise to resolve and use the * _resolved_ value to determine existence of EC. This can be done using async/await or a `then` callback. * * @example * Using `async/await`: * ```typescript * const ecExists = await salsify.enhancedContent.exists("123", "SKU"); * if (ecExists) { * // ...render EC * } * ``` * * @example * Using a `then` callback: * ```typescript * salsify.enhancedContent.exists("123", "SKU").then((ecExists) => { * if (ecExists) { * // ...render EC * } * }); * ``` * * @param productId The string ID for the product. * @param idType The identifier type for the product; defaults to the value set {@link "api".SdkApi.init | on `init`}. * @returns `true` if the product has Enhanced Content to display, `false` otherwise */ public async exists(productId: string, idType?: string): Promise<boolean> { idType = this.#checkAndGetIdType(idType) const cdnPath = this.#buildCdnPath(productId, idType) const config = await this.#perProductConfigCache.getConfig(cdnPath) const source = this.#getSource(config, cdnPath, productId) const content = config?.content ?? null const allContentExists = await this.#checkAllContentExists(config?.content, cdnPath) const sourceExists = !!source && (await this.#checkExists(cdnPath, source)) this.#lastRenderConfig = { idType, productId, content, allContentExists, source, sourceExists, } this.#logger.log('ec_exists', this.#lastRenderConfig) return sourceExists } /** * Renders Enhanced Content for the given `productId` and `idType` into an * iFrame that is inserted into the provided `container` element. * * This can only be called in a browser runtime context. * * The Salsify SDK is responsible for creating the `IFrame` element and synchronizing the * height based on content changes. * * @example * ```javascript * const salsify = window.salsifyExperiencesSdk; * const element = document.getElementById('enhanced-content-container'); * salsify.enhancedContent.renderIframe(element, productId, idType); * ``` * * @param container The DOM element within which to render Enhanced Content. * @param productId The string ID for the product. * @param idType The identifier type for the product; defaults to the value set {@link "api".SdkApi.init | on `init`}. */ public async renderIframe(container: HTMLElement, productId: string, idType?: string): Promise<void> { if (!inBrowser()) { throw new Error('Can only render EC iframe in a browser runtime context.') } if (this.#options?.beforeRender) { this.#options.beforeRender(productId, idType) } idType = this.#checkAndGetIdType(idType) const cdnPath = this.#buildCdnPath(productId, idType) const config = await this.#perProductConfigCache.getConfig(cdnPath) const source = this.#getSource(config, cdnPath, productId) if (source !== null) { attachIframeResizeListener(this.#logger) const iframe = this.#createIframe() iframe.src = this.#buildContentUrl(cdnPath, source) iframe.title = 'Salsify Enhanced Content' container.appendChild(iframe) } const content = config?.content ?? null const allContentExists = await this.#checkAllContentExists(config?.content, cdnPath) const sourceExists = !!source && (await this.#checkExists(cdnPath, source)) this.#lastRenderConfig = { idType, productId, content, allContentExists, source, sourceExists, } this.#logger.log('ec_render_iframe', this.#lastRenderConfig) this.#options?.afterRender?.(productId, idType) } /** * Updates the language code for subsequent Enhanced Content requests. * * This method can be used when a user updates the page language, and will * change the language of the content _without re-rendering_. * * To update the currently displayed content, the client application will * need to re-render the content after calling this method. * * @example * ```javascript * salsify.enhancedContent.updateLanguageCode('fr-CA'); * * // this call now uses the new language code, "fr-CA" * salsify.enhancedContent.exists(productId, idType); * ``` * * @param languageCode The language code to use for subsequent calls. */ public updateLanguageCode(languageCode: string): void { this.#settings = { ...this.#settings, languageCode } this.#logger.log('ec_update_language_code', { languageCode }) } /** @internal */ public get lastRenderConfig(): EcRenderConfig | undefined { return this.#lastRenderConfig } #checkAndGetIdType(idType?: string): string { if (idType) { return idType } if (this.#settings.enhancedContent.idType) { return this.#settings.enhancedContent.idType } throw new Error('No ID type specified.') } #createIframe(): HTMLIFrameElement { const iframe: HTMLIFrameElement = document.createElement('iframe') iframe.id = iframeId iframe.height = '0' iframe.width = '100%' iframe.style.border = '0' iframe.scrolling = 'no' return iframe } #buildCdnOrigin(): string { if (this.#settings.cdnRoot && testEnvCdnPattern.test(this.#settings.cdnRoot)) { return this.#settings.cdnRoot } else if (this.#settings.staging) { return stagingCdnOrigin } else { return prodCdnOrigin } } #buildCdnPath(productId: string, idType: string): string { const cdnOrigin = this.#buildCdnOrigin() return `${cdnOrigin}/${cdnPrefix}/${this.#settings.clientId}/${this.#settings.languageCode}/BTF/${idType}/${productId}` } #buildContentUrl(cdnPath: string, source = defaultSource): string { return `${cdnPath}/${source}` } #getSource(config: PerProductConfig | undefined, cdnPath: string, productId: string): string | null { if (config && this.#settings.tracking) { const { content } = config const { sessionId } = this.#context if (sessionId) { const selectedSource = selectSource({ productId, content, sessionId }) return selectedSource.source } } return defaultSource } async #checkExists(cdnPath: string, source: string): Promise<boolean> { const cacheKey = `${cdnPath}/${source}` if (!this.#existsCache.has(cacheKey)) { const contentUrl = this.#buildContentUrl(cdnPath, source) let response: Response try { response = await request.head(contentUrl) } catch (error) { const errorMessage = error instanceof Error ? error.message : undefined this.#logger.log('error', { errorContext: 'exists', errorType: 'fetch', errorMessage: `Error on HEAD request of ${contentUrl}: ${errorMessage}`, }) return false } this.#existsCache.set(cacheKey, response.headers.get(Header.ContentLength) !== '0') } return this.#existsCache.get(cacheKey) || false } async #checkAllContentExists(content: PerProductConfig['content'] | undefined, cdnPath: string): Promise<boolean> { if (!content) { return false } let hasNonNullSource = false for (const src of content) { if (src.source) { hasNonNullSource = true const sourceExists = await this.#checkExists(cdnPath, src.source) if (!sourceExists) { return false } } } return hasNonNullSource } }