salsify-experiences-sdk
Version:
SDK to be used by commerce websites to implement product experiences.
343 lines • 13.4 kB
JavaScript
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
__setModuleDefault(result, mod);
return result;
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.attachIframeContextListener = void 0;
const request_1 = __importStar(require("../utils/request"));
const runtime_1 = require("../utils/runtime");
const perProductConfig_1 = require("./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) {
return event.origin === prodCdnOrigin || event.origin === stagingCdnOrigin || testEnvCdnPattern.test(event.origin);
}
const attachIframeResizeListener = (logger) => {
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);
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) {
return ((!('MessagePort' in window) || !(source instanceof MessagePort)) &&
(!('ServiceWorker' in window) || !(source instanceof ServiceWorker)) &&
!!source.postMessage);
}
/** @internal */
const attachIframeContextListener = (context) => {
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);
}
});
};
exports.attachIframeContextListener = attachIframeContextListener;
/**
* The Enhanced Content API.
*
* This API is used to check for the existence of and to render Enhanced Content.
*/
class EnhancedContentApi {
#perProductConfigCache;
#existsCache = new Map();
#lastRenderConfig;
#settings;
#context;
#logger;
#options;
/** @internal */
constructor(settings, context, logger, options) {
this.#settings = settings;
this.#context = context;
this.#logger = logger;
this.#options = options;
this.#perProductConfigCache = new perProductConfig_1.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
*/
async exists(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);
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`}.
*/
async renderIframe(container, productId, idType) {
if (!(0, runtime_1.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.
*/
updateLanguageCode(languageCode) {
this.#settings = { ...this.#settings, languageCode };
this.#logger.log('ec_update_language_code', { languageCode });
}
/** @internal */
get lastRenderConfig() {
return this.#lastRenderConfig;
}
#checkAndGetIdType(idType) {
if (idType) {
return idType;
}
if (this.#settings.enhancedContent.idType) {
return this.#settings.enhancedContent.idType;
}
throw new Error('No ID type specified.');
}
#createIframe() {
const iframe = document.createElement('iframe');
iframe.id = iframeId;
iframe.height = '0';
iframe.width = '100%';
iframe.style.border = '0';
iframe.scrolling = 'no';
return iframe;
}
#buildCdnOrigin() {
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, idType) {
const cdnOrigin = this.#buildCdnOrigin();
return `${cdnOrigin}/${cdnPrefix}/${this.#settings.clientId}/${this.#settings.languageCode}/BTF/${idType}/${productId}`;
}
#buildContentUrl(cdnPath, source = defaultSource) {
return `${cdnPath}/${source}`;
}
#getSource(config, cdnPath, productId) {
if (config && this.#settings.tracking) {
const { content } = config;
const { sessionId } = this.#context;
if (sessionId) {
const selectedSource = (0, perProductConfig_1.selectSource)({ productId, content, sessionId });
return selectedSource.source;
}
}
return defaultSource;
}
async #checkExists(cdnPath, source) {
const cacheKey = `${cdnPath}/${source}`;
if (!this.#existsCache.has(cacheKey)) {
const contentUrl = this.#buildContentUrl(cdnPath, source);
let response;
try {
response = await request_1.default.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(request_1.Header.ContentLength) !== '0');
}
return this.#existsCache.get(cacheKey) || false;
}
async #checkAllContentExists(content, cdnPath) {
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;
}
}
exports.default = EnhancedContentApi;
//# sourceMappingURL=index.js.map