UNPKG

salsify-experiences-sdk

Version:

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

157 lines (145 loc) 5.27 kB
import request, { Header } from '../utils/request' import { murmurHash3 } from '../utils/hash' import { Logger, ErrorProperties } from '../utils/logger' export type PerProductConfig = { content: Array<Source> } type Source = { source: string | null weight: number } export function isPerProductConfig(config: unknown, logger?: Logger, url?: string): config is PerProductConfig { const errProps: Pick<ErrorProperties, 'errorType' | 'errorContext'> = { errorType: 'validation', errorContext: 'per-product config', } if (typeof config !== 'object' || config === null) { logger?.log('error', { ...errProps, errorMessage: `${url} does not contain an object` }) return false } if (!('content' in config)) { logger?.log('error', { ...errProps, errorMessage: `${url} does not contain a 'content' key` }) return false } if (!Array.isArray(config.content)) { logger?.log('error', { ...errProps, errorMessage: `'content' in ${url} is not an array` }) return false } if (config.content.length === 0) { logger?.log('error', { ...errProps, errorMessage: `'content' array in ${url} has length 0` }) return false } if (!config.content.every(source => isSource(source, logger, url))) { return false } if (Math.abs(1 - config.content.reduce((sumOfWeights, source) => (sumOfWeights += source.weight), 0)) > 0.001) { logger?.log('error', { ...errProps, errorMessage: `sum of source weights in 'content' in ${url} does not equal 1` }) return false } return true } function isSource(thing: unknown, logger?: Logger, url?: string): thing is Source { const errProps: Pick<ErrorProperties, 'errorType' | 'errorContext'> = { errorType: 'validation', errorContext: 'per-product config', } if (typeof thing !== 'object' || thing === null) { logger?.log('error', { ...errProps, errorMessage: `'content' in ${url} contains a source that is not an object` }) return false } if (!('source' in thing)) { logger?.log('error', { ...errProps, errorMessage: `'content' in ${url} contains a source that is missing a 'source' key`, }) return false } if (typeof thing.source !== 'string' && thing.source !== null) { logger?.log('error', { ...errProps, errorMessage: `'content' in ${url} contains a source with an invalid 'source' value`, }) return false } if (!('weight' in thing)) { logger?.log('error', { ...errProps, errorMessage: `'content' in ${url} contains a source that is missing a 'weight' key`, }) return false } if (typeof thing.weight !== 'number') { logger?.log('error', { ...errProps, errorMessage: `'content' in ${url} contains a source with an invalid 'weight' value`, }) return false } return true } interface SelectSourceArgs { productId: string content: Array<Source> sessionId: string } export function selectSource(args: SelectSourceArgs): Source { const contentString = JSON.stringify(args.content) const hashString = `${args.productId}${contentString}${args.sessionId}` const hash = murmurHash3(hashString, 0) // divde by max 32 bit integer to scale to 0..1 const scaledHash = hash / 0xffffffff // use scaled hash as a "random" number in weighted random selection let sum = 0 const cumulativeWeights = args.content.map(source => (sum += source.weight)) const index = cumulativeWeights.findIndex(cWeight => cWeight >= scaledHash) return args.content[index] } export class PerProductConfigCache { #cache = new Map<string, PerProductConfig | undefined>() #logger: Logger /** @internal */ public constructor(logger: Logger) { this.#logger = logger } public async getConfig(cdnPath: string): Promise<PerProductConfig | undefined> { return this.#cache.has(cdnPath) ? this.#cache.get(cdnPath) : this.#fetchConfig(cdnPath) } async #fetchConfig(cdnPath: string): Promise<PerProductConfig | undefined> { const configUrl = `${cdnPath}/config.json` let response: Response try { response = await request.get(configUrl) } catch (error) { const errorMessage = error instanceof Error ? error.message : undefined this.#logger.log('error', { errorContext: 'per-product config', errorType: 'fetch', errorMessage: `Error fetching ${configUrl}: ${errorMessage}`, }) return undefined } if (response.headers.get(Header.ContentLength) === '0') { return this.#cacheAndReturn(cdnPath, undefined) } let data: unknown try { data = await response.json() } catch (error) { const errorMessage = error instanceof Error ? error.message : undefined this.#logger.log('error', { errorContext: 'per-product config', errorType: 'parse', errorMessage: `Error parsing ${configUrl}: ${errorMessage}`, }) return this.#cacheAndReturn(cdnPath, undefined) } if (isPerProductConfig(data, this.#logger, configUrl)) { return this.#cacheAndReturn(cdnPath, data) } return this.#cacheAndReturn(cdnPath, undefined) } #cacheAndReturn<T extends PerProductConfig | undefined>(cdnPath: string, config: T): T { this.#cache.set(cdnPath, config) return config } }