Skip to content

Commit 1ce8e5e

Browse files
authored
Merge pull request #72 from trysurface/agent/custom-domain-script-attribute
[SURF-1836] feat(tag): support custom-domain script attribute
1 parent 84faa92 commit 1ce8e5e

12 files changed

Lines changed: 383 additions & 118 deletions

File tree

README.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,26 @@ cd test && ./serve.sh
3131

3232
Open `http://localhost:8000/test/index.html` to test all embed types.
3333

34+
## Custom Domains
35+
36+
Add `data-custom-domain` to the Surface tag when an environment uses a verified
37+
custom domain:
38+
39+
```html
40+
<script
41+
src="https://cdn.jsdelivr.net/.../surface_tag.min.js"
42+
site-id="your-environment-id"
43+
data-custom-domain="demo.example.com">
44+
</script>
45+
```
46+
47+
The tag sends lead identification, journey tracking, external-form events, and
48+
open-trigger requests to `https://demo.example.com/api/v1`. It also trusts form
49+
iframe messages from `https://demo.example.com`. The value must be an HTTPS
50+
hostname or origin without a path, query string, credentials, or fragment. If
51+
the attribute is absent or invalid, the tag continues to use
52+
`https://forms.withsurface.com`.
53+
3454
## Embedding Types
3555

3656
- **Popup** -- modal overlay triggered by button click

src/conversions/conversion-listener.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ const isConversionMessage = (data: any): data is ConversionMessage =>
3535
// Handles a `surface:conversion` message from a Surface form iframe: fires the
3636
// pixel in this (parent) page, then acks so the iframe knows not to fall back to
3737
// in-frame firing. The caller guarantees the origin is already trusted (checked
38-
// in the shared message listener against SURFACE_DOMAINS).
38+
// in the shared message listener against the runtime Surface-domain allowlist).
3939
export const handleConversionMessage = (event: MessageEvent, log: Logger): void => {
4040
const data = event.data;
4141
if (!isConversionMessage(data)) return;

src/external-form/external-form.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
1-
import { EXTERNAL_FORM_API } from "../constants";
21
import { isDebugMode } from "../utils/debug";
32
import { sendBeacon } from "../utils/beacon";
43
import { getSiteIdFromScript } from "../lead/site-id";
54
import { attachFormHandlers } from "./form-handlers";
65
import type { ExternalFormProps } from "../types";
6+
import { getSurfaceRuntimeConfig } from "../runtime-config";
77

88
export class SurfaceExternalForm {
99
initialRenderTime: Date;
@@ -27,7 +27,7 @@ export class SurfaceExternalForm {
2727
this.formStarted = {};
2828

2929
this.config = {
30-
serverBaseUrl: props?.serverBaseUrl || EXTERNAL_FORM_API,
30+
serverBaseUrl: props?.serverBaseUrl || getSurfaceRuntimeConfig().apiBaseUrl,
3131
debugMode: isDebugMode(),
3232
};
3333

src/index.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,15 @@ import { SurfaceExternalForm } from "./external-form/external-form";
1010
import { SurfaceEmbed } from "./embed/embed";
1111
import { resolveOpenTriggersOnLoad } from "./open-triggers/open-triggers";
1212
import { initReview } from "./review/review";
13+
import { initializeSurfaceRuntimeConfig } from "./runtime-config";
1314

1415
const scriptTag = document.currentScript as HTMLScriptElement;
16+
const runtimeConfig = initializeSurfaceRuntimeConfig(scriptTag);
1517
const environmentId = getSiteIdFromScript(scriptTag);
1618
setEnvironmentId(environmentId);
1719

1820
// Create singleton store
19-
const SurfaceTagStore = new SurfaceStore(environmentId);
21+
const SurfaceTagStore = new SurfaceStore(environmentId, runtimeConfig);
2022

2123
// Expose public API on window (backwards compatible)
2224
const w = window as unknown as Record<string, unknown>;
@@ -30,7 +32,7 @@ w.SurfaceGetSiteIdFromScript = getSiteIdFromScript;
3032

3133
// Auto-open a form when the host URL carries a configured `?<slug>=true` param.
3234
// Fire-and-forget; only touches the network when params are present.
33-
void resolveOpenTriggersOnLoad(environmentId);
35+
void resolveOpenTriggersOnLoad(environmentId, runtimeConfig);
3436

3537
// Surface CMS review bridge. Inert unless the page is loaded inside the CMS
3638
// review iframe (?surface_review= token) — adds no listeners otherwise.

src/lead/identify.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
1-
import { LEAD_DATA_TTL, LEAD_IDENTIFY_API } from "../constants";
1+
import { LEAD_DATA_TTL } from "../constants";
22
import { getBrowserFingerprint } from "./fingerprint";
33
import type { LeadData } from "../types";
4+
import {
5+
getSurfaceRuntimeConfig,
6+
type SurfaceRuntimeConfig,
7+
} from "../runtime-config";
48

59
let environmentId: string | null = null;
610
let identifyInProgress = false;
@@ -49,7 +53,8 @@ export function getLeadDataWithTTL(): LeadData | null {
4953
}
5054

5155
export async function identifyLead(
52-
envId: string
56+
envId: string,
57+
config: SurfaceRuntimeConfig = getSurfaceRuntimeConfig()
5358
): Promise<LeadData | null> {
5459
if (identifyInProgress) {
5560
return waitForCachedData();
@@ -66,7 +71,7 @@ export async function identifyLead(
6671
const fingerprint = await getBrowserFingerprint(envId);
6772
const parentUrl = new URL(window.location.href);
6873

69-
const response = await fetch(LEAD_IDENTIFY_API, {
74+
const response = await fetch(config.leadIdentifyApi, {
7075
method: "POST",
7176
headers: { "Content-Type": "application/json" },
7277
body: JSON.stringify({

src/open-triggers/open-triggers.ts

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
1-
import { EXTERNAL_FORM_API } from "../constants";
21
import { SurfaceEmbed } from "../embed/embed";
32
import { openTriggerOverlay } from "./open-trigger-overlay";
43
import { OpenTriggerEntry, OpenTriggersMap, pickOpenTrigger } from "./resolve";
4+
import {
5+
getSurfaceRuntimeConfig,
6+
type SurfaceRuntimeConfig,
7+
} from "../runtime-config";
58

69
const SESSION_PREFIX = "surface_open_triggers:";
710
// Self-healing cache: re-fetch the map after this long so a slug retargeted/disabled
@@ -30,12 +33,15 @@ interface OverridableWindow {
3033
* present as `?<slug>=true`. Opens a form even if it isn't already embedded on the
3134
* page. No params → zero network. Always fails safe (never breaks the host page).
3235
*/
33-
export async function resolveOpenTriggersOnLoad(environmentId: string | null): Promise<void> {
36+
export async function resolveOpenTriggersOnLoad(
37+
environmentId: string | null,
38+
config: SurfaceRuntimeConfig = getSurfaceRuntimeConfig()
39+
): Promise<void> {
3440
try {
3541
if (!environmentId) return;
3642
if (!window.location.search) return;
3743

38-
const map = await fetchOpenTriggersMap(environmentId);
44+
const map = await fetchOpenTriggersMap(environmentId, config);
3945
const entry = pickOpenTrigger(window.location.search, map);
4046
if (!entry) return;
4147

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

48-
async function fetchOpenTriggersMap(environmentId: string): Promise<OpenTriggersMap | null> {
54+
async function fetchOpenTriggersMap(
55+
environmentId: string,
56+
config: SurfaceRuntimeConfig
57+
): Promise<OpenTriggersMap | null> {
4958
const w = window as unknown as OverridableWindow;
5059

5160
// Test/escape hatch: a directly-injected map bypasses the network entirely.
5261
if (w.__SURFACE_OPEN_TRIGGERS_MAP) return w.__SURFACE_OPEN_TRIGGERS_MAP;
5362

54-
const sessionKey = SESSION_PREFIX + environmentId;
63+
const sessionKey = `${SESSION_PREFIX}${config.apiBaseUrl}:${environmentId}`;
5564
try {
5665
const cached = sessionStorage.getItem(sessionKey);
5766
if (cached) {
@@ -64,7 +73,7 @@ async function fetchOpenTriggersMap(environmentId: string): Promise<OpenTriggers
6473
// sessionStorage unavailable / malformed (e.g. privacy mode) — fall through to a live fetch.
6574
}
6675

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

src/runtime-config.ts

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import {
2+
EXTERNAL_FORM_API,
3+
LEAD_IDENTIFY_API,
4+
SURFACE_DOMAINS,
5+
USER_JOURNEY_TRACKING_API,
6+
} from "./constants";
7+
8+
export const CUSTOM_DOMAIN_ATTRIBUTE = "data-custom-domain";
9+
10+
export interface SurfaceRuntimeConfig {
11+
apiBaseUrl: string;
12+
leadIdentifyApi: string;
13+
userJourneyTrackingApi: string;
14+
surfaceDomains: readonly string[];
15+
customOrigin: string | null;
16+
}
17+
18+
export const DEFAULT_SURFACE_RUNTIME_CONFIG: SurfaceRuntimeConfig = {
19+
apiBaseUrl: EXTERNAL_FORM_API,
20+
leadIdentifyApi: LEAD_IDENTIFY_API,
21+
userJourneyTrackingApi: USER_JOURNEY_TRACKING_API,
22+
surfaceDomains: SURFACE_DOMAINS,
23+
customOrigin: null,
24+
};
25+
26+
let runtimeConfig = DEFAULT_SURFACE_RUNTIME_CONFIG;
27+
28+
function normalizeCustomOrigin(value: string): string | null {
29+
const trimmed = value.trim();
30+
if (!trimmed) return null;
31+
32+
try {
33+
const url = new URL(trimmed.includes("://") ? trimmed : `https://${trimmed}`);
34+
if (
35+
url.protocol !== "https:" ||
36+
url.username ||
37+
url.password ||
38+
url.pathname !== "/" ||
39+
url.search ||
40+
url.hash
41+
) {
42+
return null;
43+
}
44+
45+
return url.origin;
46+
} catch {
47+
return null;
48+
}
49+
}
50+
51+
export function resolveSurfaceRuntimeConfig(
52+
scriptElement: HTMLScriptElement | null
53+
): SurfaceRuntimeConfig {
54+
const customOrigin = normalizeCustomOrigin(
55+
scriptElement?.getAttribute(CUSTOM_DOMAIN_ATTRIBUTE) ?? ""
56+
);
57+
if (!customOrigin) return DEFAULT_SURFACE_RUNTIME_CONFIG;
58+
59+
const apiBaseUrl = `${customOrigin}/api/v1`;
60+
return {
61+
apiBaseUrl,
62+
leadIdentifyApi: `${apiBaseUrl}/lead/identify`,
63+
userJourneyTrackingApi: `${apiBaseUrl}/lead/track`,
64+
surfaceDomains: Array.from(new Set([...SURFACE_DOMAINS, customOrigin])),
65+
customOrigin,
66+
};
67+
}
68+
69+
export function initializeSurfaceRuntimeConfig(
70+
scriptElement: HTMLScriptElement | null
71+
): SurfaceRuntimeConfig {
72+
runtimeConfig = resolveSurfaceRuntimeConfig(scriptElement);
73+
return runtimeConfig;
74+
}
75+
76+
export function getSurfaceRuntimeConfig(): SurfaceRuntimeConfig {
77+
return runtimeConfig;
78+
}

src/store/message-listener.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
1-
import { SURFACE_DOMAINS } from "../constants";
21
import { handleConversionMessage } from "../conversions/conversion-listener";
2+
import { SURFACE_DOMAINS } from "../constants";
33
import { identifyLead, getEnvironmentId } from "../lead/identify";
44
import type { SurfaceStore } from "./store";
55

66
export function initializeMessageListener(store: SurfaceStore): void {
77
const handleMessage = (event: MessageEvent) => {
8-
if (!event.origin || !(SURFACE_DOMAINS as readonly string[]).includes(event.origin)) {
8+
const surfaceDomains = store.surfaceDomains ?? SURFACE_DOMAINS;
9+
if (!event.origin || !surfaceDomains.includes(event.origin)) {
910
return;
1011
}
1112

@@ -19,7 +20,10 @@ export function initializeMessageListener(store: SurfaceStore): void {
1920

2021
const envId = getEnvironmentId();
2122
if (envId) {
22-
identifyLead(envId)
23+
const identify = store.config?.customOrigin
24+
? identifyLead(envId, store.config)
25+
: identifyLead(envId);
26+
identify
2327
.then(() => store.sendPayloadToIframes("LEAD_DATA_UPDATE"))
2428
.catch((e) => console.log("Failed identify", e));
2529
} else {

0 commit comments

Comments
 (0)