@scalebun/react-native 1.13.0 → 2.0.0
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/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 +115 -60
- package/android/src/main/java/com/scalebun/rn/ota/ScaleBunOtaReleaseVerifier.kt +129 -0
- package/android/src/main/java/com/scalebun/rn/ota/SlotManager.kt +18 -0
- package/android/src/oldarch/java/com/scalebun/rn/ota/ScaleBunOtaSpec.kt +4 -8
- package/dist/scalebun.full.js +127 -422
- package/dist/scalebun.slim.js +127 -422
- 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/lib/commonjs/core/config/schema.js +3 -12
- package/lib/commonjs/core/constants/version.js +1 -1
- 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 +108 -60
- package/lib/commonjs/public/ScaleBunFacade.js +5 -41
- package/lib/module/core/config/schema.js +3 -12
- package/lib/module/core/constants/version.js +1 -1
- 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 +108 -60
- package/lib/module/public/ScaleBunFacade.js +5 -41
- package/lib/typescript/core/config/schema.d.ts +0 -3
- package/lib/typescript/core/constants/version.d.ts +1 -1
- 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 +5 -8
- package/lib/typescript/features/ota/OtaTypes.d.ts +22 -0
- package/lib/typescript/public/ScaleBunFacade.d.ts +5 -11
- package/lib/typescript/specs/NativeScaleBunOta.d.ts +30 -20
- package/package.json +3 -13
- package/scalebun-react-native.podspec +4 -0
- package/src/core/config/schema.ts +3 -9
- package/src/core/constants/version.ts +1 -1
- 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 +116 -70
- package/src/features/ota/OtaTypes.ts +23 -1
- package/src/public/ScaleBunFacade.ts +5 -53
- package/src/specs/NativeScaleBunOta.ts +32 -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
|
@@ -12,7 +12,6 @@ var _OtaEventEmitter = require("./OtaEventEmitter");
|
|
|
12
12
|
var _geoCountry = require("./geoCountry");
|
|
13
13
|
var _deviceAttributes = require("./deviceAttributes");
|
|
14
14
|
var _installationId = require("../../core/id/installationId");
|
|
15
|
-
var _signature = require("./signature");
|
|
16
15
|
var _environment = require("./environment");
|
|
17
16
|
var _retry = require("./retry");
|
|
18
17
|
var _StorageBackend = require("../../storage/StorageBackend");
|
|
@@ -155,9 +154,10 @@ class OtaOrchestrator {
|
|
|
155
154
|
/**
|
|
156
155
|
* Initialize the orchestrator. Checks for boot-guard recovery state.
|
|
157
156
|
*
|
|
158
|
-
*
|
|
159
|
-
*
|
|
160
|
-
*
|
|
157
|
+
* Signature enforcement is no longer a JS concern: signed releases are
|
|
158
|
+
* verified and staged in NATIVE code against RSA keys embedded in the app
|
|
159
|
+
* package (SB-OTA-RSA-SHA256-V1), and unsigned releases keep working on
|
|
160
|
+
* SHA-256 integrity alone. There is nothing for the host to configure here.
|
|
161
161
|
*/
|
|
162
162
|
init(config) {
|
|
163
163
|
(0, _crashSafe.noThrow)(() => {
|
|
@@ -170,7 +170,6 @@ class OtaOrchestrator {
|
|
|
170
170
|
// them. Config is still refreshed so late signature setup works.
|
|
171
171
|
if (this.initialized) {
|
|
172
172
|
this.bootGuardConfig = config ?? this.bootGuardConfig;
|
|
173
|
-
this.signatureConfig = config?.signature ?? this.signatureConfig;
|
|
174
173
|
return;
|
|
175
174
|
}
|
|
176
175
|
if (!_NativeScaleBunOta.default) {
|
|
@@ -205,7 +204,6 @@ class OtaOrchestrator {
|
|
|
205
204
|
this.initialized = true;
|
|
206
205
|
this.enabled = true;
|
|
207
206
|
this.bootGuardConfig = config ?? {};
|
|
208
|
-
this.signatureConfig = config?.signature;
|
|
209
207
|
__DEV__ && _internalLogger.logger.debug(`[OTA] Orchestrator initialized (RN ${this.environment.rnVersionString ?? 'unknown'}` + `${this.environment.bridgeless ? ', bridgeless' : ''}` + `${this.environment.hermes ? `, Hermes HBC v${this.environment.hermesBytecodeVersion ?? '?'}` : ''})`);
|
|
210
208
|
|
|
211
209
|
// ONE read of the slot state, shared by both boot-time consumers.
|
|
@@ -485,12 +483,27 @@ class OtaOrchestrator {
|
|
|
485
483
|
// OTA has no bundle for a non-mobile target; claiming Android there would install the wrong one.
|
|
486
484
|
const mobileOS = (0, _device.resolveMobileOS)();
|
|
487
485
|
if (!mobileOS) throw new Error('OTA is not supported on this platform');
|
|
486
|
+
// SDK trust coverage (CICDPAGE §12): report which trusted key IDs are
|
|
487
|
+
// embedded in this binary so the dashboard can measure key-rotation
|
|
488
|
+
// adoption. Telemetry only — the server never trusts a key a device claims.
|
|
489
|
+
// Defensive: optional native method, never let it break a check.
|
|
490
|
+
let trustedKeyIds;
|
|
491
|
+
try {
|
|
492
|
+
const raw = _NativeScaleBunOta.default?.getTrustedKeyIds?.();
|
|
493
|
+
if (raw) {
|
|
494
|
+
const arr = JSON.parse(raw);
|
|
495
|
+
if (Array.isArray(arr) && arr.length) trustedKeyIds = arr.slice(0, 16);
|
|
496
|
+
}
|
|
497
|
+
} catch {
|
|
498
|
+
/* trust reporting is best-effort — never break the update check */
|
|
499
|
+
}
|
|
488
500
|
const payload = {
|
|
489
501
|
appVersion: requestParams.appVersion,
|
|
490
502
|
platform: mobileOS,
|
|
491
503
|
installationId: requestParams.installationId,
|
|
492
504
|
currentBundleId: this.currentBundle?.id,
|
|
493
505
|
currentBundleHash: this.currentBundle?.sha256,
|
|
506
|
+
trustedKeyIds,
|
|
494
507
|
channelName: requestParams.channelName,
|
|
495
508
|
// Host-supplied country wins; otherwise the cached edge-Worker lookup
|
|
496
509
|
// fills in (see geoCountry.ts). Undefined when neither knows — the
|
|
@@ -624,25 +637,12 @@ class OtaOrchestrator {
|
|
|
624
637
|
version: bundle.version
|
|
625
638
|
});
|
|
626
639
|
|
|
627
|
-
// ── VERIFY AUTHENTICITY (OTA-03) ─────────────────────────────────────
|
|
628
|
-
// Before anything touches the disk. SHA-256 proves the bytes arrived
|
|
629
|
-
// intact; only the signature proves they came from you. Checking after
|
|
630
|
-
// staging would mean writing unverified code to the slot directory first.
|
|
631
|
-
const signatureOutcome = await (0, _signature.verifyBundleSignature)(bundle.sha256, bundle.signature, this.signatureConfig);
|
|
632
|
-
if (!signatureOutcome.ok) {
|
|
633
|
-
_OtaEventEmitter.otaEventEmitter.emitSimple('APPLY_FAILED', bundle.id, {
|
|
634
|
-
releaseId: bundle.releaseId,
|
|
635
|
-
error: `Signature check failed: ${signatureOutcome.reason}`,
|
|
636
|
-
version: bundle.version
|
|
637
|
-
});
|
|
638
|
-
return {
|
|
639
|
-
status: 'ERROR',
|
|
640
|
-
error: `Bundle signature check failed (${signatureOutcome.reason})`,
|
|
641
|
-
bundle
|
|
642
|
-
};
|
|
643
|
-
}
|
|
644
|
-
|
|
645
640
|
// ── DOWNLOAD ─────────────────────────────────────────────────────────
|
|
641
|
+
// Authenticity is enforced NATIVELY now (see the envelope branch below):
|
|
642
|
+
// SHA-256 proves the bytes arrived intact, and for signed releases the
|
|
643
|
+
// RSA signature — checked in native code against keys embedded in the app
|
|
644
|
+
// package — proves they came from you. JS neither pins keys nor verifies
|
|
645
|
+
// signatures.
|
|
646
646
|
_OtaEventEmitter.otaEventEmitter.emitSimple('DOWNLOAD_STARTED', bundle.id, {
|
|
647
647
|
releaseId: bundle.releaseId,
|
|
648
648
|
version: bundle.version
|
|
@@ -696,46 +696,94 @@ class OtaOrchestrator {
|
|
|
696
696
|
}
|
|
697
697
|
});
|
|
698
698
|
let staged = false;
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
699
|
+
if (bundle.envelope) {
|
|
700
|
+
// ── NATIVE-ENFORCED VERIFY + STAGE (SB-OTA-RSA-SHA256-V1) ───────────
|
|
701
|
+
// Hand the whole envelope to native code, which verifies the RSA
|
|
702
|
+
// signature against the keys EMBEDDED in the app package, then downloads
|
|
703
|
+
// the artifact, enforces the SIGNED size + SHA-256, and stages it into
|
|
704
|
+
// the `next` slot — all outside JS. JavaScript never stages bytes it has
|
|
705
|
+
// not proven authentic. No patch path here: a signed offer is always a
|
|
706
|
+
// full verified download (native patch is a documented follow-up).
|
|
707
|
+
const envelopeJson = JSON.stringify({
|
|
708
|
+
...bundle.envelope,
|
|
709
|
+
downloadUrl: bundle.url
|
|
710
|
+
});
|
|
711
|
+
let verdict;
|
|
703
712
|
try {
|
|
704
|
-
|
|
705
|
-
);
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
713
|
+
const verdictJson = await _NativeScaleBunOta.default.verifyAndStageRelease(envelopeJson);
|
|
714
|
+
verdict = JSON.parse(verdictJson);
|
|
715
|
+
} catch (err) {
|
|
716
|
+
// A rejected promise (or an unparseable verdict) is machinery failure,
|
|
717
|
+
// never a valid result — treat it as a hard failure.
|
|
718
|
+
verdict = {
|
|
719
|
+
ok: false,
|
|
720
|
+
code: 'verify_stage_threw',
|
|
721
|
+
message: err?.message ?? String(err)
|
|
722
|
+
};
|
|
723
|
+
}
|
|
724
|
+
if (!verdict.ok) {
|
|
725
|
+
unsubscribeProgress();
|
|
726
|
+
postProgress(0, 'FAILED');
|
|
727
|
+
_internalLogger.logger.error(`[OTA] Native verify/stage refused the release: ${verdict.code ?? 'unknown'}` + `${verdict.message ? ` — ${verdict.message}` : ''}`);
|
|
728
|
+
_OtaEventEmitter.otaEventEmitter.emitSimple('APPLY_FAILED', bundle.id, {
|
|
729
|
+
releaseId: bundle.releaseId,
|
|
730
|
+
// `error` is mapped onto the wire `errorCode` by deliverOtaEvents —
|
|
731
|
+
// carry the stable native verdict code there.
|
|
732
|
+
error: verdict.code ?? 'verify_and_stage_release_failed',
|
|
733
|
+
version: bundle.version
|
|
734
|
+
});
|
|
735
|
+
return {
|
|
736
|
+
status: 'ERROR',
|
|
737
|
+
error: `Release verification failed (${verdict.code ?? 'unknown'})`,
|
|
738
|
+
bundle
|
|
739
|
+
};
|
|
740
|
+
}
|
|
741
|
+
staged = true;
|
|
742
|
+
} else {
|
|
743
|
+
// ── LEGACY / UNSIGNED (SHA-256 integrity only) ─────────────────────
|
|
744
|
+
// No envelope means the app embeds no trusted keys and the release is
|
|
745
|
+
// unsigned: keep the pre-RSA behaviour so those apps still update.
|
|
746
|
+
|
|
747
|
+
// Sprint 7: Try patch first if offered, fall back to full bundle
|
|
748
|
+
if (bundle.patch && _NativeScaleBunOta.default.stagePatch) {
|
|
749
|
+
__DEV__ && _internalLogger.logger.debug(`[OTA] Patch available (${(bundle.patch.size / 1024).toFixed(0)} KB vs ` + `${(bundle.size / 1024).toFixed(0)} KB full) — trying patch…`);
|
|
750
|
+
try {
|
|
751
|
+
staged = await _NativeScaleBunOta.default.stagePatch(bundle.patch.url, bundle.patch.sha256, bundle.sha256 // expected full-bundle hash after patching
|
|
752
|
+
);
|
|
753
|
+
if (staged) {
|
|
754
|
+
patchUsed = true;
|
|
755
|
+
__DEV__ && _internalLogger.logger.debug('[OTA] Patch applied successfully');
|
|
756
|
+
} else {
|
|
757
|
+
_internalLogger.logger.warn('[OTA] Patch staging returned false — falling back to full bundle');
|
|
758
|
+
}
|
|
759
|
+
} catch (patchErr) {
|
|
760
|
+
_internalLogger.logger.warn(`[OTA] Patch staging failed: ${patchErr.message} — falling back to full bundle`);
|
|
711
761
|
}
|
|
712
|
-
} catch (patchErr) {
|
|
713
|
-
_internalLogger.logger.warn(`[OTA] Patch staging failed: ${patchErr.message} — falling back to full bundle`);
|
|
714
762
|
}
|
|
715
|
-
}
|
|
716
763
|
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
764
|
+
// Full-bundle fallback (or primary path when no patch is available).
|
|
765
|
+
// Retried with backoff: on a mobile network a transient drop mid-download
|
|
766
|
+
// is the common case, and previously one failure meant waiting for the
|
|
767
|
+
// next cold start — often hours away, sometimes never.
|
|
768
|
+
if (!staged) {
|
|
769
|
+
try {
|
|
770
|
+
staged = await (0, _retry.retryWithBackoff)(async () => {
|
|
771
|
+
const ok = await _NativeScaleBunOta.default.stageBundle(bundle.url, bundle.sha256);
|
|
772
|
+
if (!ok) {
|
|
773
|
+
// Native returns a bare boolean, so a hash mismatch and a
|
|
774
|
+
// dropped connection are indistinguishable here. Retrying a
|
|
775
|
+
// genuine mismatch a couple of times is cheap; not retrying a
|
|
776
|
+
// dropped connection costs the whole update.
|
|
777
|
+
throw new Error('stageBundle returned false');
|
|
778
|
+
}
|
|
779
|
+
return ok;
|
|
780
|
+
}, {
|
|
781
|
+
label: `download v${bundle.version}`,
|
|
782
|
+
attempts: 3
|
|
783
|
+
});
|
|
784
|
+
} catch {
|
|
785
|
+
staged = false;
|
|
786
|
+
}
|
|
739
787
|
}
|
|
740
788
|
}
|
|
741
789
|
unsubscribeProgress();
|
|
@@ -215,17 +215,11 @@ class ScaleBunFacade {
|
|
|
215
215
|
/**
|
|
216
216
|
* Boot the OTA orchestrator when the init config asks for it.
|
|
217
217
|
*
|
|
218
|
-
*
|
|
219
|
-
*
|
|
220
|
-
*
|
|
221
|
-
*
|
|
222
|
-
*
|
|
223
|
-
*
|
|
224
|
-
* Verification comes from `ota.verifySignature` when supplied, else the
|
|
225
|
-
* built-in @noble-based verifier (optional peers). Pinning keys with
|
|
226
|
-
* NEITHER available is fail-CLOSED by design (signature.ts) — an update
|
|
227
|
-
* that cannot be verified is not installed. We say that out loud rather
|
|
228
|
-
* than letting the app discover it as a silent no-update condition.
|
|
218
|
+
* Signature enforcement is no longer configured from JS. Signed releases are
|
|
219
|
+
* verified and staged in NATIVE code against RSA keys embedded in the app
|
|
220
|
+
* package (SB-OTA-RSA-SHA256-V1); unsigned releases keep working on SHA-256
|
|
221
|
+
* integrity. There are no signing-key or `verifySignature` options to pass
|
|
222
|
+
* through — the host embeds its trusted keys natively.
|
|
229
223
|
*/
|
|
230
224
|
_maybeStartOta(rawConfig) {
|
|
231
225
|
const ota = rawConfig?.ota;
|
|
@@ -237,42 +231,12 @@ class ScaleBunFacade {
|
|
|
237
231
|
const {
|
|
238
232
|
otaOrchestrator
|
|
239
233
|
} = require('../features/ota/OtaOrchestrator');
|
|
240
|
-
const rawSingle = ota.publicSigningKey;
|
|
241
|
-
const rawList = ota.publicSigningKeys;
|
|
242
|
-
const publicKeys = Array.from(new Set([...(typeof rawSingle === 'string' && rawSingle ? [rawSingle] : []), ...(Array.isArray(rawList) ? rawList : [])].filter(k => typeof k === 'string' && k.trim().length > 0)));
|
|
243
|
-
// Signing was REQUESTED if a usable key exists, or the host passed a
|
|
244
|
-
// keys array at all — an empty one included. `publicSigningKeys: []`
|
|
245
|
-
// is a broken attempt to enable signing, and it must flow through to
|
|
246
|
-
// signature.ts's fail-closed handling, not silently disable signing.
|
|
247
|
-
const signingRequested = publicKeys.length > 0 || Array.isArray(rawList);
|
|
248
|
-
const verifier = ota.verifySignature;
|
|
249
|
-
if (signingRequested && typeof verifier !== 'function') {
|
|
250
|
-
// A host verifier is optional since the built-in one landed — the
|
|
251
|
-
// old unconditional warning here told every correctly-configured
|
|
252
|
-
// app that its updates would be rejected. Warn only when neither
|
|
253
|
-
// verifier can actually be resolved.
|
|
254
|
-
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
255
|
-
const {
|
|
256
|
-
getBuiltinVerifier
|
|
257
|
-
} = require('../features/ota/crypto/builtinVerifier');
|
|
258
|
-
if (!getBuiltinVerifier()) {
|
|
259
|
-
_internalLogger.logger.warn('[ScaleBun] A signing key is pinned but no signature verifier is available. ' + 'Signature checking is fail-closed: updates will be REJECTED until one exists. ' + 'Install the optional peers `@noble/ed25519` + `@noble/hashes` (no further ' + 'code needed), or supply `ota.verifySignature`.');
|
|
260
|
-
}
|
|
261
|
-
}
|
|
262
234
|
// `healthyAfterMs` is the field BootGuardConfig actually declares.
|
|
263
235
|
// This passed `healthyTimeoutMs`, which nothing reads, so a host that
|
|
264
236
|
// tuned the boot-guard window silently got the 10s default instead.
|
|
265
237
|
// Both spellings are accepted so the older one keeps working.
|
|
266
238
|
const healthyAfterMs = typeof ota.healthyAfterMs === 'number' ? ota.healthyAfterMs : typeof ota.healthyTimeoutMs === 'number' ? ota.healthyTimeoutMs : undefined;
|
|
267
239
|
otaOrchestrator.init({
|
|
268
|
-
...(signingRequested ? {
|
|
269
|
-
signature: {
|
|
270
|
-
// Single key stays a plain string — the shape every
|
|
271
|
-
// existing consumer and test already handles.
|
|
272
|
-
publicKey: publicKeys.length === 1 ? publicKeys[0] : publicKeys,
|
|
273
|
-
verifier
|
|
274
|
-
}
|
|
275
|
-
} : {}),
|
|
276
240
|
...(healthyAfterMs !== undefined ? {
|
|
277
241
|
healthyAfterMs
|
|
278
242
|
} : {})
|
|
@@ -285,18 +285,9 @@ const SHAPE = {
|
|
|
285
285
|
kind: 'str',
|
|
286
286
|
opt: true
|
|
287
287
|
},
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
},
|
|
292
|
-
// Rotation support: several pinned keys, any of which may verify a
|
|
293
|
-
// bundle. Ship a build pinning [old, new], re-sign server-side with
|
|
294
|
-
// new, drop old next release — no emergency store submission when a
|
|
295
|
-
// key must be replaced. Merged with publicSigningKey by the facade.
|
|
296
|
-
publicSigningKeys: {
|
|
297
|
-
kind: 'strArr',
|
|
298
|
-
opt: true
|
|
299
|
-
},
|
|
288
|
+
// No signing-key fields: signed releases are verified natively against
|
|
289
|
+
// RSA keys embedded in the app package (SB-OTA-RSA-SHA256-V1). JS must
|
|
290
|
+
// not supply a trusted key.
|
|
300
291
|
mandatoryBlocksUi: bool(false)
|
|
301
292
|
}
|
|
302
293
|
}
|
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
*/
|
|
25
25
|
|
|
26
26
|
import { installNetworkMonitoring } from './index';
|
|
27
|
+
import { isThirdPartyBeacon } from './thirdParty';
|
|
27
28
|
import { reportNetworkRequest } from '../replay/integrations/network/networkAdapter';
|
|
28
29
|
import { SessionManager } from '../session/SessionManager';
|
|
29
30
|
import { getTrackedScreen } from '../../debug/screenTracking';
|
|
@@ -44,12 +45,28 @@ export class NetworkFeature {
|
|
|
44
45
|
// self-capture loop. Apply the SAME isSdkInternalUrl filter the replay
|
|
45
46
|
// branch below already uses so genuine app requests still flow through.
|
|
46
47
|
const isInternal = isSdkInternalUrl(data.url, this.options.sdkBaseUrl);
|
|
47
|
-
|
|
48
|
+
/**
|
|
49
|
+
* Beacons and denied URLs leave NO trace, in any lane.
|
|
50
|
+
*
|
|
51
|
+
* A network request reaches three places from here: the analytics echo
|
|
52
|
+
* (`$network_request`), the local replay pipeline, and the backend network endpoint
|
|
53
|
+
* that becomes the `network_requests` row the session panel renders. Filtering one
|
|
54
|
+
* and not the others is how a request half-disappears and two counts of the same
|
|
55
|
+
* call stop agreeing -- so this is applied to all three.
|
|
56
|
+
*
|
|
57
|
+
* Kept separate from `isInternal` rather than folded into it: the SDK's own uploads
|
|
58
|
+
* are already excluded from two of the three lanes and DELIBERATELY still reach the
|
|
59
|
+
* local replay pipeline. Widening that here would be an unrelated behaviour change
|
|
60
|
+
* riding along on a filter.
|
|
61
|
+
*/
|
|
62
|
+
const isBeaconOrDenied = this.options.captureThirdParty !== true && isThirdPartyBeacon(data.url, this.options.thirdPartyHosts) || matchesDenyUrl(data.url, this.options.denyUrls);
|
|
63
|
+
const isExcluded = isInternal || isBeaconOrDenied;
|
|
64
|
+
if (!isExcluded) {
|
|
48
65
|
this.trackFn('$network_request', data);
|
|
49
66
|
}
|
|
50
67
|
|
|
51
68
|
// Bridge the raw network event into the Session Replay pipeline
|
|
52
|
-
if (data.type === 'network') {
|
|
69
|
+
if (data.type === 'network' && !isBeaconOrDenied) {
|
|
53
70
|
reportNetworkRequest({
|
|
54
71
|
method: data.method,
|
|
55
72
|
url: data.url,
|
|
@@ -72,8 +89,10 @@ export class NetworkFeature {
|
|
|
72
89
|
// (correlated by sessionId+timestamp), so we no longer emit
|
|
73
90
|
// NETWORK_REQUEST_* replay events here (that produced duplicate rows
|
|
74
91
|
// and count divergence between replay_events and network_requests).
|
|
75
|
-
// Skip SDK's own upload calls to prevent recursive event spam
|
|
76
|
-
|
|
92
|
+
// Skip SDK's own upload calls to prevent recursive event spam, and anything the
|
|
93
|
+
// beacon/denyUrls filter excluded -- this is the lane that becomes the
|
|
94
|
+
// network_requests row a person sees on the session panel.
|
|
95
|
+
if (!isExcluded) {
|
|
77
96
|
const sessionManager = SessionManager.getExistingInstance();
|
|
78
97
|
if (sessionManager) {
|
|
79
98
|
const backendTransport = sessionManager.getBackendTransport();
|
|
@@ -107,6 +126,21 @@ export class NetworkFeature {
|
|
|
107
126
|
}
|
|
108
127
|
}
|
|
109
128
|
|
|
129
|
+
/**
|
|
130
|
+
* Substring or RegExp, the same rule as the web SDK's `matchUrl`.
|
|
131
|
+
*
|
|
132
|
+
* Spelled out rather than shared because the two SDKs have no common runtime package, and the
|
|
133
|
+
* rule is two lines. What must NOT differ is the beacon list, which is why that one is a
|
|
134
|
+
* byte-identical vendored copy with a hash gate rather than a retyped constant.
|
|
135
|
+
*/
|
|
136
|
+
function matchesDenyUrl(url, patterns) {
|
|
137
|
+
if (!patterns || !patterns.length || !url) return false;
|
|
138
|
+
for (const p of patterns) {
|
|
139
|
+
if (typeof p === 'string' ? url.includes(p) : p.test(url)) return true;
|
|
140
|
+
}
|
|
141
|
+
return false;
|
|
142
|
+
}
|
|
143
|
+
|
|
110
144
|
// Returns true if the URL is a ScaleBun SDK internal endpoint that must not be
|
|
111
145
|
// captured — doing so would cause recursive network event spam.
|
|
112
146
|
function isSdkInternalUrl(url, sdkBaseUrl) {
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THIRD-PARTY BEACON HOSTS — the one list, and the one matcher, for every layer that drops them.
|
|
3
|
+
*
|
|
4
|
+
* WHAT THIS IS FOR
|
|
5
|
+
* A session on an instrumented site spends most of its network tab on traffic the app did not
|
|
6
|
+
* meaningfully make: analytics collectors, ad pixels, IP-enrichment calls, tag managers. It is
|
|
7
|
+
* captured, uploaded, stored and rendered, and none of it answers a question anyone asks a session
|
|
8
|
+
* replay. One real sign-in screen was 27 requests, most of them beacons.
|
|
9
|
+
*
|
|
10
|
+
* It is not only clutter. Session health scores `failedRequests` and `slowRequests`, so a tracker
|
|
11
|
+
* killed by an ad blocker (a transport error) or one that is simply slow moves a number that is
|
|
12
|
+
* supposed to describe the APP. That is the part that makes this a correctness fix rather than a
|
|
13
|
+
* tidy-up.
|
|
14
|
+
*
|
|
15
|
+
* WHY HOSTS ONLY, NEVER PATHS
|
|
16
|
+
* The obvious rules from looking at a network tab are `/collect`, `/p`, `/api/v2/pixel`. Every one
|
|
17
|
+
* of them is a path that a customer's own API is allowed to have, and dropping a real API call is
|
|
18
|
+
* far worse than keeping a beacon: the noise is visible and annoying, the missing request is
|
|
19
|
+
* invisible and misleading. So matching is on the HOSTNAME, with a dot boundary, and a request to
|
|
20
|
+
* `/collect` on the app's own origin is kept — correctly.
|
|
21
|
+
*
|
|
22
|
+
* WHY IT IS A SUFFIX LIST AND NOT A REGEX
|
|
23
|
+
* Two reasons, both practical. It is applied inside an SDK whose eager bundle is measured in bytes,
|
|
24
|
+
* and a comma-joined string of suffixes compresses to almost nothing next to an equivalent pattern
|
|
25
|
+
* source. And it has to be COPIED, byte for byte, into the React Native SDK and into the backend's
|
|
26
|
+
* ingest path — a list with no syntax is a list that cannot drift in meaning between three
|
|
27
|
+
* languages' worth of tooling. `third-party:parity` hashes all three copies.
|
|
28
|
+
*
|
|
29
|
+
* ZERO IMPORTS, DELIBERATELY. That is what makes the byte-identical copy possible. If an edit here
|
|
30
|
+
* ever needs an import, the parity gate is the thing that will say so — keep the module pure rather
|
|
31
|
+
* than weakening the gate.
|
|
32
|
+
*
|
|
33
|
+
* WHAT IS DELIBERATELY NOT ON THE LIST
|
|
34
|
+
* The rule is: a host whose ONLY purpose is to receive telemetry. Everything else stays, because a
|
|
35
|
+
* dropped request is invisible and a kept one is merely noisy.
|
|
36
|
+
*
|
|
37
|
+
* - Feature-flag and experiment services (LaunchDarkly, Optimizely, Split). Their RESPONSES
|
|
38
|
+
* change what the app does, so a failed flag fetch is an application incident and the single
|
|
39
|
+
* most useful request in the session.
|
|
40
|
+
* - Chat and support widgets (Intercom, Zendesk, Drift). A broken widget is a broken feature the
|
|
41
|
+
* user can see.
|
|
42
|
+
* - `facebook.com` as a whole. `graph.facebook.com` is an API real apps call; only the pixel
|
|
43
|
+
* delivery hosts are listed.
|
|
44
|
+
* - CDNs of any kind, even ones that mostly serve tags: they also serve application assets, and
|
|
45
|
+
* dropping them would hide a real asset failure.
|
|
46
|
+
*
|
|
47
|
+
* When in doubt, leave it out. `denyUrls` stays the per-app escape hatch in both SDKs, and it can
|
|
48
|
+
* do what this list will not.
|
|
49
|
+
*/
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Known telemetry, advertising and enrichment endpoints, as hostname suffixes.
|
|
53
|
+
*
|
|
54
|
+
* One comma-joined string rather than an array: identical meaning, materially smaller once gzipped,
|
|
55
|
+
* and it keeps the copy in three repos a single line to compare by eye.
|
|
56
|
+
*/
|
|
57
|
+
export const 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';
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* True when `url` is addressed to a known beacon host.
|
|
61
|
+
*
|
|
62
|
+
* Deliberately total: an unparseable or relative URL is NOT third party. A relative URL is
|
|
63
|
+
* same-origin by definition — the app's own call — and guessing on garbage input would drop real
|
|
64
|
+
* requests for no benefit.
|
|
65
|
+
*/
|
|
66
|
+
export function isThirdPartyBeacon(url, hosts) {
|
|
67
|
+
if (!url) return false;
|
|
68
|
+
let host = '';
|
|
69
|
+
try {
|
|
70
|
+
// A relative URL throws here, which is the answer we want: it is first-party.
|
|
71
|
+
host = new URL(url).hostname.toLowerCase();
|
|
72
|
+
} catch {
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
if (!host) return false;
|
|
76
|
+
for (const suffix of (hosts ?? THIRD_PARTY_BEACON_HOSTS).split(',')) {
|
|
77
|
+
// Dot boundary, so `notevil-facebook.com` does not match `facebook.com` while
|
|
78
|
+
// `connect.facebook.com` does. The bare-equality arm covers the apex domain itself.
|
|
79
|
+
if (host === suffix || host.endsWith('.' + suffix)) return true;
|
|
80
|
+
}
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
//# sourceMappingURL=thirdParty.js.map
|