@fullstory/browser 2.1.0-beta.0 → 2.1.1

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.
package/README.md CHANGED
@@ -4,6 +4,8 @@
4
4
 
5
5
  Fullstory's browser SDK lets you manage Fullstory data capture on your site as well as retrieve deep links to session replays and send your own custom events. More information about the Fullstory API can be found at https://developer.fullstory.com.
6
6
 
7
+ Version 2.1.0 updates the sdk to use v2.1 of the Fullstory snippet. No external updates are required. Please reference our developer docs for a list of [what's new in Snippet 2.1](https://developer.fullstory.com/browser/getting-started/#whats-new-in-version-21).
8
+
7
9
  > **NOTE:** this is the documentation for version 2. For version 1 documentation, please see [@fullstory/browser@1.7.1](https://www.npmjs.com/package/@fullstory/browser/v/1.7.1).
8
10
 
9
11
  ## Install the SDK
@@ -71,8 +73,8 @@ The only required option is `orgId`, all others are optional.
71
73
 
72
74
  * `orgId` - Sets your Fullstory Org Id. Find out how to get your Org Id [here](https://help.fullstory.com/hc/en-us/articles/360047075853).
73
75
  * `debug` - When set to `true`, enables Fullstory debug messages; defaults to `false`.
74
- * `host` - The recording server host domain. Can be set to direct recorded events to a proxy that you host. Defaults to `fullstory.com`.
75
- * `script` - Fullstory script host domain. Fullstory hosts the `fs.js` recording script on a CDN, but you can choose to host a copy yourself. Defaults to `edge.fullstory.com`.
76
+ * `host` - The recording server host domain. Can be set to direct recorded events to a proxy that you host. Defaults to `fullstory.com`, or to the region-specific equivalent (e.g. `eu1.fullstory.com`) when your `orgId` carries a region suffix.
77
+ * `script` - Fullstory script host domain. Fullstory hosts the `fs.js` recording script on a CDN, but you can choose to host a copy yourself. Defaults to `edge.fullstory.com/s/fs.js`, or to the region-specific equivalent (e.g. `edge.eu1.fullstory.com/s/fs.js`) when your `orgId` carries a region suffix.
76
78
  * `namespace` - Sets the global identifier for Fullstory when conflicts with `FS` arise; see [help](https://help.fullstory.com/hc/en-us/articles/360020624694-What-if-the-identifier-FS-is-used-by-another-script-on-my-site-).
77
79
  * `cookieDomain` - Overrides the cookie domain. By default, cookies will be valid for all subdomains of your site; if you want to limit the cookies to a specific subdomain, you can set the domain value explicitly. More information can be found [here](https://help.fullstory.com/hc/en-us/articles/360020622874-Can-the-Fullstory-cookie-be-associated-with-a-specific-subdomain-).
78
80
  * `recordCrossDomainIFrames` - Defaults to `false`. Fullstory can record cross-domain iFrames if: 1. The Fullstory Browser SDK is running in the cross-domain iFrame and 2. `recordCrossDomainIFrames` is set to `true` in the cross-domain iFrame and 3. The Fullstory Browser SDK is running in the parent page of the cross-domain iFrame. Click [here](https://developer.mozilla.org/en-US/docs/Web/Security/Same-origin_policy) for a detailed explanation of what "cross-domain" means. Before using, you should understand the security implications, and configure your [Content Security Policy](https://www.html5rocks.com/en/tutorials/security/content-security-policy/) (CSP) HTTP headers accordingly - specifically the frame-ancestors directive. Failure to configure your CSP headers while using this setting can bypass IFrames security protections that are included in modern browsers. More information about cross-domain iFrame recording can be found on our [Knowledge Base](https://help.fullstory.com/hc/en-us/articles/360020622514-Can-Fullstory-capture-content-that-is-presented-in-iframes-#2-the-outer-page-is-running-fullstory-and-you-have-iframes-runni). Note: the `recordCrossDomainIFrames` parameter is the same as the `window['_fs_run_in_iframe']` referenced in the KB article.
@@ -82,6 +84,17 @@ The only required option is `orgId`, all others are optional.
82
84
  * `assetMapId` - Use this to set the current asset map id. See [Asset Uploading for Web](https://help.fullstory.com/hc/en-us/articles/4404129191575-Asset-Uploading-for-Web) for more information.
83
85
  * `appHost` - Use this to set the app host for displaying session urls. If using a version of [Fullstory Relay](https://help.fullstory.com/hc/en-us/articles/360046112593-How-to-send-captured-traffic-to-your-First-Party-Domain-using-Fullstory-Relay), you may need to set `appHost` "app.fullstory.com" or "app.eu1.fullstory.com" depending on your region.
84
86
 
87
+ ### Data center regions
88
+
89
+ Your `orgId` encodes the data center your org lives in — for example the `eu1` in `o-ABC123-eu1`. The SDK reads that suffix and points `host`, `script` and `appHost` at the matching region, so an org outside the default (`na1`) data center does not need to configure anything beyond `orgId`:
90
+
91
+ ```javascript
92
+ FullStory.init({ orgId: 'o-ABC123-eu1' });
93
+ // loads https://edge.eu1.fullstory.com/s/fs.js and records to eu1.fullstory.com
94
+ ```
95
+
96
+ The region is applied to any of those three options that resolve to a Fullstory-owned domain, **including values you pass explicitly** — so `host: "fullstory.com"` on an `eu1` org becomes `eu1.fullstory.com` rather than silently recording to the wrong data center. Hosts Fullstory does not own, such as a proxy or Fullstory Relay domain of your own, are always used exactly as you provide them.
97
+
85
98
  ### Ready Callback
86
99
 
87
100
  The `init` function also accepts an optional `readyCallback` argument. If you provide a function, it will be invoked when the Fullstory session has started. Your callback will be called with one parameter: an object containing information about the session. Currently the only property is `sessionUrl`, which is a string containing the URL to the session.
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Region resolution for the Fullstory host/script options.
3
+ *
4
+ * `fs.js` derives an org's region by parsing the locale suffix off its orgId (see
5
+ * `orgLocale()` / `fullstoryHost()` in the monorepo's `packages/recording/src/fsglobals.ts`),
6
+ * but that only runs once `fs.js` has already loaded — far too late to fix the URL that
7
+ * fetched it. So the same resolution has to happen here, before the snippet injects its
8
+ * `<script>` tag, or an EU org that sets nothing but `orgId` fetches `fs.js` and its
9
+ * settings from the na1 edge, which does not serve that org (VAL-10545).
10
+ */
11
+ /**
12
+ * The `host` / `script` defaults applied by `@fullstory/snippet`. Duplicated here because
13
+ * the package does not export them, and we need the default value in hand to regionalize
14
+ * it. Keep in sync with the defaults in that package's `initFS` / `executeSnippet` when
15
+ * upgrading the dependency.
16
+ */
17
+ export declare const DEFAULT_HOST = "fullstory.com";
18
+ export declare const DEFAULT_SCRIPT = "edge.fullstory.com/s/fs.js";
19
+ /**
20
+ * Parses the region/locale suffix out of an orgId, e.g. "o-7Y9H-eu1" -> "eu1". Returns
21
+ * `undefined` for na1 and for orgIds that carry no region, meaning "apply no region".
22
+ * Mirrors `orgLocale()` in the monorepo's `packages/recording/src/fsglobals.ts`.
23
+ */
24
+ export declare const orgLocale: (orgId?: string) => string | undefined;
25
+ /**
26
+ * Applies an org's region to a Fullstory-owned host, inserting the locale as the label
27
+ * immediately before the registrable domain and preserving any path:
28
+ *
29
+ * ("fullstory.com", "eu1") -> "eu1.fullstory.com"
30
+ * ("edge.fullstory.com/s/fs.js", "eu1") -> "edge.eu1.fullstory.com/s/fs.js"
31
+ *
32
+ * Hosts Fullstory does not own — self-hosted proxies, Relay, localhost — are returned
33
+ * untouched, as are hosts already carrying the right region (so this is idempotent and
34
+ * safe to stack with the same resolution in `fs.js`).
35
+ */
36
+ export declare const regionalize: (value: string, locale?: string) => string;
package/dist/index.d.ts CHANGED
@@ -5,13 +5,17 @@ import { FSApi } from '@fullstory/snippet';
5
5
  * - orgId: Reference for your [Org Id](https://help.fullstory.com/hc/en-us/articles/360047075853) listed in FullStory.
6
6
  * - namespace: Global object name that contains the FullStory browser API methods and properties. Defaults to `FS`.
7
7
  * - debug: Debug mode with extra browser console logging.
8
- * - host: The recording server host domain. Can be set to direct recorded events to a proxy that you host. Defaults to `fullstory.com`.
9
- * - script: FullStory script host domain. FullStory hosts the `fs.js` recording script on a CDN, but you can choose to host a copy yourself. Defaults to `edge.fullstory.com`.
8
+ * - host: The recording server host domain. Can be set to direct recorded events to a proxy that you host. Defaults to `fullstory.com`, or the region-specific equivalent (e.g. `eu1.fullstory.com`) when `orgId` carries a region suffix.
9
+ * - script: FullStory script host domain. FullStory hosts the `fs.js` recording script on a CDN, but you can choose to host a copy yourself. Defaults to `edge.fullstory.com/s/fs.js`, or the region-specific equivalent (e.g. `edge.eu1.fullstory.com/s/fs.js`) when `orgId` carries a region suffix.
10
10
  * - cookieDomain: Overrides the cookie domain. By default, cookies will be valid for all subdomains of your site; if you want to limit the cookies to a specific subdomain, you can set the domain value explicitly. More information can be found [here](https://help.fullstory.com/hc/en-us/articles/360020622874-Can-the-FullStory-cookie-be-associated-with-a-specific-subdomain-).
11
11
  * - recordCrossDomainIFrames: FullStory can record cross-domain iFrames. Defaults to `false`. Certain limitations apply and can be found [here](https://help.fullstory.com/hc/en-us/articles/360020622514-Can-FullStory-capture-content-that-is-presented-in-iframes-#h_01F1G333PYKPGZ4B42WDBV3YKV).
12
12
  * - recordOnlyThisIFrame: FullStory can record the iFrame as its own unique session. Defaults to `false`. Additional conditions apply and can be found [here](https://help.fullstory.com/hc/en-us/articles/360020622514-Can-FullStory-capture-content-that-is-presented-in-iframes-#h_01F1G33B40Q2TPQA8MA7SF8Y5P).
13
13
  * - devMode: In dev mode FullStory won't record sessions. Any calls to SDK methods will `console.warn` that FullStory is in `devMode`. Defaults to `false`.
14
14
  * - sessionUid (beta): Sets the session UID passed to the `FS('init', ...)` v2 operation.
15
+ *
16
+ * The region encoded in `orgId` (e.g. the `eu1` in `o-ABC123-eu1`) is applied to `host`, `script` and `appHost`
17
+ * whenever those resolve to a FullStory-owned domain, including values you set explicitly. Hosts FullStory does
18
+ * not own — a proxy or Relay domain of your own — are always used exactly as given.
15
19
  */
16
20
  export interface SnippetOptions {
17
21
  orgId: string;
package/dist/index.esm.js CHANGED
@@ -33,6 +33,89 @@ typeof SuppressedError === "function" ? SuppressedError : function (error, suppr
33
33
  return e.name = "SuppressedError", e.error = error, e.suppressed = suppressed, e;
34
34
  };
35
35
 
36
+ /**
37
+ * Region resolution for the Fullstory host/script options.
38
+ *
39
+ * `fs.js` derives an org's region by parsing the locale suffix off its orgId (see
40
+ * `orgLocale()` / `fullstoryHost()` in the monorepo's `packages/recording/src/fsglobals.ts`),
41
+ * but that only runs once `fs.js` has already loaded — far too late to fix the URL that
42
+ * fetched it. So the same resolution has to happen here, before the snippet injects its
43
+ * `<script>` tag, or an EU org that sets nothing but `orgId` fetches `fs.js` and its
44
+ * settings from the na1 edge, which does not serve that org (VAL-10545).
45
+ */
46
+ /**
47
+ * The `host` / `script` defaults applied by `@fullstory/snippet`. Duplicated here because
48
+ * the package does not export them, and we need the default value in hand to regionalize
49
+ * it. Keep in sync with the defaults in that package's `initFS` / `executeSnippet` when
50
+ * upgrading the dependency.
51
+ */
52
+ var DEFAULT_HOST = 'fullstory.com';
53
+ var DEFAULT_SCRIPT = 'edge.fullstory.com/s/fs.js';
54
+ /**
55
+ * Matches any subdomain of "fullstory.com", e.g. "www.fullstory.com", "edge.fullstory.com".
56
+ * Anchored at the start, so a customer proxy such as "cdn.acme.com/fullstory.com/x" does
57
+ * not match.
58
+ */
59
+ var FS_DOMAIN = /^([^.]+\.)*fullstory\.com(\/|$)/;
60
+ /** Region labels look like "eu1", "ap1", "na1". */
61
+ var REGION_LABEL = /^[a-z]{2,3}[0-9]+$/;
62
+ /**
63
+ * Parses the region/locale suffix out of an orgId, e.g. "o-7Y9H-eu1" -> "eu1". Returns
64
+ * `undefined` for na1 and for orgIds that carry no region, meaning "apply no region".
65
+ * Mirrors `orgLocale()` in the monorepo's `packages/recording/src/fsglobals.ts`.
66
+ */
67
+ var orgLocale = function (orgId) {
68
+ var sections = orgId ? orgId.split('-') : [];
69
+ if (sections.length < 3) {
70
+ return undefined;
71
+ }
72
+ var prefix = sections[0];
73
+ var realm = sections[sections.length - 1];
74
+ // A standard type prefix has only one character in it (o, u, p, etc.). If there is more
75
+ // than one character then we most likely have a legacy org ID that happens to contain
76
+ // hyphens, not a region suffix.
77
+ if (realm === 'na1' || prefix.length > 1) {
78
+ return undefined;
79
+ }
80
+ return realm;
81
+ };
82
+ /**
83
+ * Applies an org's region to a Fullstory-owned host, inserting the locale as the label
84
+ * immediately before the registrable domain and preserving any path:
85
+ *
86
+ * ("fullstory.com", "eu1") -> "eu1.fullstory.com"
87
+ * ("edge.fullstory.com/s/fs.js", "eu1") -> "edge.eu1.fullstory.com/s/fs.js"
88
+ *
89
+ * Hosts Fullstory does not own — self-hosted proxies, Relay, localhost — are returned
90
+ * untouched, as are hosts already carrying the right region (so this is idempotent and
91
+ * safe to stack with the same resolution in `fs.js`).
92
+ */
93
+ var regionalize = function (value, locale) {
94
+ if (!locale || !FS_DOMAIN.test(value)) {
95
+ return value;
96
+ }
97
+ // Only the host is rewritten; anything from the first "/" on is left alone.
98
+ var pathIndex = value.indexOf('/');
99
+ var host = pathIndex === -1 ? value : value.slice(0, pathIndex);
100
+ var path = pathIndex === -1 ? '' : value.slice(pathIndex);
101
+ var labels = host.split('.');
102
+ // FS_DOMAIN guarantees at least "fullstory.com", so this is never negative.
103
+ var insertAt = labels.length - 2;
104
+ var existing = insertAt > 0 ? labels[insertAt - 1] : undefined;
105
+ if (existing === locale) {
106
+ return value;
107
+ }
108
+ if (existing !== undefined && REGION_LABEL.test(existing)) {
109
+ // A different region is already present (e.g. "edge.na1.fullstory.com" on an eu1 org).
110
+ // Replace it rather than insert, which would yield "edge.eu1.na1.fullstory.com".
111
+ labels[insertAt - 1] = locale;
112
+ }
113
+ else {
114
+ labels.splice(insertAt, 0, locale);
115
+ }
116
+ return labels.join('.') + path;
117
+ };
118
+
36
119
  // Module-level state set during `_init` (replaces former window `_fs_*` globals).
37
120
  var _namespace;
38
121
  var _initialized = false;
@@ -87,6 +170,17 @@ var _init = function (inputOptions, readyCallback) {
87
170
  console.warn('Ignoring `debug = true` because `script` is set');
88
171
  }
89
172
  }
173
+ // `fs.js` resolves the org's region itself, but not until after it has loaded — the URL
174
+ // that fetches it has to be regionalized here instead (VAL-10545). Fullstory-owned hosts
175
+ // pick up the region label; customer proxies and Relay hosts are left alone.
176
+ var locale = orgLocale(options.orgId);
177
+ if (locale) {
178
+ options.host = regionalize(options.host || DEFAULT_HOST, locale);
179
+ options.script = regionalize(options.script || DEFAULT_SCRIPT, locale);
180
+ if (fsInitEnv.appHost) {
181
+ fsInitEnv.appHost = regionalize(fsInitEnv.appHost, locale);
182
+ }
183
+ }
90
184
  initFS(options);
91
185
  var fs = getFullStory(_namespace);
92
186
  if (!fs) {
package/dist/index.js CHANGED
@@ -35,6 +35,89 @@ typeof SuppressedError === "function" ? SuppressedError : function (error, suppr
35
35
  return e.name = "SuppressedError", e.error = error, e.suppressed = suppressed, e;
36
36
  };
37
37
 
38
+ /**
39
+ * Region resolution for the Fullstory host/script options.
40
+ *
41
+ * `fs.js` derives an org's region by parsing the locale suffix off its orgId (see
42
+ * `orgLocale()` / `fullstoryHost()` in the monorepo's `packages/recording/src/fsglobals.ts`),
43
+ * but that only runs once `fs.js` has already loaded — far too late to fix the URL that
44
+ * fetched it. So the same resolution has to happen here, before the snippet injects its
45
+ * `<script>` tag, or an EU org that sets nothing but `orgId` fetches `fs.js` and its
46
+ * settings from the na1 edge, which does not serve that org (VAL-10545).
47
+ */
48
+ /**
49
+ * The `host` / `script` defaults applied by `@fullstory/snippet`. Duplicated here because
50
+ * the package does not export them, and we need the default value in hand to regionalize
51
+ * it. Keep in sync with the defaults in that package's `initFS` / `executeSnippet` when
52
+ * upgrading the dependency.
53
+ */
54
+ var DEFAULT_HOST = 'fullstory.com';
55
+ var DEFAULT_SCRIPT = 'edge.fullstory.com/s/fs.js';
56
+ /**
57
+ * Matches any subdomain of "fullstory.com", e.g. "www.fullstory.com", "edge.fullstory.com".
58
+ * Anchored at the start, so a customer proxy such as "cdn.acme.com/fullstory.com/x" does
59
+ * not match.
60
+ */
61
+ var FS_DOMAIN = /^([^.]+\.)*fullstory\.com(\/|$)/;
62
+ /** Region labels look like "eu1", "ap1", "na1". */
63
+ var REGION_LABEL = /^[a-z]{2,3}[0-9]+$/;
64
+ /**
65
+ * Parses the region/locale suffix out of an orgId, e.g. "o-7Y9H-eu1" -> "eu1". Returns
66
+ * `undefined` for na1 and for orgIds that carry no region, meaning "apply no region".
67
+ * Mirrors `orgLocale()` in the monorepo's `packages/recording/src/fsglobals.ts`.
68
+ */
69
+ var orgLocale = function (orgId) {
70
+ var sections = orgId ? orgId.split('-') : [];
71
+ if (sections.length < 3) {
72
+ return undefined;
73
+ }
74
+ var prefix = sections[0];
75
+ var realm = sections[sections.length - 1];
76
+ // A standard type prefix has only one character in it (o, u, p, etc.). If there is more
77
+ // than one character then we most likely have a legacy org ID that happens to contain
78
+ // hyphens, not a region suffix.
79
+ if (realm === 'na1' || prefix.length > 1) {
80
+ return undefined;
81
+ }
82
+ return realm;
83
+ };
84
+ /**
85
+ * Applies an org's region to a Fullstory-owned host, inserting the locale as the label
86
+ * immediately before the registrable domain and preserving any path:
87
+ *
88
+ * ("fullstory.com", "eu1") -> "eu1.fullstory.com"
89
+ * ("edge.fullstory.com/s/fs.js", "eu1") -> "edge.eu1.fullstory.com/s/fs.js"
90
+ *
91
+ * Hosts Fullstory does not own — self-hosted proxies, Relay, localhost — are returned
92
+ * untouched, as are hosts already carrying the right region (so this is idempotent and
93
+ * safe to stack with the same resolution in `fs.js`).
94
+ */
95
+ var regionalize = function (value, locale) {
96
+ if (!locale || !FS_DOMAIN.test(value)) {
97
+ return value;
98
+ }
99
+ // Only the host is rewritten; anything from the first "/" on is left alone.
100
+ var pathIndex = value.indexOf('/');
101
+ var host = pathIndex === -1 ? value : value.slice(0, pathIndex);
102
+ var path = pathIndex === -1 ? '' : value.slice(pathIndex);
103
+ var labels = host.split('.');
104
+ // FS_DOMAIN guarantees at least "fullstory.com", so this is never negative.
105
+ var insertAt = labels.length - 2;
106
+ var existing = insertAt > 0 ? labels[insertAt - 1] : undefined;
107
+ if (existing === locale) {
108
+ return value;
109
+ }
110
+ if (existing !== undefined && REGION_LABEL.test(existing)) {
111
+ // A different region is already present (e.g. "edge.na1.fullstory.com" on an eu1 org).
112
+ // Replace it rather than insert, which would yield "edge.eu1.na1.fullstory.com".
113
+ labels[insertAt - 1] = locale;
114
+ }
115
+ else {
116
+ labels.splice(insertAt, 0, locale);
117
+ }
118
+ return labels.join('.') + path;
119
+ };
120
+
38
121
  // Module-level state set during `_init` (replaces former window `_fs_*` globals).
39
122
  var _namespace;
40
123
  var _initialized = false;
@@ -89,6 +172,17 @@ var _init = function (inputOptions, readyCallback) {
89
172
  console.warn('Ignoring `debug = true` because `script` is set');
90
173
  }
91
174
  }
175
+ // `fs.js` resolves the org's region itself, but not until after it has loaded — the URL
176
+ // that fetches it has to be regionalized here instead (VAL-10545). Fullstory-owned hosts
177
+ // pick up the region label; customer proxies and Relay hosts are left alone.
178
+ var locale = orgLocale(options.orgId);
179
+ if (locale) {
180
+ options.host = regionalize(options.host || DEFAULT_HOST, locale);
181
+ options.script = regionalize(options.script || DEFAULT_SCRIPT, locale);
182
+ if (fsInitEnv.appHost) {
183
+ fsInitEnv.appHost = regionalize(fsInitEnv.appHost, locale);
184
+ }
185
+ }
92
186
  snippet.initFS(options);
93
187
  var fs = getFullStory(_namespace);
94
188
  if (!fs) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fullstory/browser",
3
- "version": "2.1.0-beta.0",
3
+ "version": "2.1.1",
4
4
  "description": "The official Fullstory browser SDK",
5
5
  "repository": "git://github.com/fullstorydev/fullstory-browser-sdk.git",
6
6
  "homepage": "https://github.com/fullstorydev/fullstory-browser-sdk",
@@ -25,7 +25,7 @@
25
25
  "sdk"
26
26
  ],
27
27
  "dependencies": {
28
- "@fullstory/snippet": "2.1.0-beta.0"
28
+ "@fullstory/snippet": "2.1.0"
29
29
  },
30
30
  "overrides": {
31
31
  "brace-expansion": "^5.0.8"
@@ -0,0 +1,65 @@
1
+ import { DEFAULT_HOST, DEFAULT_SCRIPT, orgLocale, regionalize } from './hosts';
2
+
3
+ describe('orgLocale', () => {
4
+ it('should return the region suffix of a regional orgId', () => {
5
+ expect(orgLocale('o-7Y9H-eu1')).toBe('eu1');
6
+ expect(orgLocale('o-1A2B3C-ap1')).toBe('ap1');
7
+ });
8
+
9
+ it('should return undefined for na1, which needs no region label', () => {
10
+ expect(orgLocale('o-7Y9H-na1')).toBeUndefined();
11
+ });
12
+
13
+ it('should return undefined for orgIds with no region', () => {
14
+ expect(orgLocale('123')).toBeUndefined();
15
+ expect(orgLocale('o-7Y9H')).toBeUndefined();
16
+ expect(orgLocale('')).toBeUndefined();
17
+ expect(orgLocale(undefined)).toBeUndefined();
18
+ });
19
+
20
+ it('should ignore legacy orgIds that merely contain hyphens', () => {
21
+ // A real type prefix is a single character (o, u, p); anything longer is a legacy id.
22
+ expect(orgLocale('ACME-Widgets-Inc')).toBeUndefined();
23
+ });
24
+ });
25
+
26
+ describe('regionalize', () => {
27
+ it('should insert the region before the registrable domain', () => {
28
+ expect(regionalize(DEFAULT_HOST, 'eu1')).toBe('eu1.fullstory.com');
29
+ expect(regionalize(DEFAULT_SCRIPT, 'eu1')).toBe('edge.eu1.fullstory.com/s/fs.js');
30
+ });
31
+
32
+ it('should preserve the path of a script URL', () => {
33
+ expect(regionalize('edge.fullstory.com/s/fs-debug.js', 'eu1'))
34
+ .toBe('edge.eu1.fullstory.com/s/fs-debug.js');
35
+ });
36
+
37
+ it('should regionalize the www and app subdomains', () => {
38
+ expect(regionalize('www.fullstory.com', 'eu1')).toBe('www.eu1.fullstory.com');
39
+ expect(regionalize('app.fullstory.com', 'eu1')).toBe('app.eu1.fullstory.com');
40
+ });
41
+
42
+ it('should be idempotent for hosts already carrying the region', () => {
43
+ expect(regionalize('eu1.fullstory.com', 'eu1')).toBe('eu1.fullstory.com');
44
+ expect(regionalize('edge.eu1.fullstory.com/s/fs.js', 'eu1'))
45
+ .toBe('edge.eu1.fullstory.com/s/fs.js');
46
+ });
47
+
48
+ it('should replace a mismatched region rather than stacking another one on top', () => {
49
+ expect(regionalize('edge.na1.fullstory.com/s/fs.js', 'eu1'))
50
+ .toBe('edge.eu1.fullstory.com/s/fs.js');
51
+ });
52
+
53
+ it('should leave hosts Fullstory does not own untouched', () => {
54
+ expect(regionalize('fs.acme.com', 'eu1')).toBe('fs.acme.com');
55
+ expect(regionalize('localhost:8080', 'eu1')).toBe('localhost:8080');
56
+ // Anchored, so a proxy path that happens to mention fullstory.com is not a match.
57
+ expect(regionalize('cdn.acme.com/fullstory.com/fs.js', 'eu1'))
58
+ .toBe('cdn.acme.com/fullstory.com/fs.js');
59
+ });
60
+
61
+ it('should return the value unchanged when there is no region', () => {
62
+ expect(regionalize(DEFAULT_SCRIPT, undefined)).toBe(DEFAULT_SCRIPT);
63
+ expect(regionalize(DEFAULT_SCRIPT, '')).toBe(DEFAULT_SCRIPT);
64
+ });
65
+ });
package/src/hosts.ts ADDED
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Region resolution for the Fullstory host/script options.
3
+ *
4
+ * `fs.js` derives an org's region by parsing the locale suffix off its orgId (see
5
+ * `orgLocale()` / `fullstoryHost()` in the monorepo's `packages/recording/src/fsglobals.ts`),
6
+ * but that only runs once `fs.js` has already loaded — far too late to fix the URL that
7
+ * fetched it. So the same resolution has to happen here, before the snippet injects its
8
+ * `<script>` tag, or an EU org that sets nothing but `orgId` fetches `fs.js` and its
9
+ * settings from the na1 edge, which does not serve that org (VAL-10545).
10
+ */
11
+
12
+ /**
13
+ * The `host` / `script` defaults applied by `@fullstory/snippet`. Duplicated here because
14
+ * the package does not export them, and we need the default value in hand to regionalize
15
+ * it. Keep in sync with the defaults in that package's `initFS` / `executeSnippet` when
16
+ * upgrading the dependency.
17
+ */
18
+ export const DEFAULT_HOST = 'fullstory.com';
19
+ export const DEFAULT_SCRIPT = 'edge.fullstory.com/s/fs.js';
20
+
21
+ /**
22
+ * Matches any subdomain of "fullstory.com", e.g. "www.fullstory.com", "edge.fullstory.com".
23
+ * Anchored at the start, so a customer proxy such as "cdn.acme.com/fullstory.com/x" does
24
+ * not match.
25
+ */
26
+ const FS_DOMAIN = /^([^.]+\.)*fullstory\.com(\/|$)/;
27
+
28
+ /** Region labels look like "eu1", "ap1", "na1". */
29
+ const REGION_LABEL = /^[a-z]{2,3}[0-9]+$/;
30
+
31
+ /**
32
+ * Parses the region/locale suffix out of an orgId, e.g. "o-7Y9H-eu1" -> "eu1". Returns
33
+ * `undefined` for na1 and for orgIds that carry no region, meaning "apply no region".
34
+ * Mirrors `orgLocale()` in the monorepo's `packages/recording/src/fsglobals.ts`.
35
+ */
36
+ export const orgLocale = (orgId?: string): string | undefined => {
37
+ const sections = orgId ? orgId.split('-') : [];
38
+ if (sections.length < 3) {
39
+ return undefined;
40
+ }
41
+
42
+ const prefix = sections[0];
43
+ const realm = sections[sections.length - 1];
44
+
45
+ // A standard type prefix has only one character in it (o, u, p, etc.). If there is more
46
+ // than one character then we most likely have a legacy org ID that happens to contain
47
+ // hyphens, not a region suffix.
48
+ if (realm === 'na1' || prefix.length > 1) {
49
+ return undefined;
50
+ }
51
+
52
+ return realm;
53
+ };
54
+
55
+ /**
56
+ * Applies an org's region to a Fullstory-owned host, inserting the locale as the label
57
+ * immediately before the registrable domain and preserving any path:
58
+ *
59
+ * ("fullstory.com", "eu1") -> "eu1.fullstory.com"
60
+ * ("edge.fullstory.com/s/fs.js", "eu1") -> "edge.eu1.fullstory.com/s/fs.js"
61
+ *
62
+ * Hosts Fullstory does not own — self-hosted proxies, Relay, localhost — are returned
63
+ * untouched, as are hosts already carrying the right region (so this is idempotent and
64
+ * safe to stack with the same resolution in `fs.js`).
65
+ */
66
+ export const regionalize = (value: string, locale?: string): string => {
67
+ if (!locale || !FS_DOMAIN.test(value)) {
68
+ return value;
69
+ }
70
+
71
+ // Only the host is rewritten; anything from the first "/" on is left alone.
72
+ const pathIndex = value.indexOf('/');
73
+ const host = pathIndex === -1 ? value : value.slice(0, pathIndex);
74
+ const path = pathIndex === -1 ? '' : value.slice(pathIndex);
75
+
76
+ const labels = host.split('.');
77
+ // FS_DOMAIN guarantees at least "fullstory.com", so this is never negative.
78
+ const insertAt = labels.length - 2;
79
+ const existing = insertAt > 0 ? labels[insertAt - 1] : undefined;
80
+
81
+ if (existing === locale) {
82
+ return value;
83
+ }
84
+
85
+ if (existing !== undefined && REGION_LABEL.test(existing)) {
86
+ // A different region is already present (e.g. "edge.na1.fullstory.com" on an eu1 org).
87
+ // Replace it rather than insert, which would yield "edge.eu1.na1.fullstory.com".
88
+ labels[insertAt - 1] = locale;
89
+ } else {
90
+ labels.splice(insertAt, 0, locale);
91
+ }
92
+
93
+ return labels.join('.') + path;
94
+ };
package/src/index.test.ts CHANGED
@@ -18,6 +18,20 @@ const findBrowserInitEnv = (ns = 'FS'): Record<string, unknown> | undefined => {
18
18
  return initOptions?.env;
19
19
  };
20
20
 
21
+ /** Snippet's own init call (the first one), which carries the resolved orgId/host/script. */
22
+ const findSnippetInitEnv = (ns = 'FS'): Record<string, unknown> | undefined => {
23
+ const initCalls = getFsQueue(ns)?.filter((call) => call[0] === 'init') ?? [];
24
+ const initOptions = initCalls[0]?.[1] as { env?: Record<string, unknown> } | undefined;
25
+ return initOptions?.env;
26
+ };
27
+
28
+ /** The `src` of the <script> tag the snippet injects to load fs.js. */
29
+ const injectedScriptSrc = (): string | undefined => {
30
+ const scripts = document.querySelectorAll('script[data-fs-namespace]');
31
+ expect(scripts).toHaveLength(1);
32
+ return (scripts[0] as HTMLScriptElement).src;
33
+ };
34
+
21
35
  beforeEach(() => {
22
36
  // Module-level init state lives in the SDK; re-import for a clean slate per test.
23
37
  jest.resetModules();
@@ -28,6 +42,9 @@ beforeEach(() => {
28
42
  }
29
43
  });
30
44
  delete (window as { FS?: unknown }).FS;
45
+ // The snippet appends to the shared jsdom document; drop prior tests' script tags so
46
+ // `injectedScriptSrc()` can only ever see the one injected by the test at hand.
47
+ document.querySelectorAll('script[data-fs-namespace]').forEach((el) => { el.remove(); });
31
48
  });
32
49
 
33
50
  const loadSdk = async () => {
@@ -155,6 +172,63 @@ describe('init', () => {
155
172
  });
156
173
  });
157
174
 
175
+ describe('region resolution', () => {
176
+ // fs.js only learns the org's region after it loads, so the SDK has to resolve it up
177
+ // front or a regional org fetches fs.js and its settings from the na1 edge (VAL-10545).
178
+ const euOrg = 'o-7Y9H-eu1';
179
+
180
+ it('should fetch fs.js from the org region edge', async () => {
181
+ const { init } = await loadSdk();
182
+ init({ orgId: euOrg });
183
+
184
+ expect(injectedScriptSrc()).toContain('edge.eu1.fullstory.com/s/fs.js');
185
+ });
186
+
187
+ it('should pass the regional host and script to the snippet init', async () => {
188
+ const { init } = await loadSdk();
189
+ init({ orgId: euOrg });
190
+
191
+ const env = findSnippetInitEnv();
192
+ expect(env?.host).toBe('eu1.fullstory.com');
193
+ expect(env?.script).toBe('edge.eu1.fullstory.com/s/fs.js');
194
+ });
195
+
196
+ it('should regionalize the debug script', async () => {
197
+ const { init } = await loadSdk();
198
+ init({ orgId: euOrg, debug: true });
199
+
200
+ expect(injectedScriptSrc()).toContain('edge.eu1.fullstory.com/s/fs-debug.js');
201
+ });
202
+
203
+ it('should regionalize an explicitly set Fullstory host', async () => {
204
+ const { init } = await loadSdk();
205
+ init({ orgId: euOrg, host: 'fullstory.com', appHost: 'app.fullstory.com' });
206
+
207
+ expect(findSnippetInitEnv()?.host).toBe('eu1.fullstory.com');
208
+ expect(findBrowserInitEnv()?.appHost).toBe('app.eu1.fullstory.com');
209
+ });
210
+
211
+ it('should leave a self-hosted proxy untouched', async () => {
212
+ const { init } = await loadSdk();
213
+ init({ orgId: euOrg, host: 'fs.example.com', script: 'fs.example.com/fs.js' });
214
+
215
+ const env = findSnippetInitEnv();
216
+ expect(env?.host).toBe('fs.example.com');
217
+ expect(env?.script).toBe('fs.example.com/fs.js');
218
+ expect(injectedScriptSrc()).toContain('fs.example.com/fs.js');
219
+ });
220
+
221
+ it('should not change the defaults for an org with no region', async () => {
222
+ const { init } = await loadSdk();
223
+ init({ orgId: testOrg });
224
+
225
+ const env = findSnippetInitEnv();
226
+ expect(env?.host).toBe('fullstory.com');
227
+ expect(env?.script).toBe('edge.fullstory.com/s/fs.js');
228
+ expect(injectedScriptSrc()).toContain('edge.fullstory.com/s/fs.js');
229
+ });
230
+ });
231
+
158
232
  describe('devMode', () => {
159
233
  let consoleWarnedMessage;
160
234
  const oldConsoleWarn = console.warn;
package/src/index.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { initFS, FSApi } from '@fullstory/snippet';
2
+ import { DEFAULT_HOST, DEFAULT_SCRIPT, orgLocale, regionalize } from './hosts';
2
3
 
3
4
  /**
4
5
  * FullStory Client SDK snippet options.
@@ -6,13 +7,17 @@ import { initFS, FSApi } from '@fullstory/snippet';
6
7
  * - orgId: Reference for your [Org Id](https://help.fullstory.com/hc/en-us/articles/360047075853) listed in FullStory.
7
8
  * - namespace: Global object name that contains the FullStory browser API methods and properties. Defaults to `FS`.
8
9
  * - debug: Debug mode with extra browser console logging.
9
- * - host: The recording server host domain. Can be set to direct recorded events to a proxy that you host. Defaults to `fullstory.com`.
10
- * - script: FullStory script host domain. FullStory hosts the `fs.js` recording script on a CDN, but you can choose to host a copy yourself. Defaults to `edge.fullstory.com`.
10
+ * - host: The recording server host domain. Can be set to direct recorded events to a proxy that you host. Defaults to `fullstory.com`, or the region-specific equivalent (e.g. `eu1.fullstory.com`) when `orgId` carries a region suffix.
11
+ * - script: FullStory script host domain. FullStory hosts the `fs.js` recording script on a CDN, but you can choose to host a copy yourself. Defaults to `edge.fullstory.com/s/fs.js`, or the region-specific equivalent (e.g. `edge.eu1.fullstory.com/s/fs.js`) when `orgId` carries a region suffix.
11
12
  * - cookieDomain: Overrides the cookie domain. By default, cookies will be valid for all subdomains of your site; if you want to limit the cookies to a specific subdomain, you can set the domain value explicitly. More information can be found [here](https://help.fullstory.com/hc/en-us/articles/360020622874-Can-the-FullStory-cookie-be-associated-with-a-specific-subdomain-).
12
13
  * - recordCrossDomainIFrames: FullStory can record cross-domain iFrames. Defaults to `false`. Certain limitations apply and can be found [here](https://help.fullstory.com/hc/en-us/articles/360020622514-Can-FullStory-capture-content-that-is-presented-in-iframes-#h_01F1G333PYKPGZ4B42WDBV3YKV).
13
14
  * - recordOnlyThisIFrame: FullStory can record the iFrame as its own unique session. Defaults to `false`. Additional conditions apply and can be found [here](https://help.fullstory.com/hc/en-us/articles/360020622514-Can-FullStory-capture-content-that-is-presented-in-iframes-#h_01F1G33B40Q2TPQA8MA7SF8Y5P).
14
15
  * - devMode: In dev mode FullStory won't record sessions. Any calls to SDK methods will `console.warn` that FullStory is in `devMode`. Defaults to `false`.
15
16
  * - sessionUid (beta): Sets the session UID passed to the `FS('init', ...)` v2 operation.
17
+ *
18
+ * The region encoded in `orgId` (e.g. the `eu1` in `o-ABC123-eu1`) is applied to `host`, `script` and `appHost`
19
+ * whenever those resolve to a FullStory-owned domain, including values you set explicitly. Hosts FullStory does
20
+ * not own — a proxy or Relay domain of your own — are always used exactly as given.
16
21
  */
17
22
  export interface SnippetOptions {
18
23
  orgId: string;
@@ -121,6 +126,18 @@ const _init = (inputOptions: SnippetOptions, readyCallback?: ReadyCallback) => {
121
126
  }
122
127
  }
123
128
 
129
+ // `fs.js` resolves the org's region itself, but not until after it has loaded — the URL
130
+ // that fetches it has to be regionalized here instead (VAL-10545). Fullstory-owned hosts
131
+ // pick up the region label; customer proxies and Relay hosts are left alone.
132
+ const locale = orgLocale(options.orgId);
133
+ if (locale) {
134
+ options.host = regionalize(options.host || DEFAULT_HOST, locale);
135
+ options.script = regionalize(options.script || DEFAULT_SCRIPT, locale);
136
+ if (fsInitEnv.appHost) {
137
+ fsInitEnv.appHost = regionalize(fsInitEnv.appHost, locale);
138
+ }
139
+ }
140
+
124
141
  initFS(options);
125
142
 
126
143
  const fs = getFullStory(_namespace);