@perspective-ai/sdk 1.0.0-alpha.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/README.md +333 -0
  2. package/dist/browser.cjs +1939 -0
  3. package/dist/browser.cjs.map +1 -0
  4. package/dist/browser.d.cts +213 -0
  5. package/dist/browser.d.ts +213 -0
  6. package/dist/browser.js +1900 -0
  7. package/dist/browser.js.map +1 -0
  8. package/dist/cdn/perspective.global.js +406 -0
  9. package/dist/cdn/perspective.global.js.map +1 -0
  10. package/dist/constants.cjs +142 -0
  11. package/dist/constants.cjs.map +1 -0
  12. package/dist/constants.d.cts +104 -0
  13. package/dist/constants.d.ts +104 -0
  14. package/dist/constants.js +127 -0
  15. package/dist/constants.js.map +1 -0
  16. package/dist/index.cjs +1596 -0
  17. package/dist/index.cjs.map +1 -0
  18. package/dist/index.d.cts +155 -0
  19. package/dist/index.d.ts +155 -0
  20. package/dist/index.js +1579 -0
  21. package/dist/index.js.map +1 -0
  22. package/package.json +83 -0
  23. package/src/browser.test.ts +388 -0
  24. package/src/browser.ts +509 -0
  25. package/src/config.test.ts +81 -0
  26. package/src/config.ts +95 -0
  27. package/src/constants.ts +214 -0
  28. package/src/float.test.ts +332 -0
  29. package/src/float.ts +231 -0
  30. package/src/fullpage.test.ts +224 -0
  31. package/src/fullpage.ts +126 -0
  32. package/src/iframe.test.ts +1037 -0
  33. package/src/iframe.ts +421 -0
  34. package/src/index.ts +61 -0
  35. package/src/loading.ts +90 -0
  36. package/src/popup.test.ts +344 -0
  37. package/src/popup.ts +157 -0
  38. package/src/slider.test.ts +277 -0
  39. package/src/slider.ts +158 -0
  40. package/src/styles.ts +395 -0
  41. package/src/types.ts +148 -0
  42. package/src/utils.test.ts +162 -0
  43. package/src/utils.ts +86 -0
  44. package/src/widget.test.ts +375 -0
  45. package/src/widget.ts +195 -0
package/src/browser.ts ADDED
@@ -0,0 +1,509 @@
1
+ /**
2
+ * Perspective Embed SDK - Browser Entry
3
+ *
4
+ * CDN/Script Tag Entry Point - auto-init, attaches to window.Perspective
5
+ *
6
+ * Usage:
7
+ * <script src="https://getperspective.ai/v1/perspective.js"></script>
8
+ *
9
+ * <!-- Auto-init with data attributes -->
10
+ * <div data-perspective-widget="research_xxx"></div>
11
+ * <button data-perspective-popup="research_xxx">Open Survey</button>
12
+ *
13
+ * <!-- Or programmatic -->
14
+ * <script>
15
+ * Perspective.openPopup({ researchId: 'xxx' });
16
+ * </script>
17
+ */
18
+
19
+ import type {
20
+ BrandColors,
21
+ EmbedConfig,
22
+ EmbedHandle,
23
+ FloatHandle,
24
+ ThemeConfig,
25
+ } from "./types";
26
+ import { DATA_ATTRS, THEME_VALUES } from "./constants";
27
+ import { createWidget } from "./widget";
28
+ import { openPopup } from "./popup";
29
+ import { openSlider } from "./slider";
30
+ import { createFloatBubble, createChatBubble } from "./float";
31
+ import { createFullpage } from "./fullpage";
32
+ import { configure, getConfig, hasDom, getHost } from "./config";
33
+ import { resolveIsDark } from "./utils";
34
+
35
+ // Track all active instances
36
+ const instances: Map<string, EmbedHandle | FloatHandle> = new Map();
37
+
38
+ // Theme config cache
39
+ const configCache: Map<string, ThemeConfig> = new Map();
40
+
41
+ type ButtonStyleConfig = {
42
+ themeConfig: ThemeConfig;
43
+ theme?: EmbedConfig["theme"];
44
+ brand?: EmbedConfig["brand"];
45
+ };
46
+ const styledButtons = new Map<HTMLElement, ButtonStyleConfig>();
47
+ let buttonThemeMediaQuery: MediaQueryList | null = null;
48
+
49
+ const DEFAULT_THEME: ThemeConfig = {
50
+ primaryColor: "#7c3aed",
51
+ textColor: "#ffffff",
52
+ darkPrimaryColor: "#a78bfa",
53
+ darkTextColor: "#ffffff",
54
+ };
55
+
56
+ /**
57
+ * Fetch theme config from API (cached)
58
+ */
59
+ async function fetchConfig(researchId: string): Promise<ThemeConfig> {
60
+ if (configCache.has(researchId)) {
61
+ return configCache.get(researchId)!;
62
+ }
63
+
64
+ try {
65
+ const host = getHost();
66
+ const res = await fetch(`${host}/api/v1/embed/config/${researchId}`);
67
+ if (!res.ok) return DEFAULT_THEME;
68
+ const config = await res.json();
69
+ configCache.set(researchId, config);
70
+ return config;
71
+ } catch {
72
+ return DEFAULT_THEME;
73
+ }
74
+ }
75
+
76
+ /**
77
+ * Apply theme styles to a button element
78
+ */
79
+ function styleButton(
80
+ el: HTMLElement,
81
+ themeConfig: ThemeConfig,
82
+ options?: { theme?: EmbedConfig["theme"]; brand?: EmbedConfig["brand"] }
83
+ ): void {
84
+ if (el.hasAttribute(DATA_ATTRS.noStyle)) return;
85
+
86
+ styledButtons.set(el, {
87
+ themeConfig,
88
+ theme: options?.theme,
89
+ brand: options?.brand,
90
+ });
91
+
92
+ updateButtonTheme(el, {
93
+ themeConfig,
94
+ theme: options?.theme,
95
+ brand: options?.brand,
96
+ });
97
+ }
98
+
99
+ /**
100
+ * Update button styles based on theme
101
+ */
102
+ function updateButtonTheme(el: HTMLElement, config: ButtonStyleConfig): void {
103
+ const { themeConfig, theme, brand } = config;
104
+ const isDark = resolveIsDark(theme);
105
+
106
+ const bg = isDark
107
+ ? (brand?.dark?.primary ?? themeConfig.darkPrimaryColor)
108
+ : (brand?.light?.primary ?? themeConfig.primaryColor);
109
+ const text = isDark
110
+ ? (brand?.dark?.text ?? themeConfig.darkTextColor)
111
+ : (brand?.light?.text ?? themeConfig.textColor);
112
+
113
+ el.style.backgroundColor = bg;
114
+ el.style.color = text;
115
+ el.style.padding = "10px 20px";
116
+ el.style.border = "none";
117
+ el.style.borderRadius = "8px";
118
+ el.style.fontWeight = "500";
119
+ el.style.cursor = "pointer";
120
+ }
121
+
122
+ /**
123
+ * Update all styled buttons when theme changes
124
+ */
125
+ function updateAllButtonThemes(): void {
126
+ styledButtons.forEach((config, el) => {
127
+ if (document.contains(el)) {
128
+ updateButtonTheme(el, config);
129
+ } else {
130
+ styledButtons.delete(el);
131
+ }
132
+ });
133
+ }
134
+
135
+ let buttonThemeListener: ((e: MediaQueryListEvent) => void) | null = null;
136
+
137
+ function setupButtonThemeListener(): void {
138
+ if (buttonThemeListener || !hasDom()) return;
139
+
140
+ buttonThemeMediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
141
+ buttonThemeListener = () => updateAllButtonThemes();
142
+ buttonThemeMediaQuery.addEventListener("change", buttonThemeListener);
143
+ }
144
+
145
+ function teardownButtonThemeListener(): void {
146
+ if (buttonThemeListener && buttonThemeMediaQuery) {
147
+ buttonThemeMediaQuery.removeEventListener("change", buttonThemeListener);
148
+ buttonThemeListener = null;
149
+ buttonThemeMediaQuery = null;
150
+ }
151
+ }
152
+
153
+ /**
154
+ * Parse params from data attribute (format: "key1=value1,key2=value2")
155
+ */
156
+ function parseParamsAttr(el: HTMLElement): Record<string, string> | undefined {
157
+ const paramsStr = el.getAttribute(DATA_ATTRS.params);
158
+ if (!paramsStr) return undefined;
159
+
160
+ const params: Record<string, string> = {};
161
+ for (const pair of paramsStr.split(",")) {
162
+ const [key, ...valueParts] = pair.trim().split("=");
163
+ if (key) {
164
+ params[key.trim()] = valueParts.join("=").trim();
165
+ }
166
+ }
167
+ return Object.keys(params).length > 0 ? params : undefined;
168
+ }
169
+
170
+ /**
171
+ * Parse brand colors from data attribute (format: "primary=#xxx,bg=#yyy")
172
+ */
173
+ function parseBrandAttr(attrValue: string | null): BrandColors | undefined {
174
+ if (!attrValue) return undefined;
175
+
176
+ const colors: BrandColors = {};
177
+ for (const pair of attrValue.split(",")) {
178
+ const [key, ...valueParts] = pair.trim().split("=");
179
+ if (key && valueParts.length > 0) {
180
+ const value = valueParts.join("=").trim();
181
+ if (value) {
182
+ const k = key.trim() as keyof BrandColors;
183
+ if (
184
+ k === "primary" ||
185
+ k === "secondary" ||
186
+ k === "bg" ||
187
+ k === "text"
188
+ ) {
189
+ colors[k] = value;
190
+ }
191
+ }
192
+ }
193
+ }
194
+ return Object.keys(colors).length > 0 ? colors : undefined;
195
+ }
196
+
197
+ /**
198
+ * Extract brand config and theme from element attributes
199
+ */
200
+ function extractBrandConfig(
201
+ el: HTMLElement
202
+ ): Pick<EmbedConfig, "brand" | "theme"> {
203
+ const light = parseBrandAttr(el.getAttribute(DATA_ATTRS.brand));
204
+ const dark = parseBrandAttr(el.getAttribute(DATA_ATTRS.brandDark));
205
+ const themeAttr = el.getAttribute(DATA_ATTRS.theme);
206
+
207
+ const config: Pick<EmbedConfig, "brand" | "theme"> = {};
208
+
209
+ if (light || dark) {
210
+ config.brand = {};
211
+ if (light) config.brand.light = light;
212
+ if (dark) config.brand.dark = dark;
213
+ }
214
+
215
+ if (
216
+ themeAttr === THEME_VALUES.dark ||
217
+ themeAttr === THEME_VALUES.light ||
218
+ themeAttr === THEME_VALUES.system
219
+ ) {
220
+ config.theme = themeAttr;
221
+ }
222
+
223
+ return config;
224
+ }
225
+
226
+ /**
227
+ * Initialize an embed programmatically
228
+ */
229
+ function init(config: EmbedConfig): EmbedHandle | FloatHandle {
230
+ const { researchId } = config;
231
+ // Normalize legacy "chat" type to "float"
232
+ const type = config.type === "chat" ? "float" : (config.type ?? "widget");
233
+
234
+ // Destroy existing instance for this research
235
+ if (instances.has(researchId)) {
236
+ instances.get(researchId)!.unmount();
237
+ instances.delete(researchId);
238
+ }
239
+
240
+ let instance: EmbedHandle | FloatHandle;
241
+
242
+ switch (type) {
243
+ case "popup":
244
+ instance = openPopup(config);
245
+ break;
246
+ case "slider":
247
+ instance = openSlider(config);
248
+ break;
249
+ case "float":
250
+ instance = createFloatBubble(config);
251
+ break;
252
+ case "fullpage":
253
+ instance = createFullpage(config);
254
+ break;
255
+ default:
256
+ throw new Error(
257
+ `Unknown embed type "${type}". Valid types: popup, slider, float, fullpage (use init()), or widget (use mount()).`
258
+ );
259
+ }
260
+
261
+ instances.set(researchId, instance);
262
+ return instance;
263
+ }
264
+
265
+ /**
266
+ * Mount a widget into a container element
267
+ */
268
+ function mount(
269
+ container: HTMLElement | string,
270
+ config: EmbedConfig
271
+ ): EmbedHandle {
272
+ const { researchId } = config;
273
+ const type = config.type === "chat" ? "float" : (config.type ?? "widget");
274
+
275
+ const el =
276
+ typeof container === "string"
277
+ ? document.querySelector<HTMLElement>(container)
278
+ : container;
279
+
280
+ if (!el) {
281
+ throw new Error(`Container not found: ${container}`);
282
+ }
283
+
284
+ // Destroy existing instance
285
+ if (instances.has(researchId)) {
286
+ instances.get(researchId)!.unmount();
287
+ instances.delete(researchId);
288
+ }
289
+
290
+ let instance: EmbedHandle;
291
+
292
+ switch (type) {
293
+ case "widget":
294
+ instance = createWidget(el, config);
295
+ break;
296
+ default:
297
+ // For popup/slider/float, just use init - container not used
298
+ instance = init({ ...config, type }) as EmbedHandle;
299
+ return instance;
300
+ }
301
+
302
+ instances.set(researchId, instance);
303
+ return instance;
304
+ }
305
+
306
+ /**
307
+ * Destroy an embed instance
308
+ */
309
+ function destroy(researchId: string): void {
310
+ const instance = instances.get(researchId);
311
+ if (instance) {
312
+ instance.unmount();
313
+ instances.delete(researchId);
314
+ }
315
+ }
316
+
317
+ function destroyAll(): void {
318
+ instances.forEach((instance) => instance.unmount());
319
+ instances.clear();
320
+ styledButtons.clear();
321
+ teardownButtonThemeListener();
322
+ }
323
+
324
+ /**
325
+ * Auto-initialize embeds from data attributes
326
+ */
327
+ function autoInit(): void {
328
+ if (!hasDom()) return;
329
+
330
+ setupButtonThemeListener();
331
+
332
+ // Widget embeds
333
+ document
334
+ .querySelectorAll<HTMLElement>(`[${DATA_ATTRS.widget}]`)
335
+ .forEach((el) => {
336
+ const researchId = el.getAttribute(DATA_ATTRS.widget);
337
+ if (researchId && !instances.has(researchId)) {
338
+ const params = parseParamsAttr(el);
339
+ const brandConfig = extractBrandConfig(el);
340
+ mount(el, { researchId, type: "widget", params, ...brandConfig });
341
+ }
342
+ });
343
+
344
+ // Fullpage embeds
345
+ document
346
+ .querySelectorAll<HTMLElement>(`[${DATA_ATTRS.fullpage}]`)
347
+ .forEach((el) => {
348
+ const researchId = el.getAttribute(DATA_ATTRS.fullpage);
349
+ if (researchId && !instances.has(researchId)) {
350
+ const params = parseParamsAttr(el);
351
+ const brandConfig = extractBrandConfig(el);
352
+ init({ researchId, type: "fullpage", params, ...brandConfig });
353
+ }
354
+ });
355
+
356
+ // Popup triggers
357
+ document
358
+ .querySelectorAll<HTMLElement>(`[${DATA_ATTRS.popup}]`)
359
+ .forEach((el) => {
360
+ if (el.hasAttribute("data-perspective-initialized")) return;
361
+ el.setAttribute("data-perspective-initialized", "true");
362
+
363
+ const researchId = el.getAttribute(DATA_ATTRS.popup);
364
+ if (researchId) {
365
+ const params = parseParamsAttr(el);
366
+ const brandConfig = extractBrandConfig(el);
367
+ styleButton(el, DEFAULT_THEME, brandConfig);
368
+ el.addEventListener("click", (e) => {
369
+ e.preventDefault();
370
+ init({ researchId, type: "popup", params, ...brandConfig });
371
+ });
372
+ fetchConfig(researchId).then((config) => {
373
+ styleButton(el, config, brandConfig);
374
+ });
375
+ }
376
+ });
377
+
378
+ // Slider triggers
379
+ document
380
+ .querySelectorAll<HTMLElement>(`[${DATA_ATTRS.slider}]`)
381
+ .forEach((el) => {
382
+ if (el.hasAttribute("data-perspective-initialized")) return;
383
+ el.setAttribute("data-perspective-initialized", "true");
384
+
385
+ const researchId = el.getAttribute(DATA_ATTRS.slider);
386
+ if (researchId) {
387
+ const params = parseParamsAttr(el);
388
+ const brandConfig = extractBrandConfig(el);
389
+ styleButton(el, DEFAULT_THEME, brandConfig);
390
+ el.addEventListener("click", (e) => {
391
+ e.preventDefault();
392
+ init({ researchId, type: "slider", params, ...brandConfig });
393
+ });
394
+ fetchConfig(researchId).then((config) => {
395
+ styleButton(el, config, brandConfig);
396
+ });
397
+ }
398
+ });
399
+
400
+ // Float bubble - supports both data-perspective-float and data-perspective-chat (legacy)
401
+ const floatSelector = `[${DATA_ATTRS.float}], [${DATA_ATTRS.chat}]`;
402
+ const floatEl = document.querySelector<HTMLElement>(floatSelector);
403
+ if (floatEl) {
404
+ const researchId =
405
+ floatEl.getAttribute(DATA_ATTRS.float) ||
406
+ floatEl.getAttribute(DATA_ATTRS.chat);
407
+ if (researchId && !instances.has(researchId)) {
408
+ const params = parseParamsAttr(floatEl);
409
+ const brandConfig = extractBrandConfig(floatEl);
410
+ init({
411
+ researchId,
412
+ type: "float",
413
+ params,
414
+ ...brandConfig,
415
+ _themeConfig: DEFAULT_THEME,
416
+ } as EmbedConfig & { _themeConfig: ThemeConfig });
417
+
418
+ fetchConfig(researchId).then((config) => {
419
+ // Update bubble color with fetched theme
420
+ const bubble = document.querySelector<HTMLElement>(
421
+ '[data-perspective="float-bubble"]'
422
+ );
423
+ if (bubble && !floatEl.hasAttribute(DATA_ATTRS.noStyle)) {
424
+ const isDark = resolveIsDark(brandConfig.theme);
425
+ const bg = isDark
426
+ ? (brandConfig.brand?.dark?.primary ?? config.darkPrimaryColor)
427
+ : (brandConfig.brand?.light?.primary ?? config.primaryColor);
428
+ bubble.style.setProperty("--perspective-float-bg", bg);
429
+ bubble.style.setProperty(
430
+ "--perspective-float-shadow",
431
+ `0 4px 12px ${bg}66`
432
+ );
433
+ bubble.style.setProperty(
434
+ "--perspective-float-shadow-hover",
435
+ `0 6px 16px ${bg}80`
436
+ );
437
+ bubble.style.backgroundColor = bg;
438
+ bubble.style.boxShadow = `0 4px 12px ${bg}66`;
439
+ }
440
+ });
441
+ }
442
+ }
443
+ }
444
+
445
+ // Build the public API
446
+ const Perspective = {
447
+ // Configuration
448
+ configure,
449
+ getConfig,
450
+
451
+ // Instance management
452
+ init,
453
+ mount,
454
+ destroy,
455
+ destroyAll,
456
+ autoInit,
457
+
458
+ // Direct creation functions (primary API)
459
+ createWidget,
460
+ openPopup,
461
+ openSlider,
462
+ createFloatBubble,
463
+ createFullpage,
464
+
465
+ // Legacy alias
466
+ createChatBubble,
467
+ };
468
+
469
+ declare global {
470
+ interface Window {
471
+ __PERSPECTIVE_SDK_INITIALIZED__?: boolean;
472
+ Perspective?: typeof Perspective;
473
+ }
474
+ }
475
+
476
+ // Prevent duplicate initialization when script is loaded multiple times
477
+ // (e.g., SPAs, tag managers, hot-reload, or accidental double-include).
478
+ // Without this guard, each script evaluation would register new listeners
479
+ // and create isolated module state, causing memory leaks and duplicate handlers.
480
+ if (hasDom() && !window.__PERSPECTIVE_SDK_INITIALIZED__) {
481
+ window.__PERSPECTIVE_SDK_INITIALIZED__ = true;
482
+
483
+ if (document.readyState === "loading") {
484
+ document.addEventListener("DOMContentLoaded", autoInit, { once: true });
485
+ } else {
486
+ autoInit();
487
+ }
488
+
489
+ window.Perspective = Perspective;
490
+ }
491
+
492
+ // Export for module usage
493
+ export {
494
+ configure,
495
+ getConfig,
496
+ init,
497
+ mount,
498
+ destroy,
499
+ destroyAll,
500
+ autoInit,
501
+ createWidget,
502
+ openPopup,
503
+ openSlider,
504
+ createFloatBubble,
505
+ createChatBubble,
506
+ createFullpage,
507
+ };
508
+
509
+ export default Perspective;
@@ -0,0 +1,81 @@
1
+ import { describe, it, expect, beforeEach, vi, afterEach } from "vitest";
2
+
3
+ describe("config", () => {
4
+ let configure: typeof import("./config").configure;
5
+ let getConfig: typeof import("./config").getConfig;
6
+ let hasDom: typeof import("./config").hasDom;
7
+ let getHost: typeof import("./config").getHost;
8
+
9
+ beforeEach(async () => {
10
+ // Reset module state between tests to avoid state leakage
11
+ vi.resetModules();
12
+ const configModule = await import("./config");
13
+ configure = configModule.configure;
14
+ getConfig = configModule.getConfig;
15
+ hasDom = configModule.hasDom;
16
+ getHost = configModule.getHost;
17
+ });
18
+
19
+ afterEach(() => {
20
+ vi.restoreAllMocks();
21
+ });
22
+
23
+ describe("configure", () => {
24
+ it("sets global config", () => {
25
+ configure({ host: "https://example.com" });
26
+ expect(getConfig()).toEqual({ host: "https://example.com" });
27
+ });
28
+
29
+ it("merges with existing config", () => {
30
+ configure({ host: "https://example.com" });
31
+ configure({ host: "https://other.com" });
32
+ expect(getConfig()).toEqual({ host: "https://other.com" });
33
+ });
34
+ });
35
+
36
+ describe("getConfig", () => {
37
+ it("returns a copy of config", () => {
38
+ configure({ host: "https://example.com" });
39
+ const config1 = getConfig();
40
+ const config2 = getConfig();
41
+ expect(config1).toEqual(config2);
42
+ expect(config1).not.toBe(config2); // Different object references
43
+ });
44
+ });
45
+
46
+ describe("hasDom", () => {
47
+ it("returns true when window and document exist", () => {
48
+ expect(hasDom()).toBe(true);
49
+ });
50
+ });
51
+
52
+ describe("getHost", () => {
53
+ it("returns instance host if provided", () => {
54
+ expect(getHost("https://instance.example.com")).toBe(
55
+ "https://instance.example.com"
56
+ );
57
+ });
58
+
59
+ it("normalizes URL to origin", () => {
60
+ expect(getHost("https://example.com/some/path?query=1")).toBe(
61
+ "https://example.com"
62
+ );
63
+ });
64
+
65
+ it("returns global config host if no instance host", () => {
66
+ configure({ host: "https://global.example.com" });
67
+ expect(getHost()).toBe("https://global.example.com");
68
+ });
69
+
70
+ it("instance host takes precedence over global", () => {
71
+ configure({ host: "https://global.example.com" });
72
+ expect(getHost("https://instance.example.com")).toBe(
73
+ "https://instance.example.com"
74
+ );
75
+ });
76
+
77
+ it("returns default host when no config", () => {
78
+ expect(getHost()).toBe("https://getperspective.ai");
79
+ });
80
+ });
81
+ });
package/src/config.ts ADDED
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Embed SDK configuration
3
+ * SSR-safe - DOM access is guarded and lazy
4
+ */
5
+
6
+ import type { SDKConfig } from "./types";
7
+
8
+ /** Default production host */
9
+ const DEFAULT_HOST = "https://getperspective.ai";
10
+
11
+ /** Global SDK configuration - can be set before creating embeds */
12
+ let globalConfig: SDKConfig = {};
13
+
14
+ /**
15
+ * Configure the SDK globally
16
+ * Call this before creating any embeds if you need to override defaults
17
+ */
18
+ export function configure(config: SDKConfig): void {
19
+ globalConfig = { ...globalConfig, ...config };
20
+ }
21
+
22
+ /**
23
+ * Get the current SDK configuration
24
+ */
25
+ export function getConfig(): SDKConfig {
26
+ return { ...globalConfig };
27
+ }
28
+
29
+ /**
30
+ * Check if DOM is available (SSR safety)
31
+ */
32
+ export function hasDom(): boolean {
33
+ return typeof window !== "undefined" && typeof document !== "undefined";
34
+ }
35
+
36
+ function normalizeToOrigin(host: string): string {
37
+ try {
38
+ return new URL(host).origin;
39
+ } catch {
40
+ return host;
41
+ }
42
+ }
43
+
44
+ export function getHost(instanceHost?: string): string {
45
+ // Instance-level override
46
+ if (instanceHost) {
47
+ return normalizeToOrigin(instanceHost);
48
+ }
49
+
50
+ // Global config override
51
+ if (globalConfig.host) {
52
+ return normalizeToOrigin(globalConfig.host);
53
+ }
54
+
55
+ // Try to infer from script src (only in browser, only at load time)
56
+ if (hasDom()) {
57
+ const scriptHost = getScriptHost();
58
+ if (scriptHost) {
59
+ return scriptHost;
60
+ }
61
+ }
62
+
63
+ return DEFAULT_HOST;
64
+ }
65
+
66
+ // Capture script src at load time - document.currentScript only available during initial execution
67
+ let capturedScriptHost: string | null = null;
68
+
69
+ function getScriptHost(): string | null {
70
+ if (capturedScriptHost !== null) {
71
+ return capturedScriptHost;
72
+ }
73
+
74
+ if (!hasDom()) {
75
+ return null;
76
+ }
77
+
78
+ const currentScript = document.currentScript as HTMLScriptElement | null;
79
+ if (currentScript?.src) {
80
+ try {
81
+ capturedScriptHost = new URL(currentScript.src).origin;
82
+ return capturedScriptHost;
83
+ } catch {
84
+ // Invalid URL, ignore
85
+ }
86
+ }
87
+
88
+ capturedScriptHost = ""; // Mark as attempted
89
+ return null;
90
+ }
91
+
92
+ // Capture script host immediately when module loads (in browser)
93
+ if (hasDom()) {
94
+ getScriptHost();
95
+ }