diff --git a/README.md b/README.md index e3654d1..ff03381 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,26 @@ cd test && ./serve.sh Open `http://localhost:8000/test/index.html` to test all embed types. +## Custom Domains + +Add `data-custom-domain` to the Surface tag when an environment uses a verified +custom domain: + +```html + +``` + +The tag sends lead identification, journey tracking, external-form events, and +open-trigger requests to `https://demo.example.com/api/v1`. It also trusts form +iframe messages from `https://demo.example.com`. The value must be an HTTPS +hostname or origin without a path, query string, credentials, or fragment. If +the attribute is absent or invalid, the tag continues to use +`https://forms.withsurface.com`. + ## Embedding Types - **Popup** -- modal overlay triggered by button click diff --git a/src/conversions/conversion-listener.ts b/src/conversions/conversion-listener.ts index 69723da..0f027a9 100644 --- a/src/conversions/conversion-listener.ts +++ b/src/conversions/conversion-listener.ts @@ -35,7 +35,7 @@ const isConversionMessage = (data: any): data is ConversionMessage => // Handles a `surface:conversion` message from a Surface form iframe: fires the // pixel in this (parent) page, then acks so the iframe knows not to fall back to // in-frame firing. The caller guarantees the origin is already trusted (checked -// in the shared message listener against SURFACE_DOMAINS). +// in the shared message listener against the runtime Surface-domain allowlist). export const handleConversionMessage = (event: MessageEvent, log: Logger): void => { const data = event.data; if (!isConversionMessage(data)) return; diff --git a/src/external-form/external-form.ts b/src/external-form/external-form.ts index b9eb68a..4178ea0 100644 --- a/src/external-form/external-form.ts +++ b/src/external-form/external-form.ts @@ -1,9 +1,9 @@ -import { EXTERNAL_FORM_API } from "../constants"; import { isDebugMode } from "../utils/debug"; import { sendBeacon } from "../utils/beacon"; import { getSiteIdFromScript } from "../lead/site-id"; import { attachFormHandlers } from "./form-handlers"; import type { ExternalFormProps } from "../types"; +import { getSurfaceRuntimeConfig } from "../runtime-config"; export class SurfaceExternalForm { initialRenderTime: Date; @@ -27,7 +27,7 @@ export class SurfaceExternalForm { this.formStarted = {}; this.config = { - serverBaseUrl: props?.serverBaseUrl || EXTERNAL_FORM_API, + serverBaseUrl: props?.serverBaseUrl || getSurfaceRuntimeConfig().apiBaseUrl, debugMode: isDebugMode(), }; diff --git a/src/index.ts b/src/index.ts index 8e58896..c330749 100644 --- a/src/index.ts +++ b/src/index.ts @@ -10,13 +10,15 @@ import { SurfaceExternalForm } from "./external-form/external-form"; import { SurfaceEmbed } from "./embed/embed"; import { resolveOpenTriggersOnLoad } from "./open-triggers/open-triggers"; import { initReview } from "./review/review"; +import { initializeSurfaceRuntimeConfig } from "./runtime-config"; const scriptTag = document.currentScript as HTMLScriptElement; +const runtimeConfig = initializeSurfaceRuntimeConfig(scriptTag); const environmentId = getSiteIdFromScript(scriptTag); setEnvironmentId(environmentId); // Create singleton store -const SurfaceTagStore = new SurfaceStore(environmentId); +const SurfaceTagStore = new SurfaceStore(environmentId, runtimeConfig); // Expose public API on window (backwards compatible) const w = window as unknown as Record; @@ -30,7 +32,7 @@ w.SurfaceGetSiteIdFromScript = getSiteIdFromScript; // Auto-open a form when the host URL carries a configured `?=true` param. // Fire-and-forget; only touches the network when params are present. -void resolveOpenTriggersOnLoad(environmentId); +void resolveOpenTriggersOnLoad(environmentId, runtimeConfig); // Surface CMS review bridge. Inert unless the page is loaded inside the CMS // review iframe (?surface_review= token) — adds no listeners otherwise. diff --git a/src/lead/identify.ts b/src/lead/identify.ts index 9ba1aa2..b2b4c28 100644 --- a/src/lead/identify.ts +++ b/src/lead/identify.ts @@ -1,6 +1,10 @@ -import { LEAD_DATA_TTL, LEAD_IDENTIFY_API } from "../constants"; +import { LEAD_DATA_TTL } from "../constants"; import { getBrowserFingerprint } from "./fingerprint"; import type { LeadData } from "../types"; +import { + getSurfaceRuntimeConfig, + type SurfaceRuntimeConfig, +} from "../runtime-config"; let environmentId: string | null = null; let identifyInProgress = false; @@ -49,7 +53,8 @@ export function getLeadDataWithTTL(): LeadData | null { } export async function identifyLead( - envId: string + envId: string, + config: SurfaceRuntimeConfig = getSurfaceRuntimeConfig() ): Promise { if (identifyInProgress) { return waitForCachedData(); @@ -66,7 +71,7 @@ export async function identifyLead( const fingerprint = await getBrowserFingerprint(envId); const parentUrl = new URL(window.location.href); - const response = await fetch(LEAD_IDENTIFY_API, { + const response = await fetch(config.leadIdentifyApi, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ diff --git a/src/open-triggers/open-triggers.ts b/src/open-triggers/open-triggers.ts index de3f800..3bf620a 100644 --- a/src/open-triggers/open-triggers.ts +++ b/src/open-triggers/open-triggers.ts @@ -1,7 +1,10 @@ -import { EXTERNAL_FORM_API } from "../constants"; import { SurfaceEmbed } from "../embed/embed"; import { openTriggerOverlay } from "./open-trigger-overlay"; import { OpenTriggerEntry, OpenTriggersMap, pickOpenTrigger } from "./resolve"; +import { + getSurfaceRuntimeConfig, + type SurfaceRuntimeConfig, +} from "../runtime-config"; const SESSION_PREFIX = "surface_open_triggers:"; // Self-healing cache: re-fetch the map after this long so a slug retargeted/disabled @@ -30,12 +33,15 @@ interface OverridableWindow { * present as `?=true`. Opens a form even if it isn't already embedded on the * page. No params → zero network. Always fails safe (never breaks the host page). */ -export async function resolveOpenTriggersOnLoad(environmentId: string | null): Promise { +export async function resolveOpenTriggersOnLoad( + environmentId: string | null, + config: SurfaceRuntimeConfig = getSurfaceRuntimeConfig() +): Promise { try { if (!environmentId) return; if (!window.location.search) return; - const map = await fetchOpenTriggersMap(environmentId); + const map = await fetchOpenTriggersMap(environmentId, config); const entry = pickOpenTrigger(window.location.search, map); if (!entry) return; @@ -45,13 +51,16 @@ export async function resolveOpenTriggersOnLoad(environmentId: string | null): P } } -async function fetchOpenTriggersMap(environmentId: string): Promise { +async function fetchOpenTriggersMap( + environmentId: string, + config: SurfaceRuntimeConfig +): Promise { const w = window as unknown as OverridableWindow; // Test/escape hatch: a directly-injected map bypasses the network entirely. if (w.__SURFACE_OPEN_TRIGGERS_MAP) return w.__SURFACE_OPEN_TRIGGERS_MAP; - const sessionKey = SESSION_PREFIX + environmentId; + const sessionKey = `${SESSION_PREFIX}${config.apiBaseUrl}:${environmentId}`; try { const cached = sessionStorage.getItem(sessionKey); if (cached) { @@ -64,7 +73,7 @@ async function fetchOpenTriggersMap(environmentId: string): Promise { - if (!event.origin || !(SURFACE_DOMAINS as readonly string[]).includes(event.origin)) { + const surfaceDomains = store.surfaceDomains ?? SURFACE_DOMAINS; + if (!event.origin || !surfaceDomains.includes(event.origin)) { return; } @@ -19,7 +20,10 @@ export function initializeMessageListener(store: SurfaceStore): void { const envId = getEnvironmentId(); if (envId) { - identifyLead(envId) + const identify = store.config?.customOrigin + ? identifyLead(envId, store.config) + : identifyLead(envId); + identify .then(() => store.sendPayloadToIframes("LEAD_DATA_UPDATE")) .catch((e) => console.log("Failed identify", e)); } else { diff --git a/src/store/store.ts b/src/store/store.ts index acfe034..c8f7c9f 100644 --- a/src/store/store.ts +++ b/src/store/store.ts @@ -1,4 +1,4 @@ -import { SURFACE_DOMAINS, VALID_EMBED_TYPES } from "../constants"; +import { VALID_EMBED_TYPES } from "../constants"; import { isDebugMode } from "../utils/debug"; import { createLogger } from "../utils/logger"; import { parseCookies } from "../utils/cookies"; @@ -12,6 +12,10 @@ import { clearUserJourney as clearJourney, } from "./user-journey"; import type { Logger, StorePayload, PartialFilledData, LeadData } from "../types"; +import { + getSurfaceRuntimeConfig, + type SurfaceRuntimeConfig, +} from "../runtime-config"; export class SurfaceStore { windowUrl: string; @@ -28,9 +32,13 @@ export class SurfaceStore { userJourney: unknown[]; cachedIdentifyData: LeadData | null; environmentId: string | null; + config: SurfaceRuntimeConfig; log: Logger; - constructor(environmentId: string | null = null) { + constructor( + environmentId: string | null = null, + config: SurfaceRuntimeConfig = getSurfaceRuntimeConfig() + ) { this.windowUrl = new URL(window.location.href).toString(); this.origin = new URL(window.location.href).origin.toString(); this.referrer = document.referrer || ""; @@ -40,7 +48,8 @@ export class SurfaceStore { this.partialFilledData = {}; this.validEmbedTypes = VALID_EMBED_TYPES; this.debugMode = isDebugMode(); - this.surfaceDomains = SURFACE_DOMAINS; + this.config = config; + this.surfaceDomains = config.surfaceDomains; this.userJourneyId = null; this.userJourney = []; this.cachedIdentifyData = getLeadDataWithTTL(); @@ -63,7 +72,8 @@ export class SurfaceStore { // The journey id resolves async — iframes that already received a // STORE_UPDATE need a refresh to stitch this pageview. if (resolved) this.sendPayloadToIframes("STORE_UPDATE"); - } + }, + this.config ); this.setupRouteChangeDetection(); } @@ -78,7 +88,10 @@ export class SurfaceStore { if (!this.hasSurfaceIframe()) return; this.sendPayloadToIframes("STORE_UPDATE"); if (this.environmentId) { - identifyLead(this.environmentId) + const identify = this.config.customOrigin + ? identifyLead(this.environmentId, this.config) + : identifyLead(this.environmentId); + identify .then(() => this.sendPayloadToIframes("LEAD_DATA_UPDATE")) .catch((e) => this.log.error({ message: "Initial identify failed", error: e })); } else if (getLeadDataWithTTL()) { @@ -95,14 +108,18 @@ export class SurfaceStore { } private hasSurfaceIframe(): boolean { - return Array.from(document.querySelectorAll("iframe")).some((iframe) => - SURFACE_DOMAINS.some((domain) => iframe.src.includes(domain)) - ); + return Array.from(document.querySelectorAll("iframe")).some((iframe) => { + try { + return this.surfaceDomains.includes(new URL(iframe.src).origin); + } catch { + return false; + } + }); } private isCurrentOriginSurfaceDomain(): boolean { - const hostname = window.location?.hostname ?? ""; - return SURFACE_DOMAINS.some((url) => new URL(url).hostname === hostname); + const origin = window.location?.origin ?? ""; + return this.surfaceDomains.includes(origin); } private setupRouteChangeDetection(): void { @@ -120,7 +137,8 @@ export class SurfaceStore { // A journey created/refreshed during the route change resolves after // the push below — refresh iframes so they get the new id. if (resolved) this.sendPayloadToIframes("STORE_UPDATE"); - } + }, + this.config ); this.sendPayloadToIframes("STORE_UPDATE"); @@ -145,14 +163,17 @@ export class SurfaceStore { const target = iframe || document.querySelector("#surface-iframe"); if (!target) return; - SURFACE_DOMAINS.forEach((domain) => { - if (target.src.includes(domain)) { - target.contentWindow?.postMessage( - { type, payload: this.getPayload(), sender: "surface_tag" }, - domain - ); - } - }); + try { + const targetOrigin = new URL(target.src).origin; + if (!this.surfaceDomains.includes(targetOrigin)) return; + + target.contentWindow?.postMessage( + { type, payload: this.getPayload(), sender: "surface_tag" }, + targetOrigin + ); + } catch { + // Ignore invalid iframe URLs. + } } getUrlParams(): Record { diff --git a/src/store/user-journey.ts b/src/store/user-journey.ts index 874c206..874590b 100644 --- a/src/store/user-journey.ts +++ b/src/store/user-journey.ts @@ -1,7 +1,6 @@ import { SURFACE_USER_JOURNEY_COOKIE_NAME, SURFACE_USER_JOURNEY_RECENT_VISIT_COOKIE_NAME, - USER_JOURNEY_TRACKING_API, RECENT_VISIT_COOKIE_MAX_AGE, } from "../constants"; import { setCookie, getCookie, deleteCookie } from "../utils/cookies"; @@ -12,6 +11,10 @@ import { getExistingJourneyId, } from "./journey-cookies"; import type { LeadData, Logger, JourneyTrackEvent } from "../types"; +import { + getSurfaceRuntimeConfig, + type SurfaceRuntimeConfig, +} from "../runtime-config"; function getBrowserReferrer(): string { return typeof document === "undefined" ? "" : document.referrer || ""; @@ -52,7 +55,8 @@ export function initializeUserJourneyTracking( environmentId: string | null, log: Logger, getJourneyId: () => string | null, - setJourneyId: (id: string | null) => void + setJourneyId: (id: string | null) => void, + config: SurfaceRuntimeConfig = getSurfaceRuntimeConfig() ): void { try { if (typeof window === "undefined") return; @@ -73,7 +77,8 @@ export function initializeUserJourneyTracking( createPageViewEvent(currentUrl, environmentId), log, getJourneyId, - setJourneyId + setJourneyId, + config ); setCookie(SURFACE_USER_JOURNEY_RECENT_VISIT_COOKIE_NAME, currentUrl, { @@ -92,7 +97,8 @@ export async function trackToRedis( event: JourneyTrackEvent, log: Logger, getJourneyId: () => string | null, - setJourneyId: (id: string | null) => void + setJourneyId: (id: string | null) => void, + config: SurfaceRuntimeConfig = getSurfaceRuntimeConfig() ): Promise | null> { try { const journeyId = getJourneyId(); @@ -105,7 +111,7 @@ export async function trackToRedis( const blob = new Blob([JSON.stringify(payload)], { type: "application/json", }); - const sent = navigator.sendBeacon(USER_JOURNEY_TRACKING_API, blob); + const sent = navigator.sendBeacon(config.userJourneyTrackingApi, blob); if (sent) { refreshJourneyCookie(journeyId); log.info({ message: "Tracking sent via sendBeacon", response: { sent } }); @@ -114,7 +120,7 @@ export async function trackToRedis( log.warn({ message: "sendBeacon failed, falling back to fetch" }); } - const response = await fetch(USER_JOURNEY_TRACKING_API, { + const response = await fetch(config.userJourneyTrackingApi, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), @@ -145,7 +151,8 @@ export function updateUserJourneyOnRouteChange( newUrl: string, log: Logger, getJourneyId: () => string | null, - setJourneyId: (id: string | null) => void + setJourneyId: (id: string | null) => void, + config: SurfaceRuntimeConfig = getSurfaceRuntimeConfig() ): void { try { if (typeof window === "undefined") return; @@ -162,7 +169,8 @@ export function updateUserJourneyOnRouteChange( createPageViewEvent(currentUrl, environmentId), log, getJourneyId, - setJourneyId + setJourneyId, + config ); setCookie(SURFACE_USER_JOURNEY_RECENT_VISIT_COOKIE_NAME, currentUrl, { diff --git a/surface_embed_v1.js b/surface_embed_v1.js index 3255785..076e3ac 100644 --- a/surface_embed_v1.js +++ b/surface_embed_v1.js @@ -71,6 +71,51 @@ return { ...fingerprint, id }; } + // src/runtime-config.ts + var CUSTOM_DOMAIN_ATTRIBUTE = "data-custom-domain"; + var DEFAULT_SURFACE_RUNTIME_CONFIG = { + apiBaseUrl: EXTERNAL_FORM_API, + leadIdentifyApi: LEAD_IDENTIFY_API, + userJourneyTrackingApi: USER_JOURNEY_TRACKING_API, + surfaceDomains: SURFACE_DOMAINS, + customOrigin: null + }; + var runtimeConfig = DEFAULT_SURFACE_RUNTIME_CONFIG; + function normalizeCustomOrigin(value) { + const trimmed = value.trim(); + if (!trimmed) return null; + try { + const url = new URL(trimmed.includes("://") ? trimmed : `https://${trimmed}`); + if (url.protocol !== "https:" || url.username || url.password || url.pathname !== "/" || url.search || url.hash) { + return null; + } + return url.origin; + } catch { + return null; + } + } + function resolveSurfaceRuntimeConfig(scriptElement) { + const customOrigin = normalizeCustomOrigin( + scriptElement?.getAttribute(CUSTOM_DOMAIN_ATTRIBUTE) ?? "" + ); + if (!customOrigin) return DEFAULT_SURFACE_RUNTIME_CONFIG; + const apiBaseUrl = `${customOrigin}/api/v1`; + return { + apiBaseUrl, + leadIdentifyApi: `${apiBaseUrl}/lead/identify`, + userJourneyTrackingApi: `${apiBaseUrl}/lead/track`, + surfaceDomains: Array.from(/* @__PURE__ */ new Set([...SURFACE_DOMAINS, customOrigin])), + customOrigin + }; + } + function initializeSurfaceRuntimeConfig(scriptElement) { + runtimeConfig = resolveSurfaceRuntimeConfig(scriptElement); + return runtimeConfig; + } + function getSurfaceRuntimeConfig() { + return runtimeConfig; + } + // src/lead/identify.ts var environmentId = null; var identifyInProgress = false; @@ -111,7 +156,7 @@ return null; } } - async function identifyLead(envId) { + async function identifyLead(envId, config = getSurfaceRuntimeConfig()) { if (identifyInProgress) { return waitForCachedData(); } @@ -123,7 +168,7 @@ try { const fingerprint = await getBrowserFingerprint(envId); const parentUrl = new URL(window.location.href); - const response = await fetch(LEAD_IDENTIFY_API, { + const response = await fetch(config.leadIdentifyApi, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ @@ -441,7 +486,8 @@ // src/store/message-listener.ts function initializeMessageListener(store) { const handleMessage = (event) => { - if (!event.origin || !SURFACE_DOMAINS.includes(event.origin)) { + const surfaceDomains = store.surfaceDomains ?? SURFACE_DOMAINS; + if (!event.origin || !surfaceDomains.includes(event.origin)) { return; } if (event.data?.type === "surface:conversion") { @@ -452,7 +498,8 @@ store.sendPayloadToIframes("STORE_UPDATE"); const envId = getEnvironmentId(); if (envId) { - identifyLead(envId).then(() => store.sendPayloadToIframes("LEAD_DATA_UPDATE")).catch((e) => console.log("Failed identify", e)); + const identify = store.config?.customOrigin ? identifyLead(envId, store.config) : identifyLead(envId); + identify.then(() => store.sendPayloadToIframes("LEAD_DATA_UPDATE")).catch((e) => console.log("Failed identify", e)); } else { store.sendPayloadToIframes("LEAD_DATA_UPDATE"); } @@ -514,7 +561,7 @@ } }; } - function initializeUserJourneyTracking(environmentId3, log2, getJourneyId, setJourneyId) { + function initializeUserJourneyTracking(environmentId3, log2, getJourneyId, setJourneyId, config = getSurfaceRuntimeConfig()) { try { if (typeof window === "undefined") return; const existingId = getExistingJourneyId(); @@ -530,7 +577,8 @@ createPageViewEvent(currentUrl2, environmentId3), log2, getJourneyId, - setJourneyId + setJourneyId, + config ); setCookie(SURFACE_USER_JOURNEY_RECENT_VISIT_COOKIE_NAME, currentUrl2, { maxAge: RECENT_VISIT_COOKIE_MAX_AGE, @@ -542,7 +590,7 @@ log2.error({ message: "Error initializing user journey tracking", error }); } } - async function trackToRedis(event, log2, getJourneyId, setJourneyId) { + async function trackToRedis(event, log2, getJourneyId, setJourneyId, config = getSurfaceRuntimeConfig()) { try { const journeyId = getJourneyId(); const payload = { ...event }; @@ -552,7 +600,7 @@ const blob = new Blob([JSON.stringify(payload)], { type: "application/json" }); - const sent = navigator.sendBeacon(USER_JOURNEY_TRACKING_API, blob); + const sent = navigator.sendBeacon(config.userJourneyTrackingApi, blob); if (sent) { refreshJourneyCookie(journeyId); log2.info({ message: "Tracking sent via sendBeacon", response: { sent } }); @@ -560,7 +608,7 @@ } log2.warn({ message: "sendBeacon failed, falling back to fetch" }); } - const response = await fetch(USER_JOURNEY_TRACKING_API, { + const response = await fetch(config.userJourneyTrackingApi, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload) @@ -581,7 +629,7 @@ return null; } } - function updateUserJourneyOnRouteChange(environmentId3, newUrl, log2, getJourneyId, setJourneyId) { + function updateUserJourneyOnRouteChange(environmentId3, newUrl, log2, getJourneyId, setJourneyId, config = getSurfaceRuntimeConfig()) { try { if (typeof window === "undefined") return; const currentUrl2 = newUrl || window.location.href; @@ -594,7 +642,8 @@ createPageViewEvent(currentUrl2, environmentId3), log2, getJourneyId, - setJourneyId + setJourneyId, + config ); setCookie(SURFACE_USER_JOURNEY_RECENT_VISIT_COOKIE_NAME, currentUrl2, { maxAge: RECENT_VISIT_COOKIE_MAX_AGE, @@ -616,7 +665,7 @@ // src/store/store.ts var SurfaceStore = class { - constructor(environmentId3 = null) { + constructor(environmentId3 = null, config = getSurfaceRuntimeConfig()) { this.windowUrl = new URL(window.location.href).toString(); this.origin = new URL(window.location.href).origin.toString(); this.referrer = document.referrer || ""; @@ -626,7 +675,8 @@ this.partialFilledData = {}; this.validEmbedTypes = VALID_EMBED_TYPES; this.debugMode = isDebugMode(); - this.surfaceDomains = SURFACE_DOMAINS; + this.config = config; + this.surfaceDomains = config.surfaceDomains; this.userJourneyId = null; this.userJourney = []; this.cachedIdentifyData = getLeadDataWithTTL(); @@ -642,7 +692,8 @@ const resolved = !!id && id !== this.userJourneyId; this.userJourneyId = id; if (resolved) this.sendPayloadToIframes("STORE_UPDATE"); - } + }, + this.config ); this.setupRouteChangeDetection(); } @@ -650,7 +701,8 @@ if (!this.hasSurfaceIframe()) return; this.sendPayloadToIframes("STORE_UPDATE"); if (this.environmentId) { - identifyLead(this.environmentId).then(() => this.sendPayloadToIframes("LEAD_DATA_UPDATE")).catch((e) => this.log.error({ message: "Initial identify failed", error: e })); + const identify = this.config.customOrigin ? identifyLead(this.environmentId, this.config) : identifyLead(this.environmentId); + identify.then(() => this.sendPayloadToIframes("LEAD_DATA_UPDATE")).catch((e) => this.log.error({ message: "Initial identify failed", error: e })); } else if (getLeadDataWithTTL()) { this.sendPayloadToIframes("LEAD_DATA_UPDATE"); } @@ -662,13 +714,17 @@ } } hasSurfaceIframe() { - return Array.from(document.querySelectorAll("iframe")).some( - (iframe) => SURFACE_DOMAINS.some((domain) => iframe.src.includes(domain)) - ); + return Array.from(document.querySelectorAll("iframe")).some((iframe) => { + try { + return this.surfaceDomains.includes(new URL(iframe.src).origin); + } catch { + return false; + } + }); } isCurrentOriginSurfaceDomain() { - const hostname = window.location?.hostname ?? ""; - return SURFACE_DOMAINS.some((url) => new URL(url).hostname === hostname); + const origin = window.location?.origin ?? ""; + return this.surfaceDomains.includes(origin); } setupRouteChangeDetection() { onRouteChange((newUrl) => { @@ -682,7 +738,8 @@ const resolved = !!id && id !== this.userJourneyId; this.userJourneyId = id; if (resolved) this.sendPayloadToIframes("STORE_UPDATE"); - } + }, + this.config ); this.sendPayloadToIframes("STORE_UPDATE"); this.log.info({ message: "Route changed, updated journey", response: { url: newUrl } }); @@ -699,14 +756,15 @@ notifyIframe(iframe, type) { const target = iframe || document.querySelector("#surface-iframe"); if (!target) return; - SURFACE_DOMAINS.forEach((domain) => { - if (target.src.includes(domain)) { - target.contentWindow?.postMessage( - { type, payload: this.getPayload(), sender: "surface_tag" }, - domain - ); - } - }); + try { + const targetOrigin = new URL(target.src).origin; + if (!this.surfaceDomains.includes(targetOrigin)) return; + target.contentWindow?.postMessage( + { type, payload: this.getPayload(), sender: "surface_tag" }, + targetOrigin + ); + } catch { + } } getUrlParams() { return getUrlParams(); @@ -815,7 +873,7 @@ this.formInitializationStatus = {}; this.formStarted = {}; this.config = { - serverBaseUrl: props?.serverBaseUrl || EXTERNAL_FORM_API, + serverBaseUrl: props?.serverBaseUrl || getSurfaceRuntimeConfig().apiBaseUrl, debugMode: isDebugMode() }; this.environmentId = props?.siteId || getSiteIdFromScript(document.currentScript); @@ -2134,21 +2192,21 @@ var CACHE_TTL_MS = 5 * 60 * 1e3; var REUSE_POLL_INTERVAL_MS = 150; var REUSE_POLL_MAX_TRIES = 12; - async function resolveOpenTriggersOnLoad(environmentId3) { + async function resolveOpenTriggersOnLoad(environmentId3, config = getSurfaceRuntimeConfig()) { try { if (!environmentId3) return; if (!window.location.search) return; - const map = await fetchOpenTriggersMap(environmentId3); + const map = await fetchOpenTriggersMap(environmentId3, config); const entry = pickOpenTrigger(window.location.search, map); if (!entry) return; openTriggerForm(entry); } catch { } } - async function fetchOpenTriggersMap(environmentId3) { + async function fetchOpenTriggersMap(environmentId3, config) { const w3 = window; if (w3.__SURFACE_OPEN_TRIGGERS_MAP) return w3.__SURFACE_OPEN_TRIGGERS_MAP; - const sessionKey = SESSION_PREFIX + environmentId3; + const sessionKey = `${SESSION_PREFIX}${config.apiBaseUrl}:${environmentId3}`; try { const cached2 = sessionStorage.getItem(sessionKey); if (cached2) { @@ -2159,7 +2217,7 @@ } } catch { } - const base = w3.__SURFACE_OPEN_TRIGGERS_BASE || EXTERNAL_FORM_API; + const base = w3.__SURFACE_OPEN_TRIGGERS_BASE || config.apiBaseUrl; const response = await fetch(`${base}/environments/${encodeURIComponent(environmentId3)}/open-triggers`); if (!response.ok) return null; const json = await response.json(); @@ -2403,9 +2461,10 @@ // src/index.ts var scriptTag = document.currentScript; + var runtimeConfig2 = initializeSurfaceRuntimeConfig(scriptTag); var environmentId2 = getSiteIdFromScript(scriptTag); setEnvironmentId(environmentId2); - var SurfaceTagStore = new SurfaceStore(environmentId2); + var SurfaceTagStore = new SurfaceStore(environmentId2, runtimeConfig2); var w2 = window; w2.SurfaceEmbed = SurfaceEmbed; w2.SurfaceExternalForm = SurfaceExternalForm; @@ -2414,6 +2473,6 @@ w2.SurfaceSetLeadDataWithTTL = setLeadDataWithTTL; w2.SurfaceGetLeadDataWithTTL = getLeadDataWithTTL; w2.SurfaceGetSiteIdFromScript = getSiteIdFromScript; - void resolveOpenTriggersOnLoad(environmentId2); + void resolveOpenTriggersOnLoad(environmentId2, runtimeConfig2); initReview(); })(); diff --git a/surface_tag.js b/surface_tag.js index 3255785..076e3ac 100644 --- a/surface_tag.js +++ b/surface_tag.js @@ -71,6 +71,51 @@ return { ...fingerprint, id }; } + // src/runtime-config.ts + var CUSTOM_DOMAIN_ATTRIBUTE = "data-custom-domain"; + var DEFAULT_SURFACE_RUNTIME_CONFIG = { + apiBaseUrl: EXTERNAL_FORM_API, + leadIdentifyApi: LEAD_IDENTIFY_API, + userJourneyTrackingApi: USER_JOURNEY_TRACKING_API, + surfaceDomains: SURFACE_DOMAINS, + customOrigin: null + }; + var runtimeConfig = DEFAULT_SURFACE_RUNTIME_CONFIG; + function normalizeCustomOrigin(value) { + const trimmed = value.trim(); + if (!trimmed) return null; + try { + const url = new URL(trimmed.includes("://") ? trimmed : `https://${trimmed}`); + if (url.protocol !== "https:" || url.username || url.password || url.pathname !== "/" || url.search || url.hash) { + return null; + } + return url.origin; + } catch { + return null; + } + } + function resolveSurfaceRuntimeConfig(scriptElement) { + const customOrigin = normalizeCustomOrigin( + scriptElement?.getAttribute(CUSTOM_DOMAIN_ATTRIBUTE) ?? "" + ); + if (!customOrigin) return DEFAULT_SURFACE_RUNTIME_CONFIG; + const apiBaseUrl = `${customOrigin}/api/v1`; + return { + apiBaseUrl, + leadIdentifyApi: `${apiBaseUrl}/lead/identify`, + userJourneyTrackingApi: `${apiBaseUrl}/lead/track`, + surfaceDomains: Array.from(/* @__PURE__ */ new Set([...SURFACE_DOMAINS, customOrigin])), + customOrigin + }; + } + function initializeSurfaceRuntimeConfig(scriptElement) { + runtimeConfig = resolveSurfaceRuntimeConfig(scriptElement); + return runtimeConfig; + } + function getSurfaceRuntimeConfig() { + return runtimeConfig; + } + // src/lead/identify.ts var environmentId = null; var identifyInProgress = false; @@ -111,7 +156,7 @@ return null; } } - async function identifyLead(envId) { + async function identifyLead(envId, config = getSurfaceRuntimeConfig()) { if (identifyInProgress) { return waitForCachedData(); } @@ -123,7 +168,7 @@ try { const fingerprint = await getBrowserFingerprint(envId); const parentUrl = new URL(window.location.href); - const response = await fetch(LEAD_IDENTIFY_API, { + const response = await fetch(config.leadIdentifyApi, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ @@ -441,7 +486,8 @@ // src/store/message-listener.ts function initializeMessageListener(store) { const handleMessage = (event) => { - if (!event.origin || !SURFACE_DOMAINS.includes(event.origin)) { + const surfaceDomains = store.surfaceDomains ?? SURFACE_DOMAINS; + if (!event.origin || !surfaceDomains.includes(event.origin)) { return; } if (event.data?.type === "surface:conversion") { @@ -452,7 +498,8 @@ store.sendPayloadToIframes("STORE_UPDATE"); const envId = getEnvironmentId(); if (envId) { - identifyLead(envId).then(() => store.sendPayloadToIframes("LEAD_DATA_UPDATE")).catch((e) => console.log("Failed identify", e)); + const identify = store.config?.customOrigin ? identifyLead(envId, store.config) : identifyLead(envId); + identify.then(() => store.sendPayloadToIframes("LEAD_DATA_UPDATE")).catch((e) => console.log("Failed identify", e)); } else { store.sendPayloadToIframes("LEAD_DATA_UPDATE"); } @@ -514,7 +561,7 @@ } }; } - function initializeUserJourneyTracking(environmentId3, log2, getJourneyId, setJourneyId) { + function initializeUserJourneyTracking(environmentId3, log2, getJourneyId, setJourneyId, config = getSurfaceRuntimeConfig()) { try { if (typeof window === "undefined") return; const existingId = getExistingJourneyId(); @@ -530,7 +577,8 @@ createPageViewEvent(currentUrl2, environmentId3), log2, getJourneyId, - setJourneyId + setJourneyId, + config ); setCookie(SURFACE_USER_JOURNEY_RECENT_VISIT_COOKIE_NAME, currentUrl2, { maxAge: RECENT_VISIT_COOKIE_MAX_AGE, @@ -542,7 +590,7 @@ log2.error({ message: "Error initializing user journey tracking", error }); } } - async function trackToRedis(event, log2, getJourneyId, setJourneyId) { + async function trackToRedis(event, log2, getJourneyId, setJourneyId, config = getSurfaceRuntimeConfig()) { try { const journeyId = getJourneyId(); const payload = { ...event }; @@ -552,7 +600,7 @@ const blob = new Blob([JSON.stringify(payload)], { type: "application/json" }); - const sent = navigator.sendBeacon(USER_JOURNEY_TRACKING_API, blob); + const sent = navigator.sendBeacon(config.userJourneyTrackingApi, blob); if (sent) { refreshJourneyCookie(journeyId); log2.info({ message: "Tracking sent via sendBeacon", response: { sent } }); @@ -560,7 +608,7 @@ } log2.warn({ message: "sendBeacon failed, falling back to fetch" }); } - const response = await fetch(USER_JOURNEY_TRACKING_API, { + const response = await fetch(config.userJourneyTrackingApi, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload) @@ -581,7 +629,7 @@ return null; } } - function updateUserJourneyOnRouteChange(environmentId3, newUrl, log2, getJourneyId, setJourneyId) { + function updateUserJourneyOnRouteChange(environmentId3, newUrl, log2, getJourneyId, setJourneyId, config = getSurfaceRuntimeConfig()) { try { if (typeof window === "undefined") return; const currentUrl2 = newUrl || window.location.href; @@ -594,7 +642,8 @@ createPageViewEvent(currentUrl2, environmentId3), log2, getJourneyId, - setJourneyId + setJourneyId, + config ); setCookie(SURFACE_USER_JOURNEY_RECENT_VISIT_COOKIE_NAME, currentUrl2, { maxAge: RECENT_VISIT_COOKIE_MAX_AGE, @@ -616,7 +665,7 @@ // src/store/store.ts var SurfaceStore = class { - constructor(environmentId3 = null) { + constructor(environmentId3 = null, config = getSurfaceRuntimeConfig()) { this.windowUrl = new URL(window.location.href).toString(); this.origin = new URL(window.location.href).origin.toString(); this.referrer = document.referrer || ""; @@ -626,7 +675,8 @@ this.partialFilledData = {}; this.validEmbedTypes = VALID_EMBED_TYPES; this.debugMode = isDebugMode(); - this.surfaceDomains = SURFACE_DOMAINS; + this.config = config; + this.surfaceDomains = config.surfaceDomains; this.userJourneyId = null; this.userJourney = []; this.cachedIdentifyData = getLeadDataWithTTL(); @@ -642,7 +692,8 @@ const resolved = !!id && id !== this.userJourneyId; this.userJourneyId = id; if (resolved) this.sendPayloadToIframes("STORE_UPDATE"); - } + }, + this.config ); this.setupRouteChangeDetection(); } @@ -650,7 +701,8 @@ if (!this.hasSurfaceIframe()) return; this.sendPayloadToIframes("STORE_UPDATE"); if (this.environmentId) { - identifyLead(this.environmentId).then(() => this.sendPayloadToIframes("LEAD_DATA_UPDATE")).catch((e) => this.log.error({ message: "Initial identify failed", error: e })); + const identify = this.config.customOrigin ? identifyLead(this.environmentId, this.config) : identifyLead(this.environmentId); + identify.then(() => this.sendPayloadToIframes("LEAD_DATA_UPDATE")).catch((e) => this.log.error({ message: "Initial identify failed", error: e })); } else if (getLeadDataWithTTL()) { this.sendPayloadToIframes("LEAD_DATA_UPDATE"); } @@ -662,13 +714,17 @@ } } hasSurfaceIframe() { - return Array.from(document.querySelectorAll("iframe")).some( - (iframe) => SURFACE_DOMAINS.some((domain) => iframe.src.includes(domain)) - ); + return Array.from(document.querySelectorAll("iframe")).some((iframe) => { + try { + return this.surfaceDomains.includes(new URL(iframe.src).origin); + } catch { + return false; + } + }); } isCurrentOriginSurfaceDomain() { - const hostname = window.location?.hostname ?? ""; - return SURFACE_DOMAINS.some((url) => new URL(url).hostname === hostname); + const origin = window.location?.origin ?? ""; + return this.surfaceDomains.includes(origin); } setupRouteChangeDetection() { onRouteChange((newUrl) => { @@ -682,7 +738,8 @@ const resolved = !!id && id !== this.userJourneyId; this.userJourneyId = id; if (resolved) this.sendPayloadToIframes("STORE_UPDATE"); - } + }, + this.config ); this.sendPayloadToIframes("STORE_UPDATE"); this.log.info({ message: "Route changed, updated journey", response: { url: newUrl } }); @@ -699,14 +756,15 @@ notifyIframe(iframe, type) { const target = iframe || document.querySelector("#surface-iframe"); if (!target) return; - SURFACE_DOMAINS.forEach((domain) => { - if (target.src.includes(domain)) { - target.contentWindow?.postMessage( - { type, payload: this.getPayload(), sender: "surface_tag" }, - domain - ); - } - }); + try { + const targetOrigin = new URL(target.src).origin; + if (!this.surfaceDomains.includes(targetOrigin)) return; + target.contentWindow?.postMessage( + { type, payload: this.getPayload(), sender: "surface_tag" }, + targetOrigin + ); + } catch { + } } getUrlParams() { return getUrlParams(); @@ -815,7 +873,7 @@ this.formInitializationStatus = {}; this.formStarted = {}; this.config = { - serverBaseUrl: props?.serverBaseUrl || EXTERNAL_FORM_API, + serverBaseUrl: props?.serverBaseUrl || getSurfaceRuntimeConfig().apiBaseUrl, debugMode: isDebugMode() }; this.environmentId = props?.siteId || getSiteIdFromScript(document.currentScript); @@ -2134,21 +2192,21 @@ var CACHE_TTL_MS = 5 * 60 * 1e3; var REUSE_POLL_INTERVAL_MS = 150; var REUSE_POLL_MAX_TRIES = 12; - async function resolveOpenTriggersOnLoad(environmentId3) { + async function resolveOpenTriggersOnLoad(environmentId3, config = getSurfaceRuntimeConfig()) { try { if (!environmentId3) return; if (!window.location.search) return; - const map = await fetchOpenTriggersMap(environmentId3); + const map = await fetchOpenTriggersMap(environmentId3, config); const entry = pickOpenTrigger(window.location.search, map); if (!entry) return; openTriggerForm(entry); } catch { } } - async function fetchOpenTriggersMap(environmentId3) { + async function fetchOpenTriggersMap(environmentId3, config) { const w3 = window; if (w3.__SURFACE_OPEN_TRIGGERS_MAP) return w3.__SURFACE_OPEN_TRIGGERS_MAP; - const sessionKey = SESSION_PREFIX + environmentId3; + const sessionKey = `${SESSION_PREFIX}${config.apiBaseUrl}:${environmentId3}`; try { const cached2 = sessionStorage.getItem(sessionKey); if (cached2) { @@ -2159,7 +2217,7 @@ } } catch { } - const base = w3.__SURFACE_OPEN_TRIGGERS_BASE || EXTERNAL_FORM_API; + const base = w3.__SURFACE_OPEN_TRIGGERS_BASE || config.apiBaseUrl; const response = await fetch(`${base}/environments/${encodeURIComponent(environmentId3)}/open-triggers`); if (!response.ok) return null; const json = await response.json(); @@ -2403,9 +2461,10 @@ // src/index.ts var scriptTag = document.currentScript; + var runtimeConfig2 = initializeSurfaceRuntimeConfig(scriptTag); var environmentId2 = getSiteIdFromScript(scriptTag); setEnvironmentId(environmentId2); - var SurfaceTagStore = new SurfaceStore(environmentId2); + var SurfaceTagStore = new SurfaceStore(environmentId2, runtimeConfig2); var w2 = window; w2.SurfaceEmbed = SurfaceEmbed; w2.SurfaceExternalForm = SurfaceExternalForm; @@ -2414,6 +2473,6 @@ w2.SurfaceSetLeadDataWithTTL = setLeadDataWithTTL; w2.SurfaceGetLeadDataWithTTL = getLeadDataWithTTL; w2.SurfaceGetSiteIdFromScript = getSiteIdFromScript; - void resolveOpenTriggersOnLoad(environmentId2); + void resolveOpenTriggersOnLoad(environmentId2, runtimeConfig2); initReview(); })();