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",