@scalebun/react-native 1.13.0 → 2.0.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/android/build.gradle +8 -0
- package/android/src/androidTest/java/com/scalebun/rn/ota/ScaleBunOtaVerifierInstrumentedTest.kt +112 -0
- package/android/src/main/java/com/scalebun/rn/ota/BundleDownloader.kt +10 -0
- package/android/src/main/java/com/scalebun/rn/ota/DeviceIntegrity.kt +75 -0
- package/android/src/main/java/com/scalebun/rn/ota/OtaProtocol.kt +244 -0
- package/android/src/main/java/com/scalebun/rn/ota/ScaleBunOtaKeyRegistry.kt +100 -0
- package/android/src/main/java/com/scalebun/rn/ota/ScaleBunOtaModule.kt +132 -62
- package/android/src/main/java/com/scalebun/rn/ota/ScaleBunOtaReleaseVerifier.kt +129 -0
- package/android/src/main/java/com/scalebun/rn/ota/SlotManager.kt +80 -1
- package/android/src/main/java/com/scalebun/rn/ota/TlsPinning.kt +118 -0
- package/android/src/oldarch/java/com/scalebun/rn/ota/ScaleBunOtaSpec.kt +6 -8
- package/dist/scalebun.full.js +964 -533
- package/dist/scalebun.slim.js +962 -531
- package/ios/Ota/OtaProtocol.swift +242 -0
- package/ios/Ota/OtaSlotManager.swift +26 -0
- package/ios/Ota/ScaleBunOtaBridge.mm +6 -3
- package/ios/Ota/ScaleBunOtaKeyRegistry.swift +147 -0
- package/ios/Ota/ScaleBunOtaModule.swift +125 -53
- package/ios/Ota/ScaleBunOtaReleaseVerifier.swift +115 -0
- package/ios/Ota/ScaleBunOtaVerifierTests.swift +92 -0
- package/ios/ReplaySdk.swift +65 -2
- package/lib/commonjs/bootstrap/SDKBootstrapper.js +30 -0
- package/lib/commonjs/core/config/schema.js +19 -12
- package/lib/commonjs/core/constants/version.js +1 -1
- package/lib/commonjs/features/journey/ScaleBunDebugRoot.js +293 -5
- package/lib/commonjs/features/journey/ScaleBunScrollView.js +28 -0
- package/lib/commonjs/features/journey/autoInstrumentScroll.js +161 -0
- package/lib/commonjs/features/journey/interactionProtocol.js +131 -10
- package/lib/commonjs/features/journey/scrollContext.js +146 -0
- package/lib/commonjs/features/journey/targetGeometry.js +164 -0
- package/lib/commonjs/features/journey/touchTarget.js +152 -24
- package/lib/commonjs/features/journey/uiState.js +81 -1
- package/lib/commonjs/features/navigation/AutoScreenDetector.js +74 -5
- package/lib/commonjs/features/network/NetworkFeature.js +39 -5
- package/lib/commonjs/features/network/thirdParty.js +90 -0
- package/lib/commonjs/features/ota/OtaOrchestrator.js +175 -80
- package/lib/commonjs/features/session/SessionManager.js +63 -0
- package/lib/commonjs/public/ScaleBunFacade.js +23 -57
- package/lib/module/bootstrap/SDKBootstrapper.js +30 -0
- package/lib/module/core/config/schema.js +19 -12
- package/lib/module/core/constants/version.js +1 -1
- package/lib/module/features/journey/ScaleBunDebugRoot.js +295 -7
- package/lib/module/features/journey/ScaleBunScrollView.js +28 -0
- package/lib/module/features/journey/autoInstrumentScroll.js +155 -0
- package/lib/module/features/journey/interactionProtocol.js +128 -8
- package/lib/module/features/journey/scrollContext.js +135 -0
- package/lib/module/features/journey/targetGeometry.js +155 -0
- package/lib/module/features/journey/touchTarget.js +151 -24
- package/lib/module/features/journey/uiState.js +78 -1
- package/lib/module/features/navigation/AutoScreenDetector.js +74 -5
- package/lib/module/features/network/NetworkFeature.js +38 -4
- package/lib/module/features/network/thirdParty.js +83 -0
- package/lib/module/features/ota/OtaOrchestrator.js +175 -80
- package/lib/module/features/session/SessionManager.js +63 -0
- package/lib/module/public/ScaleBunFacade.js +23 -57
- package/lib/typescript/core/config/schema.d.ts +2 -3
- package/lib/typescript/core/constants/version.d.ts +1 -1
- package/lib/typescript/features/journey/autoInstrumentScroll.d.ts +46 -0
- package/lib/typescript/features/journey/interactionProtocol.d.ts +139 -1
- package/lib/typescript/features/journey/scrollContext.d.ts +85 -0
- package/lib/typescript/features/journey/targetGeometry.d.ts +114 -0
- package/lib/typescript/features/journey/touchTarget.d.ts +148 -15
- package/lib/typescript/features/journey/uiState.d.ts +39 -0
- package/lib/typescript/features/navigation/AutoScreenDetector.d.ts +48 -2
- package/lib/typescript/features/network/index.d.ts +18 -0
- package/lib/typescript/features/network/thirdParty.d.ts +65 -0
- package/lib/typescript/features/ota/OtaOrchestrator.d.ts +12 -8
- package/lib/typescript/features/ota/OtaTypes.d.ts +22 -0
- package/lib/typescript/features/session/SessionManager.d.ts +55 -0
- package/lib/typescript/public/ScaleBunFacade.d.ts +5 -11
- package/lib/typescript/public/types.d.ts +10 -0
- package/lib/typescript/specs/NativeScaleBunOta.d.ts +32 -20
- package/package.json +3 -13
- package/scalebun-react-native.podspec +4 -0
- package/src/bootstrap/SDKBootstrapper.ts +40 -0
- package/src/core/config/schema.ts +21 -9
- package/src/core/constants/version.ts +1 -1
- package/src/features/journey/ScaleBunDebugRoot.tsx +288 -7
- package/src/features/journey/ScaleBunScrollView.tsx +29 -0
- package/src/features/journey/autoInstrumentScroll.ts +155 -0
- package/src/features/journey/interactionProtocol.ts +188 -10
- package/src/features/journey/scrollContext.ts +137 -0
- package/src/features/journey/targetGeometry.ts +176 -0
- package/src/features/journey/touchTarget.ts +237 -26
- package/src/features/journey/uiState.ts +80 -1
- package/src/features/navigation/AutoScreenDetector.ts +65 -5
- package/src/features/network/NetworkFeature.ts +41 -4
- package/src/features/network/index.ts +18 -0
- package/src/features/network/thirdParty.ts +92 -0
- package/src/features/ota/OtaOrchestrator.ts +190 -90
- package/src/features/ota/OtaTypes.ts +23 -1
- package/src/features/session/SessionManager.ts +113 -0
- package/src/public/ScaleBunFacade.ts +23 -69
- package/src/public/types.ts +10 -0
- package/src/specs/NativeScaleBunOta.ts +35 -20
- package/lib/commonjs/features/ota/crypto/builtinVerifier.js +0 -248
- package/lib/commonjs/features/ota/crypto/loadEd25519.js +0 -40
- package/lib/commonjs/features/ota/crypto/loadSha512.js +0 -40
- package/lib/commonjs/features/ota/crypto/nativeVerifier.js +0 -121
- package/lib/commonjs/features/ota/signature.js +0 -175
- package/lib/module/features/ota/crypto/builtinVerifier.js +0 -240
- package/lib/module/features/ota/crypto/loadEd25519.js +0 -34
- package/lib/module/features/ota/crypto/loadSha512.js +0 -34
- package/lib/module/features/ota/crypto/nativeVerifier.js +0 -113
- package/lib/module/features/ota/signature.js +0 -169
- package/lib/typescript/features/ota/crypto/builtinVerifier.d.ts +0 -53
- package/lib/typescript/features/ota/crypto/loadEd25519.d.ts +0 -30
- package/lib/typescript/features/ota/crypto/loadSha512.d.ts +0 -15
- package/lib/typescript/features/ota/crypto/nativeVerifier.d.ts +0 -35
- package/lib/typescript/features/ota/signature.d.ts +0 -81
- package/src/features/ota/crypto/builtinVerifier.ts +0 -257
- package/src/features/ota/crypto/loadEd25519.ts +0 -41
- package/src/features/ota/crypto/loadSha512.ts +0 -35
- package/src/features/ota/crypto/nativeVerifier.ts +0 -117
- package/src/features/ota/signature.ts +0 -206
|
@@ -5,6 +5,7 @@ Object.defineProperty(exports, "__esModule", {
|
|
|
5
5
|
});
|
|
6
6
|
exports.describeTouchTarget = describeTouchTarget;
|
|
7
7
|
exports.resolveTouchTarget = resolveTouchTarget;
|
|
8
|
+
exports.targetIdentity = targetIdentity;
|
|
8
9
|
/**
|
|
9
10
|
* Resolve WHAT was tapped, from a React Native touch event.
|
|
10
11
|
*
|
|
@@ -16,19 +17,70 @@ exports.resolveTouchTarget = resolveTouchTarget;
|
|
|
16
17
|
* WHY THE FIBER. There is no DOM. The only place a tapped view's identity exists in JS is the React
|
|
17
18
|
* fiber hanging off the touch event (`_targetInst`), which carries `memoizedProps` — where
|
|
18
19
|
* `accessibilityLabel` and `testID` live. This is a React internal, so every access is defensive and
|
|
19
|
-
* the whole thing degrades to `null` rather than throwing:
|
|
20
|
+
* the whole thing degrades to `null` rather than throwing: an analytics label is never worth a crash.
|
|
20
21
|
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
22
|
+
* ─────────────────────────────────────────────────────────────────────────────────────────────
|
|
23
|
+
* RN-2 — THE IDENTITY LADDER USED TO CAPTURE USER TEXT, AND THAT IS A PRIVACY DEFECT, NOT A
|
|
24
|
+
* TRADE-OFF.
|
|
25
|
+
*
|
|
26
|
+
* The previous preference order was:
|
|
27
|
+
*
|
|
28
|
+
* 1. accessibilityLabel 2. testID 3. a literal string child 4. nearest component
|
|
29
|
+
*
|
|
30
|
+
* Ranks 1 and 3 are FREE TEXT authored for humans, and in a real app they routinely contain user
|
|
31
|
+
* data. An accessible order row is labelled "Order 1042 for mohamed@scalebun.com". A pay button
|
|
32
|
+
* reads "Pay EGP 1,442". A contact row's caption is a person's name. Rank 1 was preferred over
|
|
33
|
+
* `testID`, so the leakiest source won whenever it existed.
|
|
34
|
+
*
|
|
35
|
+
* Two separate harms, and the second is easy to miss:
|
|
36
|
+
*
|
|
37
|
+
* PRIVACY. `describeTouchTarget`'s output is persisted as the interaction's `target`. So names,
|
|
38
|
+
* email addresses and amounts were being written into an analytics store, under a field nobody
|
|
39
|
+
* would think to audit for PII. The same product already found 27 rows in `replay_events`
|
|
40
|
+
* carrying email addresses through a different route; this was a second one.
|
|
41
|
+
*
|
|
42
|
+
* CORRECTNESS. `target` is an AGGREGATION KEY. Keyed on text that varies per user, a single
|
|
43
|
+
* button produces one bucket per person — so the grouping this feature exists to provide is
|
|
44
|
+
* destroyed precisely in the screens that matter most. A leaky key is also a useless key.
|
|
45
|
+
*
|
|
46
|
+
* SO THE LADDER IS NOW DERIVED FROM CODE, NEVER FROM CONTENT:
|
|
47
|
+
*
|
|
48
|
+
* 1. `testID` — authored to identify, stable across copy edits, and by convention never
|
|
49
|
+
* user data. The strongest signal and now the first.
|
|
50
|
+
* 2. `path` — the component-name chain from the tapped node upward, e.g.
|
|
51
|
+
* `CartScreen>OrderRow>Pressable`. Structural: it changes when the code
|
|
52
|
+
* changes, not when the data does. This is what makes the ladder useful in
|
|
53
|
+
* the overwhelming majority of apps, which have no testIDs — without it,
|
|
54
|
+
* removing free text would have meant removing identity.
|
|
55
|
+
* 3. `component` — the nearest named component alone. Coarse, but a code identifier.
|
|
56
|
+
*
|
|
57
|
+
* Free text is still RESOLVABLE, because a team that has audited its own labels may legitimately
|
|
58
|
+
* want them, but it is opt-in per call (`includeText`) and it is NEVER what
|
|
59
|
+
* `describeTouchTarget` returns. Default-off matters more than the option: a default that leaks
|
|
60
|
+
* is a leak, whatever the documentation says.
|
|
61
|
+
*
|
|
62
|
+
* WHY THIS SHIPS WITH RN-1 (persistence) RATHER THAN AFTER IT. RN-1 makes the resolved target
|
|
63
|
+
* actually reach the backend. Landing that first would persist the leaky ladder and create a
|
|
64
|
+
* historical PII problem to clean up; landing the sanitised ladder first and persistence second
|
|
65
|
+
* leaves a window where the feature looks broken. They are one change.
|
|
66
|
+
* ─────────────────────────────────────────────────────────────────────────────────────────────
|
|
26
67
|
*/
|
|
27
68
|
|
|
69
|
+
/** Where a resolved identity came from — so a consumer can tell an authored id from a guess. */
|
|
70
|
+
|
|
28
71
|
/** How far up the tree to look before giving up. Deep enough for a wrapped button, cheap enough to run on every tap. */
|
|
29
72
|
const MAX_DEPTH = 12;
|
|
30
73
|
/** Labels longer than this are truncated — a caption, not a paragraph. */
|
|
31
74
|
const MAX_LEN = 60;
|
|
75
|
+
/**
|
|
76
|
+
* How many component names the structural path may hold.
|
|
77
|
+
*
|
|
78
|
+
* Four is enough to separate two same-named controls in different containers
|
|
79
|
+
* (`Pressable<OrderRow<CartScreen` vs `Pressable<SuggestionRow<CartScreen`) without letting the
|
|
80
|
+
* key drift every time someone adds a wrapper view higher up. A path that changes on unrelated
|
|
81
|
+
* refactors is a key that silently splits its own history.
|
|
82
|
+
*/
|
|
83
|
+
const MAX_PATH_SEGMENTS = 4;
|
|
32
84
|
function str(v) {
|
|
33
85
|
if (typeof v !== 'string') return undefined;
|
|
34
86
|
const s = v.trim();
|
|
@@ -48,7 +100,7 @@ function componentName(f) {
|
|
|
48
100
|
return n.slice(0, MAX_LEN);
|
|
49
101
|
}
|
|
50
102
|
|
|
51
|
-
/** First literal string child, e.g. <Text>Buy now</Text>. */
|
|
103
|
+
/** First literal string child, e.g. <Text>Buy now</Text>. FREE TEXT — see the header. */
|
|
52
104
|
function textChild(props) {
|
|
53
105
|
if (!props) return undefined;
|
|
54
106
|
const c = props.children;
|
|
@@ -68,10 +120,9 @@ function fiberOf(event) {
|
|
|
68
120
|
const e = event;
|
|
69
121
|
const direct = e._targetInst;
|
|
70
122
|
if (direct && typeof direct === 'object') return direct;
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
const handle = target._internalFiberInstanceHandleDEV ?? target._internalInstanceHandle;
|
|
123
|
+
const target = e.target ?? e.nativeEvent?.target;
|
|
124
|
+
if (target) {
|
|
125
|
+
const handle = target._internalInstanceHandle ?? target._targetInst;
|
|
75
126
|
if (handle && typeof handle === 'object') return handle;
|
|
76
127
|
}
|
|
77
128
|
return null;
|
|
@@ -80,42 +131,119 @@ function fiberOf(event) {
|
|
|
80
131
|
/**
|
|
81
132
|
* Walk up from the touched fiber collecting the best identity available.
|
|
82
133
|
*
|
|
83
|
-
* Keeps walking after a hit so a wrapper's `testID` can still be picked up when the leaf
|
|
84
|
-
*
|
|
85
|
-
* that pair.
|
|
134
|
+
* Keeps walking after a hit so a wrapper's `testID` can still be picked up when the leaf had none,
|
|
135
|
+
* and stops as soon as a `testID` is known AND the path is full — nothing above can improve on that.
|
|
86
136
|
*/
|
|
87
|
-
function resolveTouchTarget(event,
|
|
137
|
+
function resolveTouchTarget(event, optsOrMaxDepth = {}) {
|
|
138
|
+
/* Number form kept for the previous signature `resolveTouchTarget(event, maxDepth)`. */
|
|
139
|
+
const opts = typeof optsOrMaxDepth === 'number' ? {
|
|
140
|
+
maxDepth: optsOrMaxDepth
|
|
141
|
+
} : optsOrMaxDepth;
|
|
142
|
+
const maxDepth = opts.maxDepth ?? MAX_DEPTH;
|
|
88
143
|
let f = fiberOf(event);
|
|
89
144
|
if (!f) return null;
|
|
90
145
|
const out = {};
|
|
146
|
+
const segments = [];
|
|
147
|
+
const ancestors = [];
|
|
91
148
|
let depth = 0;
|
|
92
149
|
try {
|
|
93
150
|
while (f && depth < maxDepth) {
|
|
94
151
|
const props = f.memoizedProps;
|
|
95
152
|
if (props) {
|
|
96
|
-
|
|
153
|
+
const beforeTestId = out.testID;
|
|
97
154
|
out.testID ??= str(props.testID);
|
|
98
|
-
|
|
155
|
+
/* The node is pinned to the fiber that supplied the testID — the CONTROL, not the
|
|
156
|
+
deepest node hit. A Text inside a Pressable is a tap on the Pressable, so the rect
|
|
157
|
+
has to be the Pressable's or the containment test would be run against the caption. */
|
|
158
|
+
if (!beforeTestId && out.testID) out.node = f.stateNode;
|
|
159
|
+
/* The nearest enclosing cell wins: nested lists mean a row can sit inside another
|
|
160
|
+
row, and the innermost is the one the finger is actually in. `??=` keeps the first
|
|
161
|
+
seen while walking outward, which is the innermost. */
|
|
162
|
+
if (typeof props.cellKey === 'string' && props.cellKey) {
|
|
163
|
+
out.itemKey ??= str(props.cellKey);
|
|
164
|
+
if (typeof props.index === 'number' && Number.isFinite(props.index)) {
|
|
165
|
+
out.itemIndex ??= props.index;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
if (opts.includeText) {
|
|
169
|
+
out.accessibilityLabel ??= str(props.accessibilityLabel);
|
|
170
|
+
out.text ??= textChild(props);
|
|
171
|
+
}
|
|
99
172
|
}
|
|
100
|
-
|
|
101
|
-
|
|
173
|
+
/* Host instances only — a composite fiber has no native tag, so keeping them would pad
|
|
174
|
+
the chain with entries no scroller could ever match. */
|
|
175
|
+
if (f.stateNode) ancestors.push(f.stateNode);
|
|
176
|
+
const name = componentName(f);
|
|
177
|
+
if (name) {
|
|
178
|
+
out.component ??= name;
|
|
179
|
+
/* Consecutive duplicates collapse: a component that renders itself through a memo or a
|
|
180
|
+
forwardRef appears twice in the fiber chain and would otherwise pad the path with a
|
|
181
|
+
repetition that carries no information. */
|
|
182
|
+
if (segments[segments.length - 1] !== name && segments.length < MAX_PATH_SEGMENTS) {
|
|
183
|
+
segments.push(name);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
if (out.testID && segments.length >= MAX_PATH_SEGMENTS) break;
|
|
102
187
|
f = f.return ?? null;
|
|
103
188
|
depth++;
|
|
104
189
|
}
|
|
105
190
|
} catch {
|
|
106
191
|
/* React internals shifted under us — return whatever was gathered so far */
|
|
107
192
|
}
|
|
108
|
-
|
|
193
|
+
|
|
194
|
+
/* Tapped node first, so the most specific segment leads and a truncated path is still anchored
|
|
195
|
+
to the thing actually touched. */
|
|
196
|
+
if (segments.length) out.path = segments.join('<');
|
|
197
|
+
|
|
198
|
+
/* No authored testID anywhere, so identity came from the structural path. Anchor the rect to
|
|
199
|
+
the node that was actually touched — imperfect (it may be the caption rather than the
|
|
200
|
+
control) but honest, and better than no geometry for the majority of apps, which author no
|
|
201
|
+
testIDs. `targetSource` records which case this was, so a consumer can weight it. */
|
|
202
|
+
if (!out.node) out.node = fiberOf(event)?.stateNode;
|
|
203
|
+
if (ancestors.length) out.ancestors = ancestors;
|
|
204
|
+
return out.testID || out.path || out.component || out.accessibilityLabel || out.text || out.itemKey ? out : null;
|
|
109
205
|
}
|
|
110
206
|
|
|
111
207
|
/**
|
|
112
|
-
* One short
|
|
208
|
+
* One short, STABLE, non-identifying label for the tapped thing.
|
|
113
209
|
*
|
|
114
|
-
* This is what ends up grouping the Events explorer
|
|
115
|
-
*
|
|
210
|
+
* This is what ends up grouping the Events explorer and what gets persisted as the interaction's
|
|
211
|
+
* target, so it must satisfy two constraints at once: stable across copy edits, and free of user
|
|
212
|
+
* data. Both rule out visible text, which is why the free-text fields are not consulted here even
|
|
213
|
+
* when they have been resolved — see the RN-2 note in the header.
|
|
116
214
|
*/
|
|
117
215
|
function describeTouchTarget(t) {
|
|
118
216
|
if (!t) return undefined;
|
|
119
|
-
return t.
|
|
217
|
+
return t.testID || t.path || t.component;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* The identity plus WHERE IT CAME FROM.
|
|
222
|
+
*
|
|
223
|
+
* A consumer cannot treat an authored `testID` and a structural `path` as equally trustworthy: the
|
|
224
|
+
* first is a promise from the app's authors that this control is this control, the second is an
|
|
225
|
+
* inference that breaks when the tree is refactored. Returning the source lets the accounting lane
|
|
226
|
+
* say WHY a tap could not be attributed, instead of reporting a missing target and leaving the
|
|
227
|
+
* reader to guess whether the control had no identity or the walk failed.
|
|
228
|
+
*/
|
|
229
|
+
function targetIdentity(t) {
|
|
230
|
+
if (!t) return {
|
|
231
|
+
source: 'none'
|
|
232
|
+
};
|
|
233
|
+
if (t.testID) return {
|
|
234
|
+
id: t.testID,
|
|
235
|
+
source: 'testID'
|
|
236
|
+
};
|
|
237
|
+
if (t.path) return {
|
|
238
|
+
id: t.path,
|
|
239
|
+
source: 'path'
|
|
240
|
+
};
|
|
241
|
+
if (t.component) return {
|
|
242
|
+
id: t.component,
|
|
243
|
+
source: 'component'
|
|
244
|
+
};
|
|
245
|
+
return {
|
|
246
|
+
source: 'none'
|
|
247
|
+
};
|
|
120
248
|
}
|
|
121
249
|
//# sourceMappingURL=touchTarget.js.map
|
|
@@ -4,7 +4,10 @@ Object.defineProperty(exports, "__esModule", {
|
|
|
4
4
|
value: true
|
|
5
5
|
});
|
|
6
6
|
exports.clearUiState = clearUiState;
|
|
7
|
+
exports.onUiStateChange = onUiStateChange;
|
|
8
|
+
exports.resetUiStateListeners = resetUiStateListeners;
|
|
7
9
|
exports.setUiState = setUiState;
|
|
10
|
+
exports.uiStateListenerCount = uiStateListenerCount;
|
|
8
11
|
exports.uiStateSignature = uiStateSignature;
|
|
9
12
|
/**
|
|
10
13
|
* UI STATE AT INTERACTION TIME — React Native.
|
|
@@ -57,7 +60,10 @@ const clean = s => typeof s === 'string' ? s.replace(/[;:|]/g, '').trim().slice(
|
|
|
57
60
|
function setUiState(name, value) {
|
|
58
61
|
const n = clean(name);
|
|
59
62
|
const v = clean(value);
|
|
60
|
-
if (n
|
|
63
|
+
if (!n || !v) return;
|
|
64
|
+
if (declared.get(n) === v) return;
|
|
65
|
+
declared.set(n, v);
|
|
66
|
+
notifyChanged();
|
|
61
67
|
}
|
|
62
68
|
|
|
63
69
|
/**
|
|
@@ -69,7 +75,9 @@ function setUiState(name, value) {
|
|
|
69
75
|
* screen.
|
|
70
76
|
*/
|
|
71
77
|
function clearUiState(name) {
|
|
78
|
+
const before = declared.size;
|
|
72
79
|
if (name === undefined) declared.clear();else declared.delete(clean(name));
|
|
80
|
+
if (declared.size !== before) notifyChanged();
|
|
73
81
|
}
|
|
74
82
|
|
|
75
83
|
/**
|
|
@@ -91,4 +99,76 @@ function uiStateSignature() {
|
|
|
91
99
|
}
|
|
92
100
|
return signature || undefined;
|
|
93
101
|
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* RN-11 — TELL SOMEONE THE STATE CHANGED, so a frame can exist for it.
|
|
105
|
+
*
|
|
106
|
+
* THE GAP. Declaring a state used to mutate a Map and nothing else. Nothing captured a frame, so
|
|
107
|
+
* the visual for a declared state existed only if a screenshot happened to be taken for some other
|
|
108
|
+
* reason — a navigation, a timer, or the interaction capture that fires AFTER the touch and
|
|
109
|
+
* photographs the outcome. A state could therefore be recorded as a fact with no picture of it
|
|
110
|
+
* anywhere, or with a picture of the state that replaced it.
|
|
111
|
+
*
|
|
112
|
+
* The native capture reasons are NAVIGATION, MANUAL, INTERACTION and TIMER. None of them is "the
|
|
113
|
+
* UI changed shape", which is exactly the moment a heatmap needs a frame.
|
|
114
|
+
*
|
|
115
|
+
* FIRING ON A REAL CHANGE ONLY, AND WHY THAT IS THE SDK'S JOB. The advice above says to call
|
|
116
|
+
* `setUiState` when the state changes rather than on every render — but that is advice to the
|
|
117
|
+
* host, not a guarantee. A component that declares its state inside render is ordinary React, and
|
|
118
|
+
* trusting the caller would mean a screenshot per render: a performance disaster in exactly the
|
|
119
|
+
* apps that instrument most carefully. So a redundant set — same dimension, same value — is now a
|
|
120
|
+
* no-op, and the change test lives here where it cannot be got wrong by a host.
|
|
121
|
+
*
|
|
122
|
+
* COALESCED, because a transition usually declares several dimensions in one tick ("drawer:open"
|
|
123
|
+
* and "overlay:visible" belong to one visual change). Notifying per call would capture the
|
|
124
|
+
* intermediate shapes, which are states the user never saw.
|
|
125
|
+
*
|
|
126
|
+
* The listener is deliberately NOT given the signature. A subscriber that wants it calls
|
|
127
|
+
* `uiStateSignature()` at the moment it acts, which is the value that will actually be attached
|
|
128
|
+
* to the next tap; handing over a snapshot invites acting on a state that has already moved on.
|
|
129
|
+
*/
|
|
130
|
+
|
|
131
|
+
const listeners = new Set();
|
|
132
|
+
let pending = null;
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Subscribe to declared-state changes. Returns an unsubscribe.
|
|
136
|
+
*
|
|
137
|
+
* Registration is idempotent per function reference, so a component that re-subscribes on every
|
|
138
|
+
* render does not accumulate listeners — a leak that would only show up in long sessions.
|
|
139
|
+
*/
|
|
140
|
+
function onUiStateChange(listener) {
|
|
141
|
+
listeners.add(listener);
|
|
142
|
+
return () => {
|
|
143
|
+
listeners.delete(listener);
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Drop every listener and any pending notification. For teardown between sessions. */
|
|
148
|
+
function resetUiStateListeners() {
|
|
149
|
+
listeners.clear();
|
|
150
|
+
if (pending !== null) {
|
|
151
|
+
clearTimeout(pending);
|
|
152
|
+
pending = null;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** For assertions that a re-subscribing caller has not leaked. */
|
|
157
|
+
function uiStateListenerCount() {
|
|
158
|
+
return listeners.size;
|
|
159
|
+
}
|
|
160
|
+
function notifyChanged() {
|
|
161
|
+
if (pending !== null) return;
|
|
162
|
+
pending = setTimeout(() => {
|
|
163
|
+
pending = null;
|
|
164
|
+
for (const listener of listeners) {
|
|
165
|
+
try {
|
|
166
|
+
listener();
|
|
167
|
+
} catch {
|
|
168
|
+
/* One bad subscriber must not stop the others, and must never surface to the host: a
|
|
169
|
+
screenshot is not worth an app crash. */
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}, 0);
|
|
173
|
+
}
|
|
94
174
|
//# sourceMappingURL=uiState.js.map
|
|
@@ -41,6 +41,15 @@ class AutoScreenDetector {
|
|
|
41
41
|
_manualScreen = null;
|
|
42
42
|
_manualScreenTs = 0;
|
|
43
43
|
_autoScreen = null;
|
|
44
|
+
/**
|
|
45
|
+
* When `_autoScreen` was last written.
|
|
46
|
+
*
|
|
47
|
+
* Needed so `getCurrentScreen()` can prefer whichever source spoke MOST RECENTLY.
|
|
48
|
+
* Without a timestamp the only comparison available was "is the manual value still
|
|
49
|
+
* fresh", which is a different question and gave the wrong answer — see the note on
|
|
50
|
+
* `_currentScreen()`.
|
|
51
|
+
*/
|
|
52
|
+
_autoScreenTs = 0;
|
|
44
53
|
/** `name#key` of the last auto-detected screen VISIT — see _extractActiveVisit. */
|
|
45
54
|
_autoVisitSig = null;
|
|
46
55
|
_navigationRef = null;
|
|
@@ -115,6 +124,7 @@ class AutoScreenDetector {
|
|
|
115
124
|
if (this.isManualScreenActive()) return;
|
|
116
125
|
if (name !== this._autoScreen) {
|
|
117
126
|
this._autoScreen = name;
|
|
127
|
+
this._autoScreenTs = Date.now();
|
|
118
128
|
this._autoVisitSig = name; // native lifecycle carries no route key — name-only visit
|
|
119
129
|
this._emitScreenChange(name, 'heuristic');
|
|
120
130
|
}
|
|
@@ -122,8 +132,19 @@ class AutoScreenDetector {
|
|
|
122
132
|
}
|
|
123
133
|
|
|
124
134
|
/**
|
|
125
|
-
*
|
|
126
|
-
*
|
|
135
|
+
* Should a WRITER defer to the manual screen right now?
|
|
136
|
+
*
|
|
137
|
+
* THIS IS A PRECEDENCE GATE, NOT A VALIDITY TEST, and conflating the two was a real
|
|
138
|
+
* bug. Its job is what the cooldown constant says: for a short window after a manual
|
|
139
|
+
* beacon fires, an auto source must not overwrite it — the poller runs every 1500 ms
|
|
140
|
+
* and would otherwise clobber a fresh declaration with whatever it inferred.
|
|
141
|
+
*
|
|
142
|
+
* `getCurrentScreen()` used to reuse this as "is the manual screen still the current
|
|
143
|
+
* screen", which made a declared screen READABLE for only 2000 ms. Nothing called
|
|
144
|
+
* `setManualScreen` in production, so the effect was latent — but the moment the
|
|
145
|
+
* `<ScaleBunScreen>` beacon was wired to it (RN-8), every tap more than two seconds
|
|
146
|
+
* after a screen mounted would have lost its name, which is very nearly every tap. A
|
|
147
|
+
* fix that introduced that would have been worse than the gap it closed.
|
|
127
148
|
*/
|
|
128
149
|
isManualScreenActive() {
|
|
129
150
|
if (!this._manualScreen) return false;
|
|
@@ -183,6 +204,7 @@ class AutoScreenDetector {
|
|
|
183
204
|
const visit = this._extractActiveVisit(state);
|
|
184
205
|
if (visit && this._visitSig(visit) !== this._autoVisitSig) {
|
|
185
206
|
this._autoScreen = visit.name;
|
|
207
|
+
this._autoScreenTs = Date.now();
|
|
186
208
|
this._autoVisitSig = this._visitSig(visit);
|
|
187
209
|
this._emitScreenChange(visit.name, 'navRef');
|
|
188
210
|
}
|
|
@@ -201,6 +223,7 @@ class AutoScreenDetector {
|
|
|
201
223
|
const visit = this._extractActiveVisit(state);
|
|
202
224
|
if (visit) {
|
|
203
225
|
this._autoScreen = visit.name;
|
|
226
|
+
this._autoScreenTs = Date.now();
|
|
204
227
|
this._autoVisitSig = this._visitSig(visit);
|
|
205
228
|
this._emitScreenChange(visit.name, 'navRef');
|
|
206
229
|
}
|
|
@@ -244,6 +267,7 @@ class AutoScreenDetector {
|
|
|
244
267
|
const visit = this._extractActiveVisit(state);
|
|
245
268
|
if (visit && this._visitSig(visit) !== this._autoVisitSig) {
|
|
246
269
|
this._autoScreen = visit.name;
|
|
270
|
+
this._autoScreenTs = Date.now();
|
|
247
271
|
this._autoVisitSig = this._visitSig(visit);
|
|
248
272
|
this._emitScreenChange(visit.name, 'heuristic');
|
|
249
273
|
}
|
|
@@ -500,16 +524,61 @@ class AutoScreenDetector {
|
|
|
500
524
|
this._listeners = [];
|
|
501
525
|
this._manualScreen = null;
|
|
502
526
|
this._autoScreen = null;
|
|
527
|
+
this._autoScreenTs = 0;
|
|
503
528
|
this._navigationRef = null;
|
|
504
529
|
_instance = null;
|
|
505
530
|
}
|
|
506
531
|
|
|
507
532
|
/** Get the current best-known screen name */
|
|
508
533
|
getCurrentScreen() {
|
|
509
|
-
|
|
510
|
-
|
|
534
|
+
return this._currentScreen().name;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
/**
|
|
538
|
+
* WHERE the current screen name came from, or 'none'.
|
|
539
|
+
*
|
|
540
|
+
* Recorded on every interaction (RN-8) so low screen coverage is DIAGNOSABLE rather
|
|
541
|
+
* than merely visible. Production shows 13.8% of Android interactions carrying a
|
|
542
|
+
* screen and 0.5% on iOS, and those numbers cannot today distinguish three different
|
|
543
|
+
* causes: the app never instrumented anything, the app instrumented manually and the
|
|
544
|
+
* value never reached interactions, or auto-detection ran and failed. Those have
|
|
545
|
+
* different owners and different fixes, and guessing between them is how a P0 stays
|
|
546
|
+
* open.
|
|
547
|
+
*/
|
|
548
|
+
getCurrentScreenSource() {
|
|
549
|
+
return this._currentScreen().source;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/**
|
|
553
|
+
* LAST WRITER WINS between the manual beacon and auto-detection.
|
|
554
|
+
*
|
|
555
|
+
* A screen declared by `<ScaleBunScreen name>` stays current until something else
|
|
556
|
+
* declares a different one — that is what mounting a screen means. It does not
|
|
557
|
+
* expire, which is the correction here: the previous rule returned the manual name
|
|
558
|
+
* only while it was under 2000 ms old and then silently fell back to the auto value,
|
|
559
|
+
* which in a manually-instrumented app is `null`.
|
|
560
|
+
*
|
|
561
|
+
* Comparing timestamps rather than hard-coding a priority also handles the real
|
|
562
|
+
* interleaving: an app can use React Navigation for most screens AND drop a manual
|
|
563
|
+
* beacon on one modal, and whichever spoke last is the truth.
|
|
564
|
+
*/
|
|
565
|
+
_currentScreen() {
|
|
566
|
+
const manual = this._manualScreen;
|
|
567
|
+
const auto = this._autoScreen;
|
|
568
|
+
if (manual && (!auto || this._manualScreenTs >= this._autoScreenTs)) {
|
|
569
|
+
return {
|
|
570
|
+
name: manual,
|
|
571
|
+
source: 'manual'
|
|
572
|
+
};
|
|
511
573
|
}
|
|
512
|
-
return
|
|
574
|
+
if (auto) return {
|
|
575
|
+
name: auto,
|
|
576
|
+
source: 'navigation'
|
|
577
|
+
};
|
|
578
|
+
return {
|
|
579
|
+
name: null,
|
|
580
|
+
source: 'none'
|
|
581
|
+
};
|
|
513
582
|
}
|
|
514
583
|
|
|
515
584
|
// ─── Internal ───────────────────────────────────────────────────────
|
|
@@ -5,6 +5,7 @@ Object.defineProperty(exports, "__esModule", {
|
|
|
5
5
|
});
|
|
6
6
|
exports.NetworkFeature = void 0;
|
|
7
7
|
var _index = require("./index");
|
|
8
|
+
var _thirdParty = require("./thirdParty");
|
|
8
9
|
var _networkAdapter = require("../replay/integrations/network/networkAdapter");
|
|
9
10
|
var _SessionManager = require("../session/SessionManager");
|
|
10
11
|
var _screenTracking = require("../../debug/screenTracking");
|
|
@@ -50,12 +51,28 @@ class NetworkFeature {
|
|
|
50
51
|
// self-capture loop. Apply the SAME isSdkInternalUrl filter the replay
|
|
51
52
|
// branch below already uses so genuine app requests still flow through.
|
|
52
53
|
const isInternal = isSdkInternalUrl(data.url, this.options.sdkBaseUrl);
|
|
53
|
-
|
|
54
|
+
/**
|
|
55
|
+
* Beacons and denied URLs leave NO trace, in any lane.
|
|
56
|
+
*
|
|
57
|
+
* A network request reaches three places from here: the analytics echo
|
|
58
|
+
* (`$network_request`), the local replay pipeline, and the backend network endpoint
|
|
59
|
+
* that becomes the `network_requests` row the session panel renders. Filtering one
|
|
60
|
+
* and not the others is how a request half-disappears and two counts of the same
|
|
61
|
+
* call stop agreeing -- so this is applied to all three.
|
|
62
|
+
*
|
|
63
|
+
* Kept separate from `isInternal` rather than folded into it: the SDK's own uploads
|
|
64
|
+
* are already excluded from two of the three lanes and DELIBERATELY still reach the
|
|
65
|
+
* local replay pipeline. Widening that here would be an unrelated behaviour change
|
|
66
|
+
* riding along on a filter.
|
|
67
|
+
*/
|
|
68
|
+
const isBeaconOrDenied = this.options.captureThirdParty !== true && (0, _thirdParty.isThirdPartyBeacon)(data.url, this.options.thirdPartyHosts) || matchesDenyUrl(data.url, this.options.denyUrls);
|
|
69
|
+
const isExcluded = isInternal || isBeaconOrDenied;
|
|
70
|
+
if (!isExcluded) {
|
|
54
71
|
this.trackFn('$network_request', data);
|
|
55
72
|
}
|
|
56
73
|
|
|
57
74
|
// Bridge the raw network event into the Session Replay pipeline
|
|
58
|
-
if (data.type === 'network') {
|
|
75
|
+
if (data.type === 'network' && !isBeaconOrDenied) {
|
|
59
76
|
(0, _networkAdapter.reportNetworkRequest)({
|
|
60
77
|
method: data.method,
|
|
61
78
|
url: data.url,
|
|
@@ -78,8 +95,10 @@ class NetworkFeature {
|
|
|
78
95
|
// (correlated by sessionId+timestamp), so we no longer emit
|
|
79
96
|
// NETWORK_REQUEST_* replay events here (that produced duplicate rows
|
|
80
97
|
// and count divergence between replay_events and network_requests).
|
|
81
|
-
// Skip SDK's own upload calls to prevent recursive event spam
|
|
82
|
-
|
|
98
|
+
// Skip SDK's own upload calls to prevent recursive event spam, and anything the
|
|
99
|
+
// beacon/denyUrls filter excluded -- this is the lane that becomes the
|
|
100
|
+
// network_requests row a person sees on the session panel.
|
|
101
|
+
if (!isExcluded) {
|
|
83
102
|
const sessionManager = _SessionManager.SessionManager.getExistingInstance();
|
|
84
103
|
if (sessionManager) {
|
|
85
104
|
const backendTransport = sessionManager.getBackendTransport();
|
|
@@ -113,9 +132,24 @@ class NetworkFeature {
|
|
|
113
132
|
}
|
|
114
133
|
}
|
|
115
134
|
|
|
135
|
+
/**
|
|
136
|
+
* Substring or RegExp, the same rule as the web SDK's `matchUrl`.
|
|
137
|
+
*
|
|
138
|
+
* Spelled out rather than shared because the two SDKs have no common runtime package, and the
|
|
139
|
+
* rule is two lines. What must NOT differ is the beacon list, which is why that one is a
|
|
140
|
+
* byte-identical vendored copy with a hash gate rather than a retyped constant.
|
|
141
|
+
*/
|
|
142
|
+
exports.NetworkFeature = NetworkFeature;
|
|
143
|
+
function matchesDenyUrl(url, patterns) {
|
|
144
|
+
if (!patterns || !patterns.length || !url) return false;
|
|
145
|
+
for (const p of patterns) {
|
|
146
|
+
if (typeof p === 'string' ? url.includes(p) : p.test(url)) return true;
|
|
147
|
+
}
|
|
148
|
+
return false;
|
|
149
|
+
}
|
|
150
|
+
|
|
116
151
|
// Returns true if the URL is a ScaleBun SDK internal endpoint that must not be
|
|
117
152
|
// captured — doing so would cause recursive network event spam.
|
|
118
|
-
exports.NetworkFeature = NetworkFeature;
|
|
119
153
|
function isSdkInternalUrl(url, sdkBaseUrl) {
|
|
120
154
|
if (!url) return false;
|
|
121
155
|
try {
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
Object.defineProperty(exports, "__esModule", {
|
|
4
|
+
value: true
|
|
5
|
+
});
|
|
6
|
+
exports.THIRD_PARTY_BEACON_HOSTS = void 0;
|
|
7
|
+
exports.isThirdPartyBeacon = isThirdPartyBeacon;
|
|
8
|
+
/**
|
|
9
|
+
* THIRD-PARTY BEACON HOSTS — the one list, and the one matcher, for every layer that drops them.
|
|
10
|
+
*
|
|
11
|
+
* WHAT THIS IS FOR
|
|
12
|
+
* A session on an instrumented site spends most of its network tab on traffic the app did not
|
|
13
|
+
* meaningfully make: analytics collectors, ad pixels, IP-enrichment calls, tag managers. It is
|
|
14
|
+
* captured, uploaded, stored and rendered, and none of it answers a question anyone asks a session
|
|
15
|
+
* replay. One real sign-in screen was 27 requests, most of them beacons.
|
|
16
|
+
*
|
|
17
|
+
* It is not only clutter. Session health scores `failedRequests` and `slowRequests`, so a tracker
|
|
18
|
+
* killed by an ad blocker (a transport error) or one that is simply slow moves a number that is
|
|
19
|
+
* supposed to describe the APP. That is the part that makes this a correctness fix rather than a
|
|
20
|
+
* tidy-up.
|
|
21
|
+
*
|
|
22
|
+
* WHY HOSTS ONLY, NEVER PATHS
|
|
23
|
+
* The obvious rules from looking at a network tab are `/collect`, `/p`, `/api/v2/pixel`. Every one
|
|
24
|
+
* of them is a path that a customer's own API is allowed to have, and dropping a real API call is
|
|
25
|
+
* far worse than keeping a beacon: the noise is visible and annoying, the missing request is
|
|
26
|
+
* invisible and misleading. So matching is on the HOSTNAME, with a dot boundary, and a request to
|
|
27
|
+
* `/collect` on the app's own origin is kept — correctly.
|
|
28
|
+
*
|
|
29
|
+
* WHY IT IS A SUFFIX LIST AND NOT A REGEX
|
|
30
|
+
* Two reasons, both practical. It is applied inside an SDK whose eager bundle is measured in bytes,
|
|
31
|
+
* and a comma-joined string of suffixes compresses to almost nothing next to an equivalent pattern
|
|
32
|
+
* source. And it has to be COPIED, byte for byte, into the React Native SDK and into the backend's
|
|
33
|
+
* ingest path — a list with no syntax is a list that cannot drift in meaning between three
|
|
34
|
+
* languages' worth of tooling. `third-party:parity` hashes all three copies.
|
|
35
|
+
*
|
|
36
|
+
* ZERO IMPORTS, DELIBERATELY. That is what makes the byte-identical copy possible. If an edit here
|
|
37
|
+
* ever needs an import, the parity gate is the thing that will say so — keep the module pure rather
|
|
38
|
+
* than weakening the gate.
|
|
39
|
+
*
|
|
40
|
+
* WHAT IS DELIBERATELY NOT ON THE LIST
|
|
41
|
+
* The rule is: a host whose ONLY purpose is to receive telemetry. Everything else stays, because a
|
|
42
|
+
* dropped request is invisible and a kept one is merely noisy.
|
|
43
|
+
*
|
|
44
|
+
* - Feature-flag and experiment services (LaunchDarkly, Optimizely, Split). Their RESPONSES
|
|
45
|
+
* change what the app does, so a failed flag fetch is an application incident and the single
|
|
46
|
+
* most useful request in the session.
|
|
47
|
+
* - Chat and support widgets (Intercom, Zendesk, Drift). A broken widget is a broken feature the
|
|
48
|
+
* user can see.
|
|
49
|
+
* - `facebook.com` as a whole. `graph.facebook.com` is an API real apps call; only the pixel
|
|
50
|
+
* delivery hosts are listed.
|
|
51
|
+
* - CDNs of any kind, even ones that mostly serve tags: they also serve application assets, and
|
|
52
|
+
* dropping them would hide a real asset failure.
|
|
53
|
+
*
|
|
54
|
+
* When in doubt, leave it out. `denyUrls` stays the per-app escape hatch in both SDKs, and it can
|
|
55
|
+
* do what this list will not.
|
|
56
|
+
*/
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Known telemetry, advertising and enrichment endpoints, as hostname suffixes.
|
|
60
|
+
*
|
|
61
|
+
* One comma-joined string rather than an array: identical meaning, materially smaller once gzipped,
|
|
62
|
+
* and it keeps the copy in three repos a single line to compare by eye.
|
|
63
|
+
*/
|
|
64
|
+
const THIRD_PARTY_BEACON_HOSTS = exports.THIRD_PARTY_BEACON_HOSTS = 'google-analytics.com,googletagmanager.com,analytics.google.com,doubleclick.net,googlesyndication.com,' + 'googleadservices.com,app-measurement.com,facebook.net,analytics.tiktok.com,' + 'ads-twitter.com,analytics.twitter.com,ct.pinterest.com,bat.bing.com,px.ads.linkedin.com,tr.snapchat.com,' + 'criteo.com,criteo.net,taboola.com,outbrain.com,adroll.com,adsrvr.org,quantserve.com,scorecardresearch.com,' + 'hotjar.com,hotjar.io,clarity.ms,fullstory.com,mouseflow.com,crazyegg.com,luckyorange.com,inspectlet.com,' + 'segment.io,mixpanel.com,amplitude.com,heap.io,heapanalytics.com,rudderstack.com,posthog.com,' + 'matomo.cloud,plausible.io,simpleanalytics.com,statcounter.com,' + 'appsflyer.com,adjust.com,branch.io,' + 'ipify.org,ipinfo.io,ipapi.co,ip-api.com,geolocation-db.com,cloudflareinsights.com,' + 'nr-data.net,browser-intake-datadoghq.com,sentry.io,bugsnag.com,rollbar.com';
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* True when `url` is addressed to a known beacon host.
|
|
68
|
+
*
|
|
69
|
+
* Deliberately total: an unparseable or relative URL is NOT third party. A relative URL is
|
|
70
|
+
* same-origin by definition — the app's own call — and guessing on garbage input would drop real
|
|
71
|
+
* requests for no benefit.
|
|
72
|
+
*/
|
|
73
|
+
function isThirdPartyBeacon(url, hosts) {
|
|
74
|
+
if (!url) return false;
|
|
75
|
+
let host = '';
|
|
76
|
+
try {
|
|
77
|
+
// A relative URL throws here, which is the answer we want: it is first-party.
|
|
78
|
+
host = new URL(url).hostname.toLowerCase();
|
|
79
|
+
} catch {
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
if (!host) return false;
|
|
83
|
+
for (const suffix of (hosts ?? THIRD_PARTY_BEACON_HOSTS).split(',')) {
|
|
84
|
+
// Dot boundary, so `notevil-facebook.com` does not match `facebook.com` while
|
|
85
|
+
// `connect.facebook.com` does. The bare-equality arm covers the apex domain itself.
|
|
86
|
+
if (host === suffix || host.endsWith('.' + suffix)) return true;
|
|
87
|
+
}
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
//# sourceMappingURL=thirdParty.js.map
|