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.test.ts b/src/external-form/external-form.test.ts new file mode 100644 index 0000000..2098475 --- /dev/null +++ b/src/external-form/external-form.test.ts @@ -0,0 +1,31 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { initializeSurfaceRuntimeConfig } from "../runtime-config"; +import { SurfaceExternalForm } from "./external-form"; + +describe("SurfaceExternalForm custom domain", () => { + afterEach(() => { + initializeSurfaceRuntimeConfig(null); + }); + + it("inherits the API base derived from the tag attribute", () => { + const script = document.createElement("script"); + script.setAttribute("data-custom-domain", "demo.example.com"); + initializeSurfaceRuntimeConfig(script); + + const form = new SurfaceExternalForm(); + + expect(form.config.serverBaseUrl).toBe("https://demo.example.com/api/v1"); + }); + + it("preserves the explicit constructor override", () => { + const script = document.createElement("script"); + script.setAttribute("data-custom-domain", "demo.example.com"); + initializeSurfaceRuntimeConfig(script); + + const form = new SurfaceExternalForm({ + serverBaseUrl: "https://override.example/api/v1", + }); + + expect(form.config.serverBaseUrl).toBe("https://override.example/api/v1"); + }); +}); 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.test.ts b/src/lead/identify.test.ts new file mode 100644 index 0000000..a593d2a --- /dev/null +++ b/src/lead/identify.test.ts @@ -0,0 +1,39 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { resolveSurfaceRuntimeConfig } from "../runtime-config"; +import { identifyLead } from "./identify"; + +vi.mock("./fingerprint", () => ({ + getBrowserFingerprint: vi.fn(async () => ({ id: "fingerprint_123" })), +})); + +describe("identifyLead custom domain", () => { + beforeEach(() => { + localStorage.clear(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.clearAllMocks(); + localStorage.clear(); + }); + + it("posts lead identification to the configured custom domain", async () => { + const script = document.createElement("script"); + script.setAttribute("data-custom-domain", "demo.example.com"); + const config = resolveSurfaceRuntimeConfig(script); + const fetchMock = vi.fn(async () => ({ + ok: true, + json: async () => ({ + data: { data: { leadId: "lead_123", sessionId: "session_123" } }, + }), + })); + vi.stubGlobal("fetch", fetchMock); + + await identifyLead("env_123", config); + + expect(fetchMock).toHaveBeenCalledWith( + "https://demo.example.com/api/v1/lead/identify", + expect.objectContaining({ method: "POST" }) + ); + }); +}); 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 { + const script = document.createElement("script"); + script.setAttribute("data-custom-domain", value); + return script; +}; + +describe("resolveSurfaceRuntimeConfig", () => { + it("keeps the existing Surface endpoints when the attribute is absent", () => { + expect(resolveSurfaceRuntimeConfig(document.createElement("script"))).toBe( + DEFAULT_SURFACE_RUNTIME_CONFIG + ); + }); + + it("derives every Surface API endpoint from data-custom-domain", () => { + const config = resolveSurfaceRuntimeConfig( + scriptWithCustomDomain("demo.example.com") + ); + + expect(config).toMatchObject({ + apiBaseUrl: "https://demo.example.com/api/v1", + leadIdentifyApi: "https://demo.example.com/api/v1/lead/identify", + userJourneyTrackingApi: "https://demo.example.com/api/v1/lead/track", + customOrigin: "https://demo.example.com", + }); + expect(config.surfaceDomains).toContain("https://demo.example.com"); + expect(config.surfaceDomains).toContain("https://forms.withsurface.com"); + }); + + it("accepts an explicit HTTPS origin and removes its trailing slash", () => { + const config = resolveSurfaceRuntimeConfig( + scriptWithCustomDomain("https://demo.example.com/") + ); + + expect(config.customOrigin).toBe("https://demo.example.com"); + }); + + it.each([ + "http://demo.example.com", + "https://demo.example.com/forms", + "https://user:password@demo.example.com", + "not a domain", + ])("falls back safely for invalid custom domain %s", (value) => { + expect(resolveSurfaceRuntimeConfig(scriptWithCustomDomain(value))).toBe( + DEFAULT_SURFACE_RUNTIME_CONFIG + ); + }); +}); diff --git a/src/runtime-config.ts b/src/runtime-config.ts new file mode 100644 index 0000000..2c36beb --- /dev/null +++ b/src/runtime-config.ts @@ -0,0 +1,78 @@ +import { + EXTERNAL_FORM_API, + LEAD_IDENTIFY_API, + SURFACE_DOMAINS, + USER_JOURNEY_TRACKING_API, +} from "./constants"; + +export const CUSTOM_DOMAIN_ATTRIBUTE = "data-custom-domain"; + +export interface SurfaceRuntimeConfig { + apiBaseUrl: string; + leadIdentifyApi: string; + userJourneyTrackingApi: string; + surfaceDomains: readonly string[]; + customOrigin: string | null; +} + +export const DEFAULT_SURFACE_RUNTIME_CONFIG: SurfaceRuntimeConfig = { + apiBaseUrl: EXTERNAL_FORM_API, + leadIdentifyApi: LEAD_IDENTIFY_API, + userJourneyTrackingApi: USER_JOURNEY_TRACKING_API, + surfaceDomains: SURFACE_DOMAINS, + customOrigin: null, +}; + +let runtimeConfig = DEFAULT_SURFACE_RUNTIME_CONFIG; + +function normalizeCustomOrigin(value: string): string | null { + 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; + } +} + +export function resolveSurfaceRuntimeConfig( + scriptElement: HTMLScriptElement | null +): SurfaceRuntimeConfig { + 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(new Set([...SURFACE_DOMAINS, customOrigin])), + customOrigin, + }; +} + +export function initializeSurfaceRuntimeConfig( + scriptElement: HTMLScriptElement | null +): SurfaceRuntimeConfig { + runtimeConfig = resolveSurfaceRuntimeConfig(scriptElement); + return runtimeConfig; +} + +export function getSurfaceRuntimeConfig(): SurfaceRuntimeConfig { + return runtimeConfig; +} diff --git a/src/store/message-listener.test.ts b/src/store/message-listener.test.ts index 1060e3a..1f48623 100644 --- a/src/store/message-listener.test.ts +++ b/src/store/message-listener.test.ts @@ -2,6 +2,10 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { initializeMessageListener } from "./message-listener"; import { identifyLead, getEnvironmentId } from "../lead/identify"; import type { SurfaceStore } from "./store"; +import { + DEFAULT_SURFACE_RUNTIME_CONFIG, + resolveSurfaceRuntimeConfig, +} from "../runtime-config"; vi.mock("../lead/identify", () => ({ identifyLead: vi.fn(async () => null), @@ -18,6 +22,8 @@ const makeStore = () => sendPayloadToIframes: vi.fn(), clearUserJourney: vi.fn(), log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, + config: DEFAULT_SURFACE_RUNTIME_CONFIG, + surfaceDomains: DEFAULT_SURFACE_RUNTIME_CONFIG.surfaceDomains, }) as unknown as SurfaceStore; const dispatch = (data: unknown, origin: string = FORMS_ORIGIN) => @@ -57,7 +63,10 @@ describe("initializeMessageListener", () => { expect(store.sendPayloadToIframes).toHaveBeenCalledTimes(1); expect(store.sendPayloadToIframes).toHaveBeenCalledWith("STORE_UPDATE"); - expect(identifyLead).toHaveBeenCalledWith("env_123"); + expect(identifyLead).toHaveBeenCalledWith( + "env_123", + DEFAULT_SURFACE_RUNTIME_CONFIG + ); await flushMicrotasks(); expect(store.sendPayloadToIframes).toHaveBeenLastCalledWith("LEAD_DATA_UPDATE"); @@ -84,6 +93,23 @@ describe("initializeMessageListener", () => { expect(store.sendPayloadToIframes).not.toHaveBeenCalled(); }); + it("accepts messages from the configured custom domain", () => { + const script = document.createElement("script"); + script.setAttribute("data-custom-domain", "demo.example.com"); + const config = resolveSurfaceRuntimeConfig(script); + const store = makeStore(); + store.config = config; + store.surfaceDomains = config.surfaceDomains; + initializeMessageListener(store); + + dispatch( + { type: "SEND_DATA", sender: "surface_form" }, + "https://demo.example.com" + ); + + expect(store.sendPayloadToIframes).toHaveBeenCalledWith("STORE_UPDATE"); + }); + it("clears the user journey on CLEAR_USER_JOURNEY_DATA", () => { const store = makeStore(); initializeMessageListener(store); diff --git a/src/store/message-listener.ts b/src/store/message-listener.ts index 0b366fc..5090560 100644 --- a/src/store/message-listener.ts +++ b/src/store/message-listener.ts @@ -1,11 +1,10 @@ -import { SURFACE_DOMAINS } from "../constants"; import { handleConversionMessage } from "../conversions/conversion-listener"; import { identifyLead, getEnvironmentId } from "../lead/identify"; import type { SurfaceStore } from "./store"; export function initializeMessageListener(store: SurfaceStore): void { const handleMessage = (event: MessageEvent) => { - if (!event.origin || !(SURFACE_DOMAINS as readonly string[]).includes(event.origin)) { + if (!event.origin || !store.surfaceDomains.includes(event.origin)) { return; } @@ -19,7 +18,7 @@ export function initializeMessageListener(store: SurfaceStore): void { const envId = getEnvironmentId(); if (envId) { - identifyLead(envId) + identifyLead(envId, store.config) .then(() => store.sendPayloadToIframes("LEAD_DATA_UPDATE")) .catch((e) => console.log("Failed identify", e)); } else { diff --git a/src/store/store.test.ts b/src/store/store.test.ts index d5a7243..1686650 100644 --- a/src/store/store.test.ts +++ b/src/store/store.test.ts @@ -4,6 +4,10 @@ import { identifyLead, getLeadDataWithTTL } from "../lead/identify"; import { initializeUserJourneyTracking, updateUserJourneyOnRouteChange } from "./user-journey"; import { onRouteChange } from "../utils/route-observer"; import type { LeadData } from "../types"; +import { + DEFAULT_SURFACE_RUNTIME_CONFIG, + resolveSurfaceRuntimeConfig, +} from "../runtime-config"; vi.mock("./message-listener", () => ({ initializeMessageListener: vi.fn(), @@ -23,6 +27,11 @@ vi.mock("../utils/route-observer", () => ({ })); const SURFACE_IFRAME_SRC = "https://forms.withsurface.com/s/form123"; +const customDomainConfig = () => { + const script = document.createElement("script"); + script.setAttribute("data-custom-domain", "demo.example.com"); + return resolveSurfaceRuntimeConfig(script); +}; const addIframe = (src: string) => { const iframe = document.createElement("iframe"); @@ -65,7 +74,10 @@ describe("SurfaceStore boot push (direct-iframe rescue)", () => { await vi.runAllTimersAsync(); expect(pushedTypes(pushes)).toEqual(["STORE_UPDATE", "LEAD_DATA_UPDATE"]); - expect(identifyLead).toHaveBeenCalledWith("env_123"); + expect(identifyLead).toHaveBeenCalledWith( + "env_123", + DEFAULT_SURFACE_RUNTIME_CONFIG + ); }); it("never pushes or identifies when the page has no Surface iframe", async () => { @@ -179,4 +191,35 @@ describe("SurfaceStore postMessage protocol", () => { ); expect(otherPost).not.toHaveBeenCalled(); }); + + it("detects and posts to an iframe on the configured custom domain", async () => { + const customIframe = addIframe("https://demo.example.com/s/form123"); + const config = customDomainConfig(); + const store = new SurfaceStore("env_123", config); + const customPost = vi + .spyOn(customIframe.contentWindow as Window, "postMessage") + .mockImplementation(() => {}); + + await vi.runAllTimersAsync(); + + expect(identifyLead).toHaveBeenCalledWith("env_123", config); + expect(customPost).toHaveBeenCalledWith( + expect.objectContaining({ type: "STORE_UPDATE", sender: "surface_tag" }), + "https://demo.example.com" + ); + }); + + it("requires an exact iframe origin match", () => { + const lookalikeIframe = addIframe( + "https://demo.example.com.evil.test/?https://demo.example.com" + ); + const store = new SurfaceStore(null, customDomainConfig()); + const lookalikePost = vi + .spyOn(lookalikeIframe.contentWindow as Window, "postMessage") + .mockImplementation(() => {}); + + store.sendPayloadToIframes("STORE_UPDATE"); + + expect(lookalikePost).not.toHaveBeenCalled(); + }); }); diff --git a/src/store/store.ts b/src/store/store.ts index acfe034..ac49ce7 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,7 @@ export class SurfaceStore { if (!this.hasSurfaceIframe()) return; this.sendPayloadToIframes("STORE_UPDATE"); if (this.environmentId) { - identifyLead(this.environmentId) + identifyLead(this.environmentId, this.config) .then(() => this.sendPayloadToIframes("LEAD_DATA_UPDATE")) .catch((e) => this.log.error({ message: "Initial identify failed", error: e })); } else if (getLeadDataWithTTL()) { @@ -95,14 +105,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 +134,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 +160,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..45e32f5 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,7 @@ // src/store/message-listener.ts function initializeMessageListener(store) { const handleMessage = (event) => { - if (!event.origin || !SURFACE_DOMAINS.includes(event.origin)) { + if (!event.origin || !store.surfaceDomains.includes(event.origin)) { return; } if (event.data?.type === "surface:conversion") { @@ -452,7 +497,7 @@ store.sendPayloadToIframes("STORE_UPDATE"); const envId = getEnvironmentId(); if (envId) { - identifyLead(envId).then(() => store.sendPayloadToIframes("LEAD_DATA_UPDATE")).catch((e) => console.log("Failed identify", e)); + identifyLead(envId, store.config).then(() => store.sendPayloadToIframes("LEAD_DATA_UPDATE")).catch((e) => console.log("Failed identify", e)); } else { store.sendPayloadToIframes("LEAD_DATA_UPDATE"); } @@ -514,7 +559,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 +575,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 +588,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 +598,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 +606,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 +627,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 +640,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 +663,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 +673,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 +690,8 @@ const resolved = !!id && id !== this.userJourneyId; this.userJourneyId = id; if (resolved) this.sendPayloadToIframes("STORE_UPDATE"); - } + }, + this.config ); this.setupRouteChangeDetection(); } @@ -650,7 +699,7 @@ 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 })); + identifyLead(this.environmentId, this.config).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 +711,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 +735,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 +753,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 +870,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 +2189,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 +2214,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 +2458,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 +2470,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..45e32f5 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,7 @@ // src/store/message-listener.ts function initializeMessageListener(store) { const handleMessage = (event) => { - if (!event.origin || !SURFACE_DOMAINS.includes(event.origin)) { + if (!event.origin || !store.surfaceDomains.includes(event.origin)) { return; } if (event.data?.type === "surface:conversion") { @@ -452,7 +497,7 @@ store.sendPayloadToIframes("STORE_UPDATE"); const envId = getEnvironmentId(); if (envId) { - identifyLead(envId).then(() => store.sendPayloadToIframes("LEAD_DATA_UPDATE")).catch((e) => console.log("Failed identify", e)); + identifyLead(envId, store.config).then(() => store.sendPayloadToIframes("LEAD_DATA_UPDATE")).catch((e) => console.log("Failed identify", e)); } else { store.sendPayloadToIframes("LEAD_DATA_UPDATE"); } @@ -514,7 +559,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 +575,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 +588,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 +598,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 +606,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 +627,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 +640,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 +663,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 +673,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 +690,8 @@ const resolved = !!id && id !== this.userJourneyId; this.userJourneyId = id; if (resolved) this.sendPayloadToIframes("STORE_UPDATE"); - } + }, + this.config ); this.setupRouteChangeDetection(); } @@ -650,7 +699,7 @@ 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 })); + identifyLead(this.environmentId, this.config).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 +711,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 +735,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 +753,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 +870,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 +2189,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 +2214,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 +2458,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 +2470,6 @@ w2.SurfaceSetLeadDataWithTTL = setLeadDataWithTTL; w2.SurfaceGetLeadDataWithTTL = getLeadDataWithTTL; w2.SurfaceGetSiteIdFromScript = getSiteIdFromScript; - void resolveOpenTriggersOnLoad(environmentId2); + void resolveOpenTriggersOnLoad(environmentId2, runtimeConfig2); initReview(); })(); diff --git a/test/unit/user-journey.test.js b/test/unit/user-journey.test.js index 17b5790..6b5fc82 100644 --- a/test/unit/user-journey.test.js +++ b/test/unit/user-journey.test.js @@ -165,6 +165,43 @@ test("sendBeacon serializes a page-view referrer unchanged", async () => { } }); +test("sendBeacon uses the configured custom-domain tracking endpoint", async () => { + let beaconUrl; + const restore = installBrowserGlobals({ + url: "https://example.com/landing", + referrer: "", + navigatorValue: { + sendBeacon: (url) => { + beaconUrl = url; + return true; + }, + }, + }); + + try { + const { trackToRedis } = await loadUserJourney(); + await trackToRedis( + { + data: { + type: "page_view", + payload: { url: "https://example.com/landing" }, + }, + metadata: {}, + }, + createLogger(), + () => "journey_123", + () => {}, + { + userJourneyTrackingApi: "https://demo.example.com/api/v1/lead/track", + } + ); + + assert.equal(beaconUrl, "https://demo.example.com/api/v1/lead/track"); + } finally { + restore(); + } +}); + test("SPA page views include the browser referrer", async () => { const restore = installBrowserGlobals({ url: "https://example.com/next?utm_campaign=summer",