@awell-health/navi-js
Version:
Navi.js loading utility - loads the Navi SDK from CDN
199 lines (162 loc) • 5.3 kB
text/typescript
import { Navi, NaviConstructor, NaviLoadOptions } from "./types";
export type LoadNavi = (
publishableKey: string,
options?: NaviLoadOptions
) => Promise<Navi | null>;
// `_VERSION` will be rewritten by `@rollup/plugin-replace` as a string literal
// containing the package.json version
declare const _VERSION: string;
// CDN configuration - GCP Cloud CDN
const getCDNConfig = (options?: NaviLoadOptions) => {
let origin = "https://cdn.awellhealth.com";
let embedOrigin = "https://navi-portal.awellhealth.com";
// Apply environment variables
if (
typeof process !== "undefined" &&
process.env.EMBED_ORIGIN !== undefined
) {
embedOrigin = process.env.EMBED_ORIGIN;
}
if (typeof process !== "undefined" && process.env.ORIGIN !== undefined) {
origin = process.env.ORIGIN;
}
// Apply explicit options (highest priority)
if (options?.origin) {
origin = options.origin;
}
if (options?.embedOrigin) {
embedOrigin = options.embedOrigin;
}
return {
origin,
embedOrigin,
};
};
// Development: alpha version for testing
// Production: will use versioned URLs later
const getNaviJSUrl = (options?: NaviLoadOptions) => {
const config = getCDNConfig(options);
return `${config.origin}/beta/navi.js`;
};
// Updated regex patterns for GCP CDN
const PRODUCTION_CDN_REGEX =
/^https:\/\/cdn\.awellhealth\.com\/(alpha|beta|v\d+.*\/)?navi\.js(\?.*)?$/;
const LOCALHOST_REGEX = /^http:\/\/localhost:3000\/(v1\/)?navi\.js(\?.*)?$/;
const isNaviJSURL = (url: string): boolean =>
PRODUCTION_CDN_REGEX.test(url) || LOCALHOST_REGEX.test(url);
export { isNaviJSURL, getNaviJSUrl, getCDNConfig };
export const findScript = (): HTMLScriptElement | null => {
const scripts = document.querySelectorAll<HTMLScriptElement>(
'script[src*="navi.js"]'
);
for (let i = 0; i < scripts.length; i++) {
const script = scripts[i];
if (!isNaviJSURL(script.src)) {
continue;
}
return script;
}
return null;
};
const injectScript = (options?: NaviLoadOptions): HTMLScriptElement => {
const script = document.createElement("script");
script.src = getNaviJSUrl(options);
const headOrBody = document.head || document.body;
if (!headOrBody) {
throw new Error(
"Expected document.body not to be null. Navi.js requires a <body> element."
);
}
headOrBody.appendChild(script);
return script;
};
let naviPromise: Promise<NaviConstructor | null> | null = null;
let onErrorListener: ((event?: Event) => void) | null = null;
let onLoadListener: (() => void) | null = null;
const onError =
(reject: (reason?: any) => void, scriptUrl: string) => (event?: Event) => {
const errorDetails = event
? `Script load error for ${scriptUrl}. Check network connection and URL.`
: `Unknown error loading ${scriptUrl}`;
reject(new Error(`Failed to load Navi.js: ${errorDetails}`));
};
const onLoad =
(
resolve: (
value: NaviConstructor | PromiseLike<NaviConstructor | null> | null
) => void,
reject: (reason?: any) => void
) =>
() => {
if (window.Navi) {
resolve(window.Navi);
} else {
reject(new Error("Navi.js not available"));
}
};
export const loadScript = (
options?: NaviLoadOptions
): Promise<NaviConstructor | null> => {
naviPromise = new Promise((resolve, reject) => {
if (typeof window === "undefined" || typeof document === "undefined") {
// Resolve to null when imported server side. This makes the module
// safe to import in an isomorphic code base.
resolve(null);
return;
}
try {
let script = findScript();
const desiredUrl = getNaviJSUrl(options);
// If a script already exists and its src matches desired, reuse it
if (script && script.src === desiredUrl && !options?.alwaysFetch) {
if (window.Navi) {
resolve(window.Navi);
return;
}
} else {
// No script, or src differs, or forced fetch → inject fresh script
if (script) {
script.parentNode?.removeChild(script);
}
script = injectScript(options);
}
onLoadListener = onLoad(resolve, reject);
onErrorListener = onError(reject, script.src);
script.addEventListener("load", onLoadListener);
script.addEventListener("error", onErrorListener);
} catch (error) {
reject(error);
return;
}
});
// Resets naviPromise on error
return naviPromise.catch((error) => {
naviPromise = null;
return Promise.reject(error);
});
};
export const initNavi = (
maybeNavi: NaviConstructor | null,
args: [string], // publishableKey
startTime: number,
options?: NaviLoadOptions
): Navi | null => {
if (maybeNavi === null) {
return null;
}
const publishableKey = args[0];
const isTestKey = publishableKey.match(/^pk_test/);
if (isTestKey && options?.verbose) {
console.log(`🚀 Navi.js loaded using test key: ${publishableKey}`);
}
// Convert navi-js options to navi.js options format
const naviJsOptions = options
? {
...getCDNConfig(options),
verbose: options.verbose,
alwaysFetch: options.alwaysFetch,
}
: undefined;
const navi = maybeNavi(publishableKey, naviJsOptions);
return navi;
};