salsify-experiences-sdk
Version:
SDK to be used by commerce websites to implement product experiences.
362 lines (327 loc) • 12.2 kB
text/typescript
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
}
}