427 lines
14 KiB
JavaScript
427 lines
14 KiB
JavaScript
var Connection;
|
|
(function (Connection) {
|
|
Connection["Wifi"] = "wifi";
|
|
Connection["Cellular"] = "cellular";
|
|
Connection["Offline"] = "offline";
|
|
})(Connection || (Connection = {}));
|
|
var ContentType;
|
|
(function (ContentType) {
|
|
ContentType["Dice"] = "Dice";
|
|
ContentType["Source"] = "Source";
|
|
ContentType["Compendium"] = "Compendium";
|
|
ContentType["Single"] = "Single";
|
|
ContentType["Bundle"] = "Bundle";
|
|
})(ContentType || (ContentType = {}));
|
|
var Device;
|
|
(function (Device) {
|
|
Device["Tablet"] = "tablet";
|
|
Device["Smartphone"] = "smartphone";
|
|
Device["Unknown"] = "unknown";
|
|
})(Device || (Device = {}));
|
|
var Orientation;
|
|
(function (Orientation) {
|
|
Orientation["Portrait"] = "portrait";
|
|
Orientation["Landscape"] = "landscape";
|
|
Orientation["Unknown"] = "unknown";
|
|
})(Orientation || (Orientation = {}));
|
|
var Platform;
|
|
(function (Platform) {
|
|
Platform["Sigil"] = "Sigil";
|
|
Platform["Web"] = "Web";
|
|
Platform["MobileAndroid"] = "MobileAndroid";
|
|
Platform["MobileIos"] = "MobileIOS";
|
|
Platform["MobileWeb"] = "MobileWeb";
|
|
})(Platform || (Platform = {}));
|
|
var Region;
|
|
(function (Region) {
|
|
Region["UsEast1"] = "us-east-1";
|
|
Region["UsEast2"] = "us-east-2";
|
|
})(Region || (Region = {}));
|
|
var Source;
|
|
(function (Source) {
|
|
Source["Web"] = "ddb.web";
|
|
Source["Mobile"] = "ddb.mobile";
|
|
})(Source || (Source = {}));
|
|
var SubContentType;
|
|
(function (SubContentType) {
|
|
SubContentType["Family"] = "Family";
|
|
SubContentType["Set"] = "Set";
|
|
SubContentType["Rule"] = "Rule";
|
|
SubContentType["Adventure"] = "Adventure";
|
|
SubContentType["Map"] = "Map";
|
|
SubContentType["Legendary"] = "Legendary";
|
|
SubContentType["Source"] = "Source";
|
|
SubContentType["MagicItem"] = "MagicItem";
|
|
SubContentType["Monster"] = "Monster";
|
|
SubContentType["Feat"] = "Feat";
|
|
SubContentType["Background"] = "Background";
|
|
SubContentType["Subrace"] = "Subrace";
|
|
SubContentType["Class"] = "Class";
|
|
SubContentType["Subclass"] = "Subclass";
|
|
SubContentType["Spell"] = "Spell";
|
|
SubContentType["Vehicle"] = "Vehicle";
|
|
SubContentType["Race"] = "Race";
|
|
})(SubContentType || (SubContentType = {}));
|
|
var SubscriptionLevel;
|
|
(function (SubscriptionLevel) {
|
|
SubscriptionLevel["Visitor"] = "Visitor";
|
|
SubscriptionLevel["RegisteredUser"] = "Registered User";
|
|
SubscriptionLevel["Hero"] = "Hero";
|
|
SubscriptionLevel["Master"] = "Master";
|
|
SubscriptionLevel["Legendary"] = "Legendary";
|
|
})(SubscriptionLevel || (SubscriptionLevel = {}));
|
|
|
|
const byteToHex = [];
|
|
for (let i = 0; i < 256; ++i) {
|
|
byteToHex.push((i + 0x100).toString(16).slice(1));
|
|
}
|
|
function unsafeStringify(arr, offset = 0) {
|
|
return (byteToHex[arr[offset + 0]] +
|
|
byteToHex[arr[offset + 1]] +
|
|
byteToHex[arr[offset + 2]] +
|
|
byteToHex[arr[offset + 3]] +
|
|
'-' +
|
|
byteToHex[arr[offset + 4]] +
|
|
byteToHex[arr[offset + 5]] +
|
|
'-' +
|
|
byteToHex[arr[offset + 6]] +
|
|
byteToHex[arr[offset + 7]] +
|
|
'-' +
|
|
byteToHex[arr[offset + 8]] +
|
|
byteToHex[arr[offset + 9]] +
|
|
'-' +
|
|
byteToHex[arr[offset + 10]] +
|
|
byteToHex[arr[offset + 11]] +
|
|
byteToHex[arr[offset + 12]] +
|
|
byteToHex[arr[offset + 13]] +
|
|
byteToHex[arr[offset + 14]] +
|
|
byteToHex[arr[offset + 15]]).toLowerCase();
|
|
}
|
|
|
|
let getRandomValues;
|
|
const rnds8 = new Uint8Array(16);
|
|
function rng() {
|
|
if (!getRandomValues) {
|
|
if (typeof crypto === 'undefined' || !crypto.getRandomValues) {
|
|
throw new Error('crypto.getRandomValues() not supported. See https://github.com/uuidjs/uuid#getrandomvalues-not-supported');
|
|
}
|
|
getRandomValues = crypto.getRandomValues.bind(crypto);
|
|
}
|
|
return getRandomValues(rnds8);
|
|
}
|
|
|
|
const randomUUID = typeof crypto !== 'undefined' && crypto.randomUUID && crypto.randomUUID.bind(crypto);
|
|
var native = { randomUUID };
|
|
|
|
function _v4(options, buf, offset) {
|
|
options = options || {};
|
|
const rnds = options.random ?? options.rng?.() ?? rng();
|
|
if (rnds.length < 16) {
|
|
throw new Error('Random bytes length must be >= 16');
|
|
}
|
|
rnds[6] = (rnds[6] & 0x0f) | 0x40;
|
|
rnds[8] = (rnds[8] & 0x3f) | 0x80;
|
|
return unsafeStringify(rnds);
|
|
}
|
|
function v4(options, buf, offset) {
|
|
if (native.randomUUID && true && !options) {
|
|
return native.randomUUID();
|
|
}
|
|
return _v4(options);
|
|
}
|
|
|
|
function flattenPlugins(event) {
|
|
const newObj = {};
|
|
Object.keys(event).forEach((key) => {
|
|
if (key === "plugins" && Array.isArray(event[key])) {
|
|
event[key].forEach((plugin) => {
|
|
Object.keys(plugin).forEach((pluginKey) => {
|
|
if (newObj[pluginKey]) {
|
|
throw new Error(`duplicate key ${pluginKey} attempting to be set!`);
|
|
}
|
|
else {
|
|
newObj[pluginKey] = plugin[pluginKey];
|
|
}
|
|
});
|
|
});
|
|
}
|
|
else {
|
|
newObj[key] = event[key];
|
|
}
|
|
});
|
|
return newObj;
|
|
}
|
|
|
|
// Regexps involved with splitting words in various case formats.
|
|
const SPLIT_LOWER_UPPER_RE = /([\p{Ll}\d])(\p{Lu})/gu;
|
|
const SPLIT_UPPER_UPPER_RE = /(\p{Lu})([\p{Lu}][\p{Ll}])/gu;
|
|
// Used to iterate over the initial split result and separate numbers.
|
|
const SPLIT_SEPARATE_NUMBER_RE = /(\d)\p{Ll}|(\p{L})\d/u;
|
|
// Regexp involved with stripping non-word characters from the result.
|
|
const DEFAULT_STRIP_REGEXP = /[^\p{L}\d]+/giu;
|
|
// The replacement value for splits.
|
|
const SPLIT_REPLACE_VALUE = "$1\0$2";
|
|
// The default characters to keep after transforming case.
|
|
const DEFAULT_PREFIX_SUFFIX_CHARACTERS = "";
|
|
/**
|
|
* Split any cased input strings into an array of words.
|
|
*/
|
|
function split(value) {
|
|
let result = value.trim();
|
|
result = result
|
|
.replace(SPLIT_LOWER_UPPER_RE, SPLIT_REPLACE_VALUE)
|
|
.replace(SPLIT_UPPER_UPPER_RE, SPLIT_REPLACE_VALUE);
|
|
result = result.replace(DEFAULT_STRIP_REGEXP, "\0");
|
|
let start = 0;
|
|
let end = result.length;
|
|
// Trim the delimiter from around the output string.
|
|
while (result.charAt(start) === "\0")
|
|
start++;
|
|
if (start === end)
|
|
return [];
|
|
while (result.charAt(end - 1) === "\0")
|
|
end--;
|
|
return result.slice(start, end).split(/\0/g);
|
|
}
|
|
/**
|
|
* Split the input string into an array of words, separating numbers.
|
|
*/
|
|
function splitSeparateNumbers(value) {
|
|
const words = split(value);
|
|
for (let i = 0; i < words.length; i++) {
|
|
const word = words[i];
|
|
const match = SPLIT_SEPARATE_NUMBER_RE.exec(word);
|
|
if (match) {
|
|
const offset = match.index + (match[1] ?? match[2]).length;
|
|
words.splice(i, 1, word.slice(0, offset), word.slice(offset));
|
|
}
|
|
}
|
|
return words;
|
|
}
|
|
/**
|
|
* Convert a string to constant case (`FOO_BAR`).
|
|
*/
|
|
function constantCase(input, options) {
|
|
const [prefix, words, suffix] = splitPrefixSuffix(input, options);
|
|
return (prefix +
|
|
words.map(upperFactory(options?.locale)).join("_") +
|
|
suffix);
|
|
}
|
|
function upperFactory(locale) {
|
|
return locale === false
|
|
? (input) => input.toUpperCase()
|
|
: (input) => input.toLocaleUpperCase(locale);
|
|
}
|
|
function splitPrefixSuffix(input, options = {}) {
|
|
const splitFn = options.split ?? (options.separateNumbers ? splitSeparateNumbers : split);
|
|
const prefixCharacters = options.prefixCharacters ?? DEFAULT_PREFIX_SUFFIX_CHARACTERS;
|
|
const suffixCharacters = options.suffixCharacters ?? DEFAULT_PREFIX_SUFFIX_CHARACTERS;
|
|
let prefixIndex = 0;
|
|
let suffixIndex = input.length;
|
|
while (prefixIndex < input.length) {
|
|
const char = input.charAt(prefixIndex);
|
|
if (!prefixCharacters.includes(char))
|
|
break;
|
|
prefixIndex++;
|
|
}
|
|
while (suffixIndex > prefixIndex) {
|
|
const index = suffixIndex - 1;
|
|
const char = input.charAt(index);
|
|
if (!suffixCharacters.includes(char))
|
|
break;
|
|
suffixIndex = index;
|
|
}
|
|
return [
|
|
input.slice(0, prefixIndex),
|
|
splitFn(input.slice(prefixIndex, suffixIndex)),
|
|
input.slice(suffixIndex),
|
|
];
|
|
}
|
|
|
|
function transformKeys(object, transformedObject = {}) {
|
|
Object.entries(object).forEach(([key, value]) => {
|
|
if (Array.isArray(value)) {
|
|
value = object[key].map((item) => typeof item === "object" ? transformKeys(item) : item);
|
|
}
|
|
else if (typeof value === "object") {
|
|
value = value ? transformKeys(value) : value;
|
|
}
|
|
transformedObject[constantCase(key)] = value ?? null;
|
|
});
|
|
return transformedObject;
|
|
}
|
|
|
|
const createEventEntry = ({ detail, detailType, eventBusName, source, }) => {
|
|
const eventTime = new Date();
|
|
const fullDetail = {
|
|
...detail,
|
|
messageId: v4(),
|
|
eventTime: eventTime.toISOString(),
|
|
};
|
|
const flattenedDetail = flattenPlugins(fullDetail);
|
|
const transformedDetail = transformKeys(flattenedDetail);
|
|
return {
|
|
Detail: JSON.stringify(transformedDetail),
|
|
DetailType: detailType,
|
|
EventBusName: eventBusName,
|
|
Source: source,
|
|
Time: eventTime,
|
|
};
|
|
};
|
|
|
|
const visitorId = "ddb_vid";
|
|
const sessionId = "ddb_sid";
|
|
|
|
/**
|
|
* Checks if the environment is a test environment.
|
|
* This is useful to avoid setting secure cookies in test environments
|
|
* where the `window` object may not be available or secure cookies are not needed.
|
|
*/
|
|
const isTestEnv = () => {
|
|
return (typeof process !== "undefined" &&
|
|
process.env &&
|
|
(process.env.NODE_ENV === "test" || process.env.VITEST));
|
|
};
|
|
const getCookie = (name) => document.cookie.match(`${name}=([^;]*)`)?.[1];
|
|
const setCookie = (name, value, opts) => {
|
|
const parts = [`${name}=${value}`, `max-age=${opts["max-age"]}`];
|
|
if (opts.secure && !isTestEnv()) {
|
|
parts.push("secure", "domain=.dndbeyond.com", "path=/");
|
|
}
|
|
return (document.cookie = parts.join(";"));
|
|
};
|
|
const deleteCookie = (name) => setCookie(name, "", { "max-age": 0, secure: !isTestEnv() });
|
|
const createSessionAndVisitorCookies = () => {
|
|
if (!getCookie(visitorId)) {
|
|
setCookie(visitorId, v4(), {
|
|
"max-age": 60 * 60 * 24 * 365, // 1 year
|
|
secure: !isTestEnv(),
|
|
});
|
|
}
|
|
if (!getCookie(sessionId)) {
|
|
setCookie(sessionId, v4(), {
|
|
"max-age": 60 * 60 * 4, // 4 hours
|
|
secure: !isTestEnv(),
|
|
});
|
|
}
|
|
else {
|
|
extendSessionCookie();
|
|
}
|
|
};
|
|
const initializeConsentCookieListener = (consentCallback) => {
|
|
const maxRetries = 10;
|
|
const retryInterval = 500;
|
|
let retries = 0;
|
|
let consentCallbackFulfilled = false;
|
|
const tryInitialize = () => {
|
|
if ("window" in globalThis && window.ketch) {
|
|
/**
|
|
* n.b. This callback will return immediately if the response is already available,
|
|
* so this acts as both an initialization and a listener for future consent changes.
|
|
*/
|
|
window.ketch("on", "consent", (consentData) => {
|
|
if (!consentCallbackFulfilled) {
|
|
consentCallbackFulfilled = true;
|
|
consentCallback?.(consentData.purposes || null);
|
|
}
|
|
if (!consentData.purposes.analytics) {
|
|
deleteCookie(visitorId);
|
|
deleteCookie(sessionId);
|
|
}
|
|
else {
|
|
createSessionAndVisitorCookies();
|
|
}
|
|
});
|
|
// For when the Ketch response never comes, we attempt a final callback
|
|
setTimeout(() => {
|
|
if (!consentCallbackFulfilled) {
|
|
console.warn("Ketch callback never fired, invoking fallback.");
|
|
consentCallbackFulfilled = true;
|
|
consentCallback?.(window.ketchConsent || null);
|
|
}
|
|
}, 5_000);
|
|
}
|
|
else if (retries < maxRetries) {
|
|
retries++;
|
|
setTimeout(tryInitialize, retryInterval);
|
|
}
|
|
else {
|
|
console.warn("Failed to initialize session cookies.");
|
|
consentCallbackFulfilled = true;
|
|
consentCallback?.(null);
|
|
}
|
|
};
|
|
tryInitialize();
|
|
};
|
|
const extendSessionCookie = () => {
|
|
const session = getCookie(sessionId);
|
|
if (session) {
|
|
setCookie(sessionId, session, {
|
|
"max-age": 60 * 60 * 4, // 4 hours
|
|
secure: !isTestEnv(),
|
|
});
|
|
}
|
|
};
|
|
|
|
class EventPipeline {
|
|
region;
|
|
/**
|
|
* Initializes the EventPipeline with the specified region and optional consent callback.
|
|
* The consent callback will return with valid consent data or null if Ketch is not available.
|
|
*
|
|
* @param region - The region for the event pipeline.
|
|
* @param consentCallback - Optional callback to handle Ketch consent data.
|
|
*/
|
|
constructor(region, consentCallback) {
|
|
this.region = region;
|
|
try {
|
|
initializeConsentCookieListener(consentCallback);
|
|
}
|
|
catch (error) {
|
|
console.warn("Failed to initialize session cookies", error);
|
|
}
|
|
}
|
|
async sendEvent(detail, source) {
|
|
try {
|
|
try {
|
|
extendSessionCookie();
|
|
}
|
|
catch (error) {
|
|
console.warn("Failed to extend session cookie", error);
|
|
}
|
|
const entry = createEventEntry({
|
|
detail,
|
|
source,
|
|
});
|
|
const response = await fetch(`https://${this.region === Region.UsEast2 ? "test-" : ""}api.dndbeyond.com/bi/events`, {
|
|
body: JSON.stringify({
|
|
Entries: [entry],
|
|
}),
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
},
|
|
method: "POST",
|
|
});
|
|
const result = {
|
|
success: response.ok,
|
|
...(response.ok
|
|
? {}
|
|
: {
|
|
errorMessage: `Send to event pipeline failed (client-bi-event)`,
|
|
}),
|
|
};
|
|
response.ok || console.error(result.errorMessage);
|
|
return result;
|
|
}
|
|
catch (error) {
|
|
console.error(error);
|
|
return {
|
|
success: false,
|
|
errorMessages: [error.message],
|
|
};
|
|
}
|
|
}
|
|
}
|
|
|
|
export { EventPipeline };
|