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