Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
<script
src="https://cdn.jsdelivr.net/.../surface_tag.min.js"
site-id="your-environment-id"
data-custom-domain="demo.example.com">
</script>
```

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
Expand Down
2 changes: 1 addition & 1 deletion src/conversions/conversion-listener.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
31 changes: 31 additions & 0 deletions src/external-form/external-form.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
4 changes: 2 additions & 2 deletions src/external-form/external-form.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -27,7 +27,7 @@ export class SurfaceExternalForm {
this.formStarted = {};

this.config = {
serverBaseUrl: props?.serverBaseUrl || EXTERNAL_FORM_API,
serverBaseUrl: props?.serverBaseUrl || getSurfaceRuntimeConfig().apiBaseUrl,
debugMode: isDebugMode(),
};

Expand Down
6 changes: 4 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>;
Expand All @@ -30,7 +32,7 @@ w.SurfaceGetSiteIdFromScript = getSiteIdFromScript;

// Auto-open a form when the host URL carries a configured `?<slug>=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.
Expand Down
39 changes: 39 additions & 0 deletions src/lead/identify.test.ts
Original file line number Diff line number Diff line change
@@ -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" })
);
});
});
11 changes: 8 additions & 3 deletions src/lead/identify.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -49,7 +53,8 @@ export function getLeadDataWithTTL(): LeadData | null {
}

export async function identifyLead(
envId: string
envId: string,
config: SurfaceRuntimeConfig = getSurfaceRuntimeConfig()
): Promise<LeadData | null> {
if (identifyInProgress) {
return waitForCachedData();
Expand All @@ -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({
Expand Down
21 changes: 15 additions & 6 deletions src/open-triggers/open-triggers.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -30,12 +33,15 @@ interface OverridableWindow {
* present as `?<slug>=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<void> {
export async function resolveOpenTriggersOnLoad(
environmentId: string | null,
config: SurfaceRuntimeConfig = getSurfaceRuntimeConfig()
): Promise<void> {
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;

Expand All @@ -45,13 +51,16 @@ export async function resolveOpenTriggersOnLoad(environmentId: string | null): P
}
}

async function fetchOpenTriggersMap(environmentId: string): Promise<OpenTriggersMap | null> {
async function fetchOpenTriggersMap(
environmentId: string,
config: SurfaceRuntimeConfig
): Promise<OpenTriggersMap | null> {
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) {
Expand All @@ -64,7 +73,7 @@ async function fetchOpenTriggersMap(environmentId: string): Promise<OpenTriggers
// sessionStorage unavailable / malformed (e.g. privacy mode) — fall through to a live fetch.
}

const base = w.__SURFACE_OPEN_TRIGGERS_BASE || EXTERNAL_FORM_API;
const base = w.__SURFACE_OPEN_TRIGGERS_BASE || config.apiBaseUrl;
const response = await fetch(`${base}/environments/${encodeURIComponent(environmentId)}/open-triggers`);
if (!response.ok) return null;

Expand Down
53 changes: 53 additions & 0 deletions src/runtime-config.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { describe, expect, it } from "vitest";
import {
DEFAULT_SURFACE_RUNTIME_CONFIG,
resolveSurfaceRuntimeConfig,
} from "./runtime-config";

const scriptWithCustomDomain = (value: string): HTMLScriptElement => {
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
);
});
});
78 changes: 78 additions & 0 deletions src/runtime-config.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Loading
Loading