@sendoracloud/sdk-react-native 1.36.0 → 1.39.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/dist/index.cjs +209 -55
- package/dist/index.d.cts +72 -17
- package/dist/index.d.ts +72 -17
- package/dist/index.js +209 -55
- package/package.json +2 -2
package/dist/index.cjs
CHANGED
|
@@ -228,7 +228,7 @@ var Storage = class {
|
|
|
228
228
|
// package.json
|
|
229
229
|
var package_default = {
|
|
230
230
|
name: "@sendoracloud/sdk-react-native",
|
|
231
|
-
version: "1.
|
|
231
|
+
version: "1.39.0",
|
|
232
232
|
description: "Sendora Cloud React Native + Expo SDK \u2014 analytics, identity, push-token registration, auth (incl. Game Center / Play Games), deep links (Branch / Firebase Dynamic Links parity). Auth + analytics + deep links work in Expo Go; push-token registration and the no-arg Game Center / Play Games sign-in require a Dev Client (EAS Build or `npx expo prebuild`).",
|
|
233
233
|
type: "module",
|
|
234
234
|
main: "./dist/index.cjs",
|
|
@@ -284,7 +284,7 @@ var package_default = {
|
|
|
284
284
|
scripts: {
|
|
285
285
|
build: "tsup src/index.ts src/contact-widget.tsx --format esm,cjs --dts --clean --external react --external react-native --external react-native-webview",
|
|
286
286
|
typecheck: "tsc --noEmit",
|
|
287
|
-
test: "tsx test/secure-storage.test.ts && tsx test/integration-storage.test.ts && tsx test/plugin.test.ts && tsx test/game-auth.test.ts && tsx test/link.test.ts && tsx test/session-preservation.test.ts && tsx test/credential-collision.test.ts && tsx test/token-expiry.test.ts && tsx test/unlink.test.ts"
|
|
287
|
+
test: "tsx test/secure-storage.test.ts && tsx test/integration-storage.test.ts && tsx test/plugin.test.ts && tsx test/game-auth.test.ts && tsx test/link.test.ts && tsx test/session-preservation.test.ts && tsx test/credential-collision.test.ts && tsx test/token-expiry.test.ts && tsx test/unlink.test.ts && tsx test/deferred-match.test.ts && tsx test/anon-id-stability.test.ts && tsx test/event-queue.test.ts"
|
|
288
288
|
},
|
|
289
289
|
devDependencies: {
|
|
290
290
|
"@types/react": "^19.2.16",
|
|
@@ -2156,7 +2156,7 @@ var Auth = class {
|
|
|
2156
2156
|
this.hooks.storage.remove(TOKEN_EXPIRES_KEY);
|
|
2157
2157
|
this.hooks.storage.remove(REFRESH_KEY);
|
|
2158
2158
|
this.stopProactiveRefreshCron();
|
|
2159
|
-
await this.hooks.onAnonymousWipe();
|
|
2159
|
+
await this.hooks.onAnonymousWipe(reason);
|
|
2160
2160
|
if (reason !== "replaced") {
|
|
2161
2161
|
this.emitAuthState({ event: "signed_out", user: null, reason });
|
|
2162
2162
|
}
|
|
@@ -2627,33 +2627,31 @@ var Links = class {
|
|
|
2627
2627
|
}
|
|
2628
2628
|
}
|
|
2629
2629
|
/**
|
|
2630
|
-
* Cold-launch deferred deep
|
|
2631
|
-
*
|
|
2632
|
-
*
|
|
2633
|
-
*
|
|
2634
|
-
*
|
|
2630
|
+
* Cold-launch deferred deep-link match.
|
|
2631
|
+
*
|
|
2632
|
+
* ⚠ NO IMPLICIT FINGERPRINTING (compliance). This used to compute a device
|
|
2633
|
+
* fingerprint on iOS when you passed no signal; it no longer does — Apple
|
|
2634
|
+
* prohibits deriving a device identifier from device signals for attribution
|
|
2635
|
+
* (App Store 5.1.2 / DPLA §3.3.9), regardless of ATT, and doing it on-device is
|
|
2636
|
+
* exactly that. Signal resolution now:
|
|
2637
|
+
* - Android: probes `react-native-play-install-referrer` — deterministic, the
|
|
2638
|
+
* preferred and compliant path.
|
|
2639
|
+
* - iOS: no deterministic signal exists (the App Store has no install referrer).
|
|
2640
|
+
* With `allowProbabilisticMatch: true` the server matches on a coarse
|
|
2641
|
+
* IP + time window (OFF by default — see `LinkMatchInput`). Otherwise this
|
|
2642
|
+
* returns `null` and you route the user through your normal onboarding UX —
|
|
2643
|
+
* which is the expected iOS cold-launch outcome, not an error.
|
|
2635
2644
|
*
|
|
2636
2645
|
* Fires `onLinkOpened` with `isDeferred: true` on success.
|
|
2637
2646
|
*/
|
|
2638
2647
|
async matchDeferred(input = {}) {
|
|
2639
|
-
|
|
2648
|
+
const fingerprintHash = input.fingerprintHash;
|
|
2640
2649
|
let installReferrer = input.installReferrer;
|
|
2641
2650
|
if (!fingerprintHash && !installReferrer) {
|
|
2642
2651
|
installReferrer = await getPlayInstallReferrer() ?? void 0;
|
|
2643
|
-
if (!installReferrer) {
|
|
2644
|
-
try {
|
|
2645
|
-
fingerprintHash = await computeDeviceFingerprint();
|
|
2646
|
-
} catch (err) {
|
|
2647
|
-
if (this.deps.debug) console.warn("[sendoracloud] fingerprint compute failed:", err);
|
|
2648
|
-
}
|
|
2649
|
-
}
|
|
2650
|
-
}
|
|
2651
|
-
if (!fingerprintHash && !installReferrer) {
|
|
2652
|
-
throw new LinkError(
|
|
2653
|
-
"INVALID_INPUT",
|
|
2654
|
-
"links.matchDeferred: install `react-native-play-install-referrer` for Android, or ensure web crypto / expo-crypto is available for iOS fingerprint compute."
|
|
2655
|
-
);
|
|
2656
2652
|
}
|
|
2653
|
+
const canMatch = !!fingerprintHash || !!installReferrer || input.allowProbabilisticMatch === true;
|
|
2654
|
+
if (!canMatch) return null;
|
|
2657
2655
|
const body = {};
|
|
2658
2656
|
if (fingerprintHash) body.fingerprintHash = fingerprintHash;
|
|
2659
2657
|
if (installReferrer) body.installReferrer = installReferrer;
|
|
@@ -2857,10 +2855,139 @@ function installAutoTrack(args) {
|
|
|
2857
2855
|
};
|
|
2858
2856
|
}
|
|
2859
2857
|
|
|
2858
|
+
// src/event-queue.ts
|
|
2859
|
+
var MAX_QUEUE = 500;
|
|
2860
|
+
var MAX_ATTEMPTS = 50;
|
|
2861
|
+
var BASE_BACKOFF_MS = 1e3;
|
|
2862
|
+
var MAX_BACKOFF_MS = 6e4;
|
|
2863
|
+
var EventQueue = class {
|
|
2864
|
+
constructor(deps) {
|
|
2865
|
+
this.deps = deps;
|
|
2866
|
+
this.q = [];
|
|
2867
|
+
this.flushing = false;
|
|
2868
|
+
this.failures = 0;
|
|
2869
|
+
this.timer = null;
|
|
2870
|
+
this.now = deps.now ?? Date.now;
|
|
2871
|
+
this.setTimer = deps.setTimer ?? ((fn, ms) => setTimeout(fn, ms));
|
|
2872
|
+
this.clearTimer = deps.clearTimer ?? ((t) => clearTimeout(t));
|
|
2873
|
+
}
|
|
2874
|
+
/** Load any events left unsent by a previous launch. Call once, after storage
|
|
2875
|
+
* hydrate. Then `flush()` to attempt delivery. */
|
|
2876
|
+
hydrate() {
|
|
2877
|
+
try {
|
|
2878
|
+
const raw = this.deps.load();
|
|
2879
|
+
if (!raw) return;
|
|
2880
|
+
const parsed = JSON.parse(raw);
|
|
2881
|
+
if (Array.isArray(parsed)) {
|
|
2882
|
+
this.q = parsed.filter((e) => !!e && typeof e === "object" && "body" in e).slice(-MAX_QUEUE);
|
|
2883
|
+
}
|
|
2884
|
+
} catch {
|
|
2885
|
+
this.q = [];
|
|
2886
|
+
}
|
|
2887
|
+
}
|
|
2888
|
+
/** Enqueue an event and trigger a (caught) flush. NEVER throws. */
|
|
2889
|
+
enqueue(body) {
|
|
2890
|
+
this.q.push({ body, attempts: 0 });
|
|
2891
|
+
if (this.q.length > MAX_QUEUE) this.q.splice(0, this.q.length - MAX_QUEUE);
|
|
2892
|
+
this.persist();
|
|
2893
|
+
this.scheduleFlush(0);
|
|
2894
|
+
}
|
|
2895
|
+
/** Flush immediately, resetting backoff — for app foreground. NEVER throws. */
|
|
2896
|
+
flushNow() {
|
|
2897
|
+
this.failures = 0;
|
|
2898
|
+
if (this.timer) {
|
|
2899
|
+
this.clearTimer(this.timer);
|
|
2900
|
+
this.timer = null;
|
|
2901
|
+
}
|
|
2902
|
+
void this.flush();
|
|
2903
|
+
}
|
|
2904
|
+
/** Drop everything (e.g. on a hard reset). */
|
|
2905
|
+
clear() {
|
|
2906
|
+
this.q = [];
|
|
2907
|
+
if (this.timer) {
|
|
2908
|
+
this.clearTimer(this.timer);
|
|
2909
|
+
this.timer = null;
|
|
2910
|
+
}
|
|
2911
|
+
this.persist();
|
|
2912
|
+
}
|
|
2913
|
+
persist() {
|
|
2914
|
+
try {
|
|
2915
|
+
this.deps.save(JSON.stringify(this.q));
|
|
2916
|
+
} catch {
|
|
2917
|
+
}
|
|
2918
|
+
}
|
|
2919
|
+
scheduleFlush(ms) {
|
|
2920
|
+
if (this.timer || this.flushing) return;
|
|
2921
|
+
this.timer = this.setTimer(() => {
|
|
2922
|
+
this.timer = null;
|
|
2923
|
+
void this.flush();
|
|
2924
|
+
}, ms);
|
|
2925
|
+
}
|
|
2926
|
+
/**
|
|
2927
|
+
* Drain the queue FIFO. Removes an event only when the server ACCEPTED it (2xx)
|
|
2928
|
+
* or PERMANENTLY refused it (a 4xx that a retry can't fix). Keeps it — and stops
|
|
2929
|
+
* draining — on a transient failure (timeout throw, network null, 408/429/5xx),
|
|
2930
|
+
* then arms a capped-exponential-backoff retry. The entire post lives inside the
|
|
2931
|
+
* try/catch, so `NetworkTimeoutError` can never surface as an unhandled rejection.
|
|
2932
|
+
*/
|
|
2933
|
+
async flush() {
|
|
2934
|
+
if (this.flushing || this.q.length === 0) return;
|
|
2935
|
+
this.flushing = true;
|
|
2936
|
+
try {
|
|
2937
|
+
while (this.q.length > 0) {
|
|
2938
|
+
const ev = this.q[0];
|
|
2939
|
+
let outcome;
|
|
2940
|
+
try {
|
|
2941
|
+
const res = await this.deps.post(ev.body);
|
|
2942
|
+
outcome = classify(res);
|
|
2943
|
+
} catch {
|
|
2944
|
+
outcome = "retry";
|
|
2945
|
+
}
|
|
2946
|
+
if (outcome === "ok" || outcome === "drop") {
|
|
2947
|
+
this.q.shift();
|
|
2948
|
+
this.failures = 0;
|
|
2949
|
+
this.persist();
|
|
2950
|
+
continue;
|
|
2951
|
+
}
|
|
2952
|
+
ev.attempts += 1;
|
|
2953
|
+
if (ev.attempts >= MAX_ATTEMPTS) {
|
|
2954
|
+
if (this.deps.debug) {
|
|
2955
|
+
console.warn(`[sendoracloud] event dropped after ${MAX_ATTEMPTS} attempts`);
|
|
2956
|
+
}
|
|
2957
|
+
this.q.shift();
|
|
2958
|
+
this.persist();
|
|
2959
|
+
continue;
|
|
2960
|
+
}
|
|
2961
|
+
this.persist();
|
|
2962
|
+
this.failures += 1;
|
|
2963
|
+
break;
|
|
2964
|
+
}
|
|
2965
|
+
} finally {
|
|
2966
|
+
this.flushing = false;
|
|
2967
|
+
}
|
|
2968
|
+
if (this.q.length > 0) {
|
|
2969
|
+
const backoff = Math.min(MAX_BACKOFF_MS, BASE_BACKOFF_MS * 2 ** Math.max(0, this.failures - 1));
|
|
2970
|
+
this.scheduleFlush(backoff);
|
|
2971
|
+
}
|
|
2972
|
+
}
|
|
2973
|
+
/** Test/introspection only. */
|
|
2974
|
+
get size() {
|
|
2975
|
+
return this.q.length;
|
|
2976
|
+
}
|
|
2977
|
+
};
|
|
2978
|
+
function classify(res) {
|
|
2979
|
+
if (res === null) return "retry";
|
|
2980
|
+
if (res.ok) return "ok";
|
|
2981
|
+
const s = res.status;
|
|
2982
|
+
if (s === 408 || s === 429 || s >= 500) return "retry";
|
|
2983
|
+
return "drop";
|
|
2984
|
+
}
|
|
2985
|
+
|
|
2860
2986
|
// src/index.ts
|
|
2861
2987
|
var DEFAULT_API_URL = "https://api.sendoracloud.com";
|
|
2862
2988
|
var ANON_KEY = "anon_id";
|
|
2863
2989
|
var USER_ID_KEY = "user_id";
|
|
2990
|
+
var EVENT_QUEUE_KEY = "event_queue";
|
|
2864
2991
|
var SCHEMA_VERSION_KEY = "schema_version";
|
|
2865
2992
|
var SCHEMA_VERSION = "1";
|
|
2866
2993
|
var AUTH_HYDRATE_KEYS = [
|
|
@@ -3007,6 +3134,7 @@ var SendoraSDK = class {
|
|
|
3007
3134
|
this.debug = false;
|
|
3008
3135
|
this.sessionId = "";
|
|
3009
3136
|
this.autoTrackCleanup = null;
|
|
3137
|
+
this.eventQueue = null;
|
|
3010
3138
|
// --- Engagement time (foreground-only, Wave 75) ---
|
|
3011
3139
|
this.engEnabled = false;
|
|
3012
3140
|
this.engScreen = null;
|
|
@@ -3066,6 +3194,7 @@ var SendoraSDK = class {
|
|
|
3066
3194
|
ANON_KEY,
|
|
3067
3195
|
USER_ID_KEY,
|
|
3068
3196
|
SCHEMA_VERSION_KEY,
|
|
3197
|
+
EVENT_QUEUE_KEY,
|
|
3069
3198
|
...AUTH_HYDRATE_KEYS
|
|
3070
3199
|
]);
|
|
3071
3200
|
if (!this.storage.get(SCHEMA_VERSION_KEY)) {
|
|
@@ -3101,9 +3230,11 @@ var SendoraSDK = class {
|
|
|
3101
3230
|
if (userId) this.storage.set(USER_ID_KEY, userId);
|
|
3102
3231
|
else this.storage.remove(USER_ID_KEY);
|
|
3103
3232
|
},
|
|
3104
|
-
onAnonymousWipe: async () => {
|
|
3105
|
-
|
|
3106
|
-
|
|
3233
|
+
onAnonymousWipe: async (reason) => {
|
|
3234
|
+
if (reason === "user" || reason === "account_deleted") {
|
|
3235
|
+
this.anonId = uuid();
|
|
3236
|
+
this.storage.set(ANON_KEY, this.anonId);
|
|
3237
|
+
}
|
|
3107
3238
|
this.userId = null;
|
|
3108
3239
|
this.storage.remove(USER_ID_KEY);
|
|
3109
3240
|
}
|
|
@@ -3117,6 +3248,18 @@ var SendoraSDK = class {
|
|
|
3117
3248
|
androidPackageName: this.config.androidPackageName,
|
|
3118
3249
|
linkDomain: this.config.linkDomain
|
|
3119
3250
|
});
|
|
3251
|
+
this.eventQueue = new EventQueue({
|
|
3252
|
+
post: (body) => post(
|
|
3253
|
+
{ apiUrl: this.config.apiUrl, publicKey: this.config.publicKey, debug: this.debug },
|
|
3254
|
+
"/api/v1/events",
|
|
3255
|
+
body
|
|
3256
|
+
).then((res) => res === null ? null : { ok: res.ok, status: res.status }),
|
|
3257
|
+
load: () => this.storage.get(EVENT_QUEUE_KEY) ?? null,
|
|
3258
|
+
save: (json) => this.storage.set(EVENT_QUEUE_KEY, json),
|
|
3259
|
+
debug: this.debug
|
|
3260
|
+
});
|
|
3261
|
+
this.eventQueue.hydrate();
|
|
3262
|
+
this.eventQueue.flushNow();
|
|
3120
3263
|
this.ready = true;
|
|
3121
3264
|
if (config.autoTrack !== false) {
|
|
3122
3265
|
this.engEnabled = config.autoTrack === true || config.autoTrack === void 0 || config.autoTrack.engagement !== false;
|
|
@@ -3128,8 +3271,10 @@ var SendoraSDK = class {
|
|
|
3128
3271
|
this.sessionId = id;
|
|
3129
3272
|
},
|
|
3130
3273
|
onForeground: (active) => {
|
|
3131
|
-
if (active)
|
|
3132
|
-
|
|
3274
|
+
if (active) {
|
|
3275
|
+
this.engResume();
|
|
3276
|
+
this.eventQueue?.flushNow();
|
|
3277
|
+
} else this.engFlush();
|
|
3133
3278
|
}
|
|
3134
3279
|
});
|
|
3135
3280
|
}
|
|
@@ -3209,7 +3354,7 @@ var SendoraSDK = class {
|
|
|
3209
3354
|
}
|
|
3210
3355
|
this.userId = userId;
|
|
3211
3356
|
this.storage.set(USER_ID_KEY, userId);
|
|
3212
|
-
|
|
3357
|
+
this.sendEvent("identify", {
|
|
3213
3358
|
userId,
|
|
3214
3359
|
traits: traits ?? {}
|
|
3215
3360
|
});
|
|
@@ -3226,7 +3371,7 @@ var SendoraSDK = class {
|
|
|
3226
3371
|
throw new Error(`[sendoracloud] properties exceed ${MAX_PROPERTIES_BYTES} bytes.`);
|
|
3227
3372
|
}
|
|
3228
3373
|
}
|
|
3229
|
-
|
|
3374
|
+
this.sendEvent("track", {
|
|
3230
3375
|
eventType,
|
|
3231
3376
|
properties: properties ?? {}
|
|
3232
3377
|
});
|
|
@@ -3238,7 +3383,7 @@ var SendoraSDK = class {
|
|
|
3238
3383
|
throw new Error("[sendoracloud] screen name must be a 1-256 char string.");
|
|
3239
3384
|
}
|
|
3240
3385
|
this.engFlush();
|
|
3241
|
-
|
|
3386
|
+
this.sendEvent("screen", {
|
|
3242
3387
|
eventType: "screen.viewed",
|
|
3243
3388
|
properties: { screenName: name, ...properties ?? {} }
|
|
3244
3389
|
});
|
|
@@ -3266,7 +3411,7 @@ var SendoraSDK = class {
|
|
|
3266
3411
|
if (ms < MIN_ENGAGEMENT_MS) return;
|
|
3267
3412
|
if (ms > MAX_ENGAGEMENT_MS) ms = MAX_ENGAGEMENT_MS;
|
|
3268
3413
|
try {
|
|
3269
|
-
|
|
3414
|
+
this.sendEvent("track", {
|
|
3270
3415
|
eventType: "app.engagement",
|
|
3271
3416
|
properties: { durationMs: Math.round(ms), screen: this.engScreen, sessionId: this.sessionId }
|
|
3272
3417
|
});
|
|
@@ -3850,6 +3995,7 @@ var SendoraSDK = class {
|
|
|
3850
3995
|
await this.auth.signOut().catch(() => void 0);
|
|
3851
3996
|
this.userId = null;
|
|
3852
3997
|
this.sessionId = "";
|
|
3998
|
+
this.eventQueue?.clear();
|
|
3853
3999
|
await this.storage.clearAll();
|
|
3854
4000
|
this.storage.set(ANON_KEY, this.anonId);
|
|
3855
4001
|
}
|
|
@@ -3861,34 +4007,42 @@ var SendoraSDK = class {
|
|
|
3861
4007
|
this.autoTrackCleanup = null;
|
|
3862
4008
|
}
|
|
3863
4009
|
}
|
|
3864
|
-
|
|
4010
|
+
/**
|
|
4011
|
+
* Build an event body and hand it to the persisted queue for delivery.
|
|
4012
|
+
*
|
|
4013
|
+
* ⚠ SYNCHRONOUS + NEVER THROWS (1.39.0). Before this, it was `async` and did
|
|
4014
|
+
* `await post(...)` with no try/catch; `http.ts` THROWS `NetworkTimeoutError` on
|
|
4015
|
+
* its 30 s abort, and every caller discarded the promise with `void` — so a slow
|
|
4016
|
+
* `/api/v1/events` request became an unhandled rejection in the host's crash
|
|
4017
|
+
* reporter AND the event was lost. Now the queue owns delivery: it retries with
|
|
4018
|
+
* backoff, persists across launches, and swallows every transport failure. A
|
|
4019
|
+
* rejection can no longer escape (so `engFlush`'s "never throws" try/catch is
|
|
4020
|
+
* finally true), and a slow network delays events instead of dropping them.
|
|
4021
|
+
*/
|
|
4022
|
+
sendEvent(kind, payload) {
|
|
3865
4023
|
if (!this.config) return;
|
|
3866
4024
|
if (!this.consentGranted) return;
|
|
3867
4025
|
const device = buildDeviceContext(this.config.appVersion, this.config.appBuild);
|
|
3868
|
-
|
|
3869
|
-
|
|
3870
|
-
"
|
|
3871
|
-
|
|
3872
|
-
|
|
3873
|
-
|
|
3874
|
-
|
|
3875
|
-
|
|
3876
|
-
|
|
3877
|
-
|
|
3878
|
-
|
|
3879
|
-
|
|
3880
|
-
|
|
3881
|
-
|
|
3882
|
-
|
|
3883
|
-
|
|
3884
|
-
|
|
3885
|
-
|
|
3886
|
-
...device ? { device } : {},
|
|
3887
|
-
...this.config.environment ? { environment: this.config.environment } : {},
|
|
3888
|
-
...kind === "identify" ? { traits: payload.traits } : {}
|
|
3889
|
-
}
|
|
4026
|
+
this.eventQueue?.enqueue({
|
|
4027
|
+
...this.config.projectId ? { projectId: this.config.projectId } : {},
|
|
4028
|
+
module: kind === "identify" ? "profiles" : "analytics",
|
|
4029
|
+
eventType: payload.eventType ?? (kind === "identify" ? "identify" : "track"),
|
|
4030
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
4031
|
+
...this.userId ? { userId: this.userId } : {},
|
|
4032
|
+
anonymousId: this.anonId,
|
|
4033
|
+
// Top-level sessionId so backend `events.session_id` populates (powers
|
|
4034
|
+
// the Audience "Sessions & engagement" metrics). Set by auto-track;
|
|
4035
|
+
// blank until the first auto event of the launch → omitted when empty.
|
|
4036
|
+
...this.sessionId ? { sessionId: this.sessionId } : {},
|
|
4037
|
+
properties: payload.properties ?? {},
|
|
4038
|
+
context: {
|
|
4039
|
+
platform: "react-native",
|
|
4040
|
+
sdk: { name: "@sendoracloud/sdk-react-native", version: SDK_VERSION },
|
|
4041
|
+
...device ? { device } : {},
|
|
4042
|
+
...this.config.environment ? { environment: this.config.environment } : {},
|
|
4043
|
+
...kind === "identify" ? { traits: payload.traits } : {}
|
|
3890
4044
|
}
|
|
3891
|
-
);
|
|
4045
|
+
});
|
|
3892
4046
|
}
|
|
3893
4047
|
assertReady() {
|
|
3894
4048
|
if (!this.ready || !this.config) {
|
package/dist/index.d.cts
CHANGED
|
@@ -497,12 +497,15 @@ interface AuthHooks {
|
|
|
497
497
|
* identity (analytics userId, push token registration, etc). */
|
|
498
498
|
onIdentityChange: (userId: string | null) => void;
|
|
499
499
|
/**
|
|
500
|
-
* Called
|
|
501
|
-
*
|
|
502
|
-
*
|
|
503
|
-
*
|
|
504
|
-
|
|
505
|
-
|
|
500
|
+
* Called from `wipeLocalIdentity` whenever the local session is cleared. The
|
|
501
|
+
* `reason` lets the parent decide whether to rotate the anonymous analytics id:
|
|
502
|
+
* it rotates ONLY at a person-boundary ("user" = explicit signOut,
|
|
503
|
+
* "account_deleted"), never on a successful sign-in/upgrade ("replaced") or an
|
|
504
|
+
* involuntary session death ("session_expired") — those are the same person, and
|
|
505
|
+
* the anon id must survive so pre-auth analytics link to them (Firebase-FID
|
|
506
|
+
* semantics; the industry norm).
|
|
507
|
+
*/
|
|
508
|
+
onAnonymousWipe: (reason: WipeReason) => Promise<void>;
|
|
506
509
|
apiUrl: string;
|
|
507
510
|
publicKey: string;
|
|
508
511
|
debug: boolean;
|
|
@@ -565,6 +568,12 @@ type AuthStateChange = {
|
|
|
565
568
|
user: AuthUser | null;
|
|
566
569
|
};
|
|
567
570
|
type AuthStateListener = (change: AuthStateChange) => void;
|
|
571
|
+
/**
|
|
572
|
+
* Internal marker for a wipe that is immediately followed by a `persist()` of
|
|
573
|
+
* a NEW session (the sign-in success path). Not a sign-out — emitting one
|
|
574
|
+
* would make every successful sign-in look like a logout to subscribers.
|
|
575
|
+
*/
|
|
576
|
+
type WipeReason = AuthSignedOutReason | "replaced";
|
|
568
577
|
declare class Auth {
|
|
569
578
|
private hooks;
|
|
570
579
|
private user;
|
|
@@ -1400,11 +1409,33 @@ interface LinkOpenedEvent<T extends LinkData = LinkData> {
|
|
|
1400
1409
|
}
|
|
1401
1410
|
type LinkOpenedHandler<T extends LinkData = LinkData> = (event: LinkOpenedEvent<T>) => void;
|
|
1402
1411
|
interface LinkMatchInput {
|
|
1403
|
-
/**
|
|
1412
|
+
/**
|
|
1413
|
+
* Hex-encoded SHA-256 device fingerprint.
|
|
1414
|
+
*
|
|
1415
|
+
* ⚠ **Do not supply this on iOS.** Apple prohibits deriving a device identifier
|
|
1416
|
+
* from device signals for attribution (App Store 5.1.2 / DPLA §3.3.9), and it
|
|
1417
|
+
* does not care which side of the network computed it — passing a hash you built
|
|
1418
|
+
* on-device is the same practice. The SDK no longer computes one for you (it used
|
|
1419
|
+
* to, implicitly, on iOS). Left here for the rare Android/self-hosted caller that
|
|
1420
|
+
* has a legitimate deterministic hash; on iOS, omit it.
|
|
1421
|
+
*/
|
|
1404
1422
|
fingerprintHash?: string;
|
|
1405
1423
|
/** Raw Play Install Referrer. Sendora probes
|
|
1406
1424
|
* `react-native-play-install-referrer` automatically when this is omitted. */
|
|
1407
1425
|
installReferrer?: string;
|
|
1426
|
+
/**
|
|
1427
|
+
* Opt in to probabilistic matching on iOS.
|
|
1428
|
+
*
|
|
1429
|
+
* iOS has no deterministic deferred-install signal (no install referrer), and the
|
|
1430
|
+
* SDK will not fingerprint the device. The only remaining matcher is the server's
|
|
1431
|
+
* coarse IP + timestamp window — the same class of practice Apple scrutinises, and
|
|
1432
|
+
* it collides across a shared NAT / iCloud Private Relay. So it is **off by
|
|
1433
|
+
* default**: without this flag, `matchDeferred()` on iOS matches nothing and
|
|
1434
|
+
* returns `null` (route the user through your normal onboarding UX). Set it only
|
|
1435
|
+
* if you accept the accuracy and compliance trade-off. No effect on Android, where
|
|
1436
|
+
* the Play Install Referrer is deterministic.
|
|
1437
|
+
*/
|
|
1438
|
+
allowProbabilisticMatch?: boolean;
|
|
1408
1439
|
iosBundleId?: string;
|
|
1409
1440
|
androidPackageName?: string;
|
|
1410
1441
|
}
|
|
@@ -1437,12 +1468,14 @@ interface LinkStats {
|
|
|
1437
1468
|
*/
|
|
1438
1469
|
declare function extractShortcodeFromUrl(url: string, linkDomain?: string): string | null;
|
|
1439
1470
|
/**
|
|
1440
|
-
* Canonical device-fingerprint recipe
|
|
1441
|
-
*
|
|
1442
|
-
* install completing on another won't accidentally match.
|
|
1471
|
+
* Canonical device-fingerprint recipe (`${platform}|${screenW}x${screenH}|${timezone}|${locale}`
|
|
1472
|
+
* → lowercase hex SHA-256), identical across the RN / iOS / Android SDKs.
|
|
1443
1473
|
*
|
|
1444
|
-
*
|
|
1445
|
-
*
|
|
1474
|
+
* @deprecated ⚠ Do not use on iOS. Apple prohibits deriving a device identifier
|
|
1475
|
+
* from device signals for attribution (App Store 5.1.2 / DPLA §3.3.9), regardless
|
|
1476
|
+
* of ATT — and this is exactly that. `matchDeferred()` no longer calls it
|
|
1477
|
+
* implicitly. It is retained only for an Android/self-hosted caller with a
|
|
1478
|
+
* legitimate deterministic use, and is slated for removal in a future major.
|
|
1446
1479
|
*/
|
|
1447
1480
|
declare function computeDeviceFingerprint(): Promise<string>;
|
|
1448
1481
|
/** Probe optional `react-native-play-install-referrer` peer. Returns the
|
|
@@ -1511,11 +1544,20 @@ declare class Links {
|
|
|
1511
1544
|
*/
|
|
1512
1545
|
handleUniversalLink(url: string): Promise<boolean>;
|
|
1513
1546
|
/**
|
|
1514
|
-
* Cold-launch deferred deep
|
|
1515
|
-
*
|
|
1516
|
-
*
|
|
1517
|
-
*
|
|
1518
|
-
*
|
|
1547
|
+
* Cold-launch deferred deep-link match.
|
|
1548
|
+
*
|
|
1549
|
+
* ⚠ NO IMPLICIT FINGERPRINTING (compliance). This used to compute a device
|
|
1550
|
+
* fingerprint on iOS when you passed no signal; it no longer does — Apple
|
|
1551
|
+
* prohibits deriving a device identifier from device signals for attribution
|
|
1552
|
+
* (App Store 5.1.2 / DPLA §3.3.9), regardless of ATT, and doing it on-device is
|
|
1553
|
+
* exactly that. Signal resolution now:
|
|
1554
|
+
* - Android: probes `react-native-play-install-referrer` — deterministic, the
|
|
1555
|
+
* preferred and compliant path.
|
|
1556
|
+
* - iOS: no deterministic signal exists (the App Store has no install referrer).
|
|
1557
|
+
* With `allowProbabilisticMatch: true` the server matches on a coarse
|
|
1558
|
+
* IP + time window (OFF by default — see `LinkMatchInput`). Otherwise this
|
|
1559
|
+
* returns `null` and you route the user through your normal onboarding UX —
|
|
1560
|
+
* which is the expected iOS cold-launch outcome, not an error.
|
|
1519
1561
|
*
|
|
1520
1562
|
* Fires `onLinkOpened` with `isDeferred: true` on success.
|
|
1521
1563
|
*/
|
|
@@ -1582,6 +1624,7 @@ declare class SendoraSDK {
|
|
|
1582
1624
|
private debug;
|
|
1583
1625
|
private sessionId;
|
|
1584
1626
|
private autoTrackCleanup;
|
|
1627
|
+
private eventQueue;
|
|
1585
1628
|
private engEnabled;
|
|
1586
1629
|
private engScreen;
|
|
1587
1630
|
private engAccumMs;
|
|
@@ -2086,6 +2129,18 @@ declare class SendoraSDK {
|
|
|
2086
2129
|
reset(): Promise<void>;
|
|
2087
2130
|
/** Tear down auto-track listeners. Call in test harnesses. */
|
|
2088
2131
|
destroy(): void;
|
|
2132
|
+
/**
|
|
2133
|
+
* Build an event body and hand it to the persisted queue for delivery.
|
|
2134
|
+
*
|
|
2135
|
+
* ⚠ SYNCHRONOUS + NEVER THROWS (1.39.0). Before this, it was `async` and did
|
|
2136
|
+
* `await post(...)` with no try/catch; `http.ts` THROWS `NetworkTimeoutError` on
|
|
2137
|
+
* its 30 s abort, and every caller discarded the promise with `void` — so a slow
|
|
2138
|
+
* `/api/v1/events` request became an unhandled rejection in the host's crash
|
|
2139
|
+
* reporter AND the event was lost. Now the queue owns delivery: it retries with
|
|
2140
|
+
* backoff, persists across launches, and swallows every transport failure. A
|
|
2141
|
+
* rejection can no longer escape (so `engFlush`'s "never throws" try/catch is
|
|
2142
|
+
* finally true), and a slow network delays events instead of dropping them.
|
|
2143
|
+
*/
|
|
2089
2144
|
private sendEvent;
|
|
2090
2145
|
private assertReady;
|
|
2091
2146
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -497,12 +497,15 @@ interface AuthHooks {
|
|
|
497
497
|
* identity (analytics userId, push token registration, etc). */
|
|
498
498
|
onIdentityChange: (userId: string | null) => void;
|
|
499
499
|
/**
|
|
500
|
-
* Called
|
|
501
|
-
*
|
|
502
|
-
*
|
|
503
|
-
*
|
|
504
|
-
|
|
505
|
-
|
|
500
|
+
* Called from `wipeLocalIdentity` whenever the local session is cleared. The
|
|
501
|
+
* `reason` lets the parent decide whether to rotate the anonymous analytics id:
|
|
502
|
+
* it rotates ONLY at a person-boundary ("user" = explicit signOut,
|
|
503
|
+
* "account_deleted"), never on a successful sign-in/upgrade ("replaced") or an
|
|
504
|
+
* involuntary session death ("session_expired") — those are the same person, and
|
|
505
|
+
* the anon id must survive so pre-auth analytics link to them (Firebase-FID
|
|
506
|
+
* semantics; the industry norm).
|
|
507
|
+
*/
|
|
508
|
+
onAnonymousWipe: (reason: WipeReason) => Promise<void>;
|
|
506
509
|
apiUrl: string;
|
|
507
510
|
publicKey: string;
|
|
508
511
|
debug: boolean;
|
|
@@ -565,6 +568,12 @@ type AuthStateChange = {
|
|
|
565
568
|
user: AuthUser | null;
|
|
566
569
|
};
|
|
567
570
|
type AuthStateListener = (change: AuthStateChange) => void;
|
|
571
|
+
/**
|
|
572
|
+
* Internal marker for a wipe that is immediately followed by a `persist()` of
|
|
573
|
+
* a NEW session (the sign-in success path). Not a sign-out — emitting one
|
|
574
|
+
* would make every successful sign-in look like a logout to subscribers.
|
|
575
|
+
*/
|
|
576
|
+
type WipeReason = AuthSignedOutReason | "replaced";
|
|
568
577
|
declare class Auth {
|
|
569
578
|
private hooks;
|
|
570
579
|
private user;
|
|
@@ -1400,11 +1409,33 @@ interface LinkOpenedEvent<T extends LinkData = LinkData> {
|
|
|
1400
1409
|
}
|
|
1401
1410
|
type LinkOpenedHandler<T extends LinkData = LinkData> = (event: LinkOpenedEvent<T>) => void;
|
|
1402
1411
|
interface LinkMatchInput {
|
|
1403
|
-
/**
|
|
1412
|
+
/**
|
|
1413
|
+
* Hex-encoded SHA-256 device fingerprint.
|
|
1414
|
+
*
|
|
1415
|
+
* ⚠ **Do not supply this on iOS.** Apple prohibits deriving a device identifier
|
|
1416
|
+
* from device signals for attribution (App Store 5.1.2 / DPLA §3.3.9), and it
|
|
1417
|
+
* does not care which side of the network computed it — passing a hash you built
|
|
1418
|
+
* on-device is the same practice. The SDK no longer computes one for you (it used
|
|
1419
|
+
* to, implicitly, on iOS). Left here for the rare Android/self-hosted caller that
|
|
1420
|
+
* has a legitimate deterministic hash; on iOS, omit it.
|
|
1421
|
+
*/
|
|
1404
1422
|
fingerprintHash?: string;
|
|
1405
1423
|
/** Raw Play Install Referrer. Sendora probes
|
|
1406
1424
|
* `react-native-play-install-referrer` automatically when this is omitted. */
|
|
1407
1425
|
installReferrer?: string;
|
|
1426
|
+
/**
|
|
1427
|
+
* Opt in to probabilistic matching on iOS.
|
|
1428
|
+
*
|
|
1429
|
+
* iOS has no deterministic deferred-install signal (no install referrer), and the
|
|
1430
|
+
* SDK will not fingerprint the device. The only remaining matcher is the server's
|
|
1431
|
+
* coarse IP + timestamp window — the same class of practice Apple scrutinises, and
|
|
1432
|
+
* it collides across a shared NAT / iCloud Private Relay. So it is **off by
|
|
1433
|
+
* default**: without this flag, `matchDeferred()` on iOS matches nothing and
|
|
1434
|
+
* returns `null` (route the user through your normal onboarding UX). Set it only
|
|
1435
|
+
* if you accept the accuracy and compliance trade-off. No effect on Android, where
|
|
1436
|
+
* the Play Install Referrer is deterministic.
|
|
1437
|
+
*/
|
|
1438
|
+
allowProbabilisticMatch?: boolean;
|
|
1408
1439
|
iosBundleId?: string;
|
|
1409
1440
|
androidPackageName?: string;
|
|
1410
1441
|
}
|
|
@@ -1437,12 +1468,14 @@ interface LinkStats {
|
|
|
1437
1468
|
*/
|
|
1438
1469
|
declare function extractShortcodeFromUrl(url: string, linkDomain?: string): string | null;
|
|
1439
1470
|
/**
|
|
1440
|
-
* Canonical device-fingerprint recipe
|
|
1441
|
-
*
|
|
1442
|
-
* install completing on another won't accidentally match.
|
|
1471
|
+
* Canonical device-fingerprint recipe (`${platform}|${screenW}x${screenH}|${timezone}|${locale}`
|
|
1472
|
+
* → lowercase hex SHA-256), identical across the RN / iOS / Android SDKs.
|
|
1443
1473
|
*
|
|
1444
|
-
*
|
|
1445
|
-
*
|
|
1474
|
+
* @deprecated ⚠ Do not use on iOS. Apple prohibits deriving a device identifier
|
|
1475
|
+
* from device signals for attribution (App Store 5.1.2 / DPLA §3.3.9), regardless
|
|
1476
|
+
* of ATT — and this is exactly that. `matchDeferred()` no longer calls it
|
|
1477
|
+
* implicitly. It is retained only for an Android/self-hosted caller with a
|
|
1478
|
+
* legitimate deterministic use, and is slated for removal in a future major.
|
|
1446
1479
|
*/
|
|
1447
1480
|
declare function computeDeviceFingerprint(): Promise<string>;
|
|
1448
1481
|
/** Probe optional `react-native-play-install-referrer` peer. Returns the
|
|
@@ -1511,11 +1544,20 @@ declare class Links {
|
|
|
1511
1544
|
*/
|
|
1512
1545
|
handleUniversalLink(url: string): Promise<boolean>;
|
|
1513
1546
|
/**
|
|
1514
|
-
* Cold-launch deferred deep
|
|
1515
|
-
*
|
|
1516
|
-
*
|
|
1517
|
-
*
|
|
1518
|
-
*
|
|
1547
|
+
* Cold-launch deferred deep-link match.
|
|
1548
|
+
*
|
|
1549
|
+
* ⚠ NO IMPLICIT FINGERPRINTING (compliance). This used to compute a device
|
|
1550
|
+
* fingerprint on iOS when you passed no signal; it no longer does — Apple
|
|
1551
|
+
* prohibits deriving a device identifier from device signals for attribution
|
|
1552
|
+
* (App Store 5.1.2 / DPLA §3.3.9), regardless of ATT, and doing it on-device is
|
|
1553
|
+
* exactly that. Signal resolution now:
|
|
1554
|
+
* - Android: probes `react-native-play-install-referrer` — deterministic, the
|
|
1555
|
+
* preferred and compliant path.
|
|
1556
|
+
* - iOS: no deterministic signal exists (the App Store has no install referrer).
|
|
1557
|
+
* With `allowProbabilisticMatch: true` the server matches on a coarse
|
|
1558
|
+
* IP + time window (OFF by default — see `LinkMatchInput`). Otherwise this
|
|
1559
|
+
* returns `null` and you route the user through your normal onboarding UX —
|
|
1560
|
+
* which is the expected iOS cold-launch outcome, not an error.
|
|
1519
1561
|
*
|
|
1520
1562
|
* Fires `onLinkOpened` with `isDeferred: true` on success.
|
|
1521
1563
|
*/
|
|
@@ -1582,6 +1624,7 @@ declare class SendoraSDK {
|
|
|
1582
1624
|
private debug;
|
|
1583
1625
|
private sessionId;
|
|
1584
1626
|
private autoTrackCleanup;
|
|
1627
|
+
private eventQueue;
|
|
1585
1628
|
private engEnabled;
|
|
1586
1629
|
private engScreen;
|
|
1587
1630
|
private engAccumMs;
|
|
@@ -2086,6 +2129,18 @@ declare class SendoraSDK {
|
|
|
2086
2129
|
reset(): Promise<void>;
|
|
2087
2130
|
/** Tear down auto-track listeners. Call in test harnesses. */
|
|
2088
2131
|
destroy(): void;
|
|
2132
|
+
/**
|
|
2133
|
+
* Build an event body and hand it to the persisted queue for delivery.
|
|
2134
|
+
*
|
|
2135
|
+
* ⚠ SYNCHRONOUS + NEVER THROWS (1.39.0). Before this, it was `async` and did
|
|
2136
|
+
* `await post(...)` with no try/catch; `http.ts` THROWS `NetworkTimeoutError` on
|
|
2137
|
+
* its 30 s abort, and every caller discarded the promise with `void` — so a slow
|
|
2138
|
+
* `/api/v1/events` request became an unhandled rejection in the host's crash
|
|
2139
|
+
* reporter AND the event was lost. Now the queue owns delivery: it retries with
|
|
2140
|
+
* backoff, persists across launches, and swallows every transport failure. A
|
|
2141
|
+
* rejection can no longer escape (so `engFlush`'s "never throws" try/catch is
|
|
2142
|
+
* finally true), and a slow network delays events instead of dropping them.
|
|
2143
|
+
*/
|
|
2089
2144
|
private sendEvent;
|
|
2090
2145
|
private assertReady;
|
|
2091
2146
|
}
|
package/dist/index.js
CHANGED
|
@@ -183,7 +183,7 @@ var Storage = class {
|
|
|
183
183
|
// package.json
|
|
184
184
|
var package_default = {
|
|
185
185
|
name: "@sendoracloud/sdk-react-native",
|
|
186
|
-
version: "1.
|
|
186
|
+
version: "1.39.0",
|
|
187
187
|
description: "Sendora Cloud React Native + Expo SDK \u2014 analytics, identity, push-token registration, auth (incl. Game Center / Play Games), deep links (Branch / Firebase Dynamic Links parity). Auth + analytics + deep links work in Expo Go; push-token registration and the no-arg Game Center / Play Games sign-in require a Dev Client (EAS Build or `npx expo prebuild`).",
|
|
188
188
|
type: "module",
|
|
189
189
|
main: "./dist/index.cjs",
|
|
@@ -239,7 +239,7 @@ var package_default = {
|
|
|
239
239
|
scripts: {
|
|
240
240
|
build: "tsup src/index.ts src/contact-widget.tsx --format esm,cjs --dts --clean --external react --external react-native --external react-native-webview",
|
|
241
241
|
typecheck: "tsc --noEmit",
|
|
242
|
-
test: "tsx test/secure-storage.test.ts && tsx test/integration-storage.test.ts && tsx test/plugin.test.ts && tsx test/game-auth.test.ts && tsx test/link.test.ts && tsx test/session-preservation.test.ts && tsx test/credential-collision.test.ts && tsx test/token-expiry.test.ts && tsx test/unlink.test.ts"
|
|
242
|
+
test: "tsx test/secure-storage.test.ts && tsx test/integration-storage.test.ts && tsx test/plugin.test.ts && tsx test/game-auth.test.ts && tsx test/link.test.ts && tsx test/session-preservation.test.ts && tsx test/credential-collision.test.ts && tsx test/token-expiry.test.ts && tsx test/unlink.test.ts && tsx test/deferred-match.test.ts && tsx test/anon-id-stability.test.ts && tsx test/event-queue.test.ts"
|
|
243
243
|
},
|
|
244
244
|
devDependencies: {
|
|
245
245
|
"@types/react": "^19.2.16",
|
|
@@ -2111,7 +2111,7 @@ var Auth = class {
|
|
|
2111
2111
|
this.hooks.storage.remove(TOKEN_EXPIRES_KEY);
|
|
2112
2112
|
this.hooks.storage.remove(REFRESH_KEY);
|
|
2113
2113
|
this.stopProactiveRefreshCron();
|
|
2114
|
-
await this.hooks.onAnonymousWipe();
|
|
2114
|
+
await this.hooks.onAnonymousWipe(reason);
|
|
2115
2115
|
if (reason !== "replaced") {
|
|
2116
2116
|
this.emitAuthState({ event: "signed_out", user: null, reason });
|
|
2117
2117
|
}
|
|
@@ -2587,33 +2587,31 @@ var Links = class {
|
|
|
2587
2587
|
}
|
|
2588
2588
|
}
|
|
2589
2589
|
/**
|
|
2590
|
-
* Cold-launch deferred deep
|
|
2591
|
-
*
|
|
2592
|
-
*
|
|
2593
|
-
*
|
|
2594
|
-
*
|
|
2590
|
+
* Cold-launch deferred deep-link match.
|
|
2591
|
+
*
|
|
2592
|
+
* ⚠ NO IMPLICIT FINGERPRINTING (compliance). This used to compute a device
|
|
2593
|
+
* fingerprint on iOS when you passed no signal; it no longer does — Apple
|
|
2594
|
+
* prohibits deriving a device identifier from device signals for attribution
|
|
2595
|
+
* (App Store 5.1.2 / DPLA §3.3.9), regardless of ATT, and doing it on-device is
|
|
2596
|
+
* exactly that. Signal resolution now:
|
|
2597
|
+
* - Android: probes `react-native-play-install-referrer` — deterministic, the
|
|
2598
|
+
* preferred and compliant path.
|
|
2599
|
+
* - iOS: no deterministic signal exists (the App Store has no install referrer).
|
|
2600
|
+
* With `allowProbabilisticMatch: true` the server matches on a coarse
|
|
2601
|
+
* IP + time window (OFF by default — see `LinkMatchInput`). Otherwise this
|
|
2602
|
+
* returns `null` and you route the user through your normal onboarding UX —
|
|
2603
|
+
* which is the expected iOS cold-launch outcome, not an error.
|
|
2595
2604
|
*
|
|
2596
2605
|
* Fires `onLinkOpened` with `isDeferred: true` on success.
|
|
2597
2606
|
*/
|
|
2598
2607
|
async matchDeferred(input = {}) {
|
|
2599
|
-
|
|
2608
|
+
const fingerprintHash = input.fingerprintHash;
|
|
2600
2609
|
let installReferrer = input.installReferrer;
|
|
2601
2610
|
if (!fingerprintHash && !installReferrer) {
|
|
2602
2611
|
installReferrer = await getPlayInstallReferrer() ?? void 0;
|
|
2603
|
-
if (!installReferrer) {
|
|
2604
|
-
try {
|
|
2605
|
-
fingerprintHash = await computeDeviceFingerprint();
|
|
2606
|
-
} catch (err) {
|
|
2607
|
-
if (this.deps.debug) console.warn("[sendoracloud] fingerprint compute failed:", err);
|
|
2608
|
-
}
|
|
2609
|
-
}
|
|
2610
|
-
}
|
|
2611
|
-
if (!fingerprintHash && !installReferrer) {
|
|
2612
|
-
throw new LinkError(
|
|
2613
|
-
"INVALID_INPUT",
|
|
2614
|
-
"links.matchDeferred: install `react-native-play-install-referrer` for Android, or ensure web crypto / expo-crypto is available for iOS fingerprint compute."
|
|
2615
|
-
);
|
|
2616
2612
|
}
|
|
2613
|
+
const canMatch = !!fingerprintHash || !!installReferrer || input.allowProbabilisticMatch === true;
|
|
2614
|
+
if (!canMatch) return null;
|
|
2617
2615
|
const body = {};
|
|
2618
2616
|
if (fingerprintHash) body.fingerprintHash = fingerprintHash;
|
|
2619
2617
|
if (installReferrer) body.installReferrer = installReferrer;
|
|
@@ -2817,10 +2815,139 @@ function installAutoTrack(args) {
|
|
|
2817
2815
|
};
|
|
2818
2816
|
}
|
|
2819
2817
|
|
|
2818
|
+
// src/event-queue.ts
|
|
2819
|
+
var MAX_QUEUE = 500;
|
|
2820
|
+
var MAX_ATTEMPTS = 50;
|
|
2821
|
+
var BASE_BACKOFF_MS = 1e3;
|
|
2822
|
+
var MAX_BACKOFF_MS = 6e4;
|
|
2823
|
+
var EventQueue = class {
|
|
2824
|
+
constructor(deps) {
|
|
2825
|
+
this.deps = deps;
|
|
2826
|
+
this.q = [];
|
|
2827
|
+
this.flushing = false;
|
|
2828
|
+
this.failures = 0;
|
|
2829
|
+
this.timer = null;
|
|
2830
|
+
this.now = deps.now ?? Date.now;
|
|
2831
|
+
this.setTimer = deps.setTimer ?? ((fn, ms) => setTimeout(fn, ms));
|
|
2832
|
+
this.clearTimer = deps.clearTimer ?? ((t) => clearTimeout(t));
|
|
2833
|
+
}
|
|
2834
|
+
/** Load any events left unsent by a previous launch. Call once, after storage
|
|
2835
|
+
* hydrate. Then `flush()` to attempt delivery. */
|
|
2836
|
+
hydrate() {
|
|
2837
|
+
try {
|
|
2838
|
+
const raw = this.deps.load();
|
|
2839
|
+
if (!raw) return;
|
|
2840
|
+
const parsed = JSON.parse(raw);
|
|
2841
|
+
if (Array.isArray(parsed)) {
|
|
2842
|
+
this.q = parsed.filter((e) => !!e && typeof e === "object" && "body" in e).slice(-MAX_QUEUE);
|
|
2843
|
+
}
|
|
2844
|
+
} catch {
|
|
2845
|
+
this.q = [];
|
|
2846
|
+
}
|
|
2847
|
+
}
|
|
2848
|
+
/** Enqueue an event and trigger a (caught) flush. NEVER throws. */
|
|
2849
|
+
enqueue(body) {
|
|
2850
|
+
this.q.push({ body, attempts: 0 });
|
|
2851
|
+
if (this.q.length > MAX_QUEUE) this.q.splice(0, this.q.length - MAX_QUEUE);
|
|
2852
|
+
this.persist();
|
|
2853
|
+
this.scheduleFlush(0);
|
|
2854
|
+
}
|
|
2855
|
+
/** Flush immediately, resetting backoff — for app foreground. NEVER throws. */
|
|
2856
|
+
flushNow() {
|
|
2857
|
+
this.failures = 0;
|
|
2858
|
+
if (this.timer) {
|
|
2859
|
+
this.clearTimer(this.timer);
|
|
2860
|
+
this.timer = null;
|
|
2861
|
+
}
|
|
2862
|
+
void this.flush();
|
|
2863
|
+
}
|
|
2864
|
+
/** Drop everything (e.g. on a hard reset). */
|
|
2865
|
+
clear() {
|
|
2866
|
+
this.q = [];
|
|
2867
|
+
if (this.timer) {
|
|
2868
|
+
this.clearTimer(this.timer);
|
|
2869
|
+
this.timer = null;
|
|
2870
|
+
}
|
|
2871
|
+
this.persist();
|
|
2872
|
+
}
|
|
2873
|
+
persist() {
|
|
2874
|
+
try {
|
|
2875
|
+
this.deps.save(JSON.stringify(this.q));
|
|
2876
|
+
} catch {
|
|
2877
|
+
}
|
|
2878
|
+
}
|
|
2879
|
+
scheduleFlush(ms) {
|
|
2880
|
+
if (this.timer || this.flushing) return;
|
|
2881
|
+
this.timer = this.setTimer(() => {
|
|
2882
|
+
this.timer = null;
|
|
2883
|
+
void this.flush();
|
|
2884
|
+
}, ms);
|
|
2885
|
+
}
|
|
2886
|
+
/**
|
|
2887
|
+
* Drain the queue FIFO. Removes an event only when the server ACCEPTED it (2xx)
|
|
2888
|
+
* or PERMANENTLY refused it (a 4xx that a retry can't fix). Keeps it — and stops
|
|
2889
|
+
* draining — on a transient failure (timeout throw, network null, 408/429/5xx),
|
|
2890
|
+
* then arms a capped-exponential-backoff retry. The entire post lives inside the
|
|
2891
|
+
* try/catch, so `NetworkTimeoutError` can never surface as an unhandled rejection.
|
|
2892
|
+
*/
|
|
2893
|
+
async flush() {
|
|
2894
|
+
if (this.flushing || this.q.length === 0) return;
|
|
2895
|
+
this.flushing = true;
|
|
2896
|
+
try {
|
|
2897
|
+
while (this.q.length > 0) {
|
|
2898
|
+
const ev = this.q[0];
|
|
2899
|
+
let outcome;
|
|
2900
|
+
try {
|
|
2901
|
+
const res = await this.deps.post(ev.body);
|
|
2902
|
+
outcome = classify(res);
|
|
2903
|
+
} catch {
|
|
2904
|
+
outcome = "retry";
|
|
2905
|
+
}
|
|
2906
|
+
if (outcome === "ok" || outcome === "drop") {
|
|
2907
|
+
this.q.shift();
|
|
2908
|
+
this.failures = 0;
|
|
2909
|
+
this.persist();
|
|
2910
|
+
continue;
|
|
2911
|
+
}
|
|
2912
|
+
ev.attempts += 1;
|
|
2913
|
+
if (ev.attempts >= MAX_ATTEMPTS) {
|
|
2914
|
+
if (this.deps.debug) {
|
|
2915
|
+
console.warn(`[sendoracloud] event dropped after ${MAX_ATTEMPTS} attempts`);
|
|
2916
|
+
}
|
|
2917
|
+
this.q.shift();
|
|
2918
|
+
this.persist();
|
|
2919
|
+
continue;
|
|
2920
|
+
}
|
|
2921
|
+
this.persist();
|
|
2922
|
+
this.failures += 1;
|
|
2923
|
+
break;
|
|
2924
|
+
}
|
|
2925
|
+
} finally {
|
|
2926
|
+
this.flushing = false;
|
|
2927
|
+
}
|
|
2928
|
+
if (this.q.length > 0) {
|
|
2929
|
+
const backoff = Math.min(MAX_BACKOFF_MS, BASE_BACKOFF_MS * 2 ** Math.max(0, this.failures - 1));
|
|
2930
|
+
this.scheduleFlush(backoff);
|
|
2931
|
+
}
|
|
2932
|
+
}
|
|
2933
|
+
/** Test/introspection only. */
|
|
2934
|
+
get size() {
|
|
2935
|
+
return this.q.length;
|
|
2936
|
+
}
|
|
2937
|
+
};
|
|
2938
|
+
function classify(res) {
|
|
2939
|
+
if (res === null) return "retry";
|
|
2940
|
+
if (res.ok) return "ok";
|
|
2941
|
+
const s = res.status;
|
|
2942
|
+
if (s === 408 || s === 429 || s >= 500) return "retry";
|
|
2943
|
+
return "drop";
|
|
2944
|
+
}
|
|
2945
|
+
|
|
2820
2946
|
// src/index.ts
|
|
2821
2947
|
var DEFAULT_API_URL = "https://api.sendoracloud.com";
|
|
2822
2948
|
var ANON_KEY = "anon_id";
|
|
2823
2949
|
var USER_ID_KEY = "user_id";
|
|
2950
|
+
var EVENT_QUEUE_KEY = "event_queue";
|
|
2824
2951
|
var SCHEMA_VERSION_KEY = "schema_version";
|
|
2825
2952
|
var SCHEMA_VERSION = "1";
|
|
2826
2953
|
var AUTH_HYDRATE_KEYS = [
|
|
@@ -2967,6 +3094,7 @@ var SendoraSDK = class {
|
|
|
2967
3094
|
this.debug = false;
|
|
2968
3095
|
this.sessionId = "";
|
|
2969
3096
|
this.autoTrackCleanup = null;
|
|
3097
|
+
this.eventQueue = null;
|
|
2970
3098
|
// --- Engagement time (foreground-only, Wave 75) ---
|
|
2971
3099
|
this.engEnabled = false;
|
|
2972
3100
|
this.engScreen = null;
|
|
@@ -3026,6 +3154,7 @@ var SendoraSDK = class {
|
|
|
3026
3154
|
ANON_KEY,
|
|
3027
3155
|
USER_ID_KEY,
|
|
3028
3156
|
SCHEMA_VERSION_KEY,
|
|
3157
|
+
EVENT_QUEUE_KEY,
|
|
3029
3158
|
...AUTH_HYDRATE_KEYS
|
|
3030
3159
|
]);
|
|
3031
3160
|
if (!this.storage.get(SCHEMA_VERSION_KEY)) {
|
|
@@ -3061,9 +3190,11 @@ var SendoraSDK = class {
|
|
|
3061
3190
|
if (userId) this.storage.set(USER_ID_KEY, userId);
|
|
3062
3191
|
else this.storage.remove(USER_ID_KEY);
|
|
3063
3192
|
},
|
|
3064
|
-
onAnonymousWipe: async () => {
|
|
3065
|
-
|
|
3066
|
-
|
|
3193
|
+
onAnonymousWipe: async (reason) => {
|
|
3194
|
+
if (reason === "user" || reason === "account_deleted") {
|
|
3195
|
+
this.anonId = uuid();
|
|
3196
|
+
this.storage.set(ANON_KEY, this.anonId);
|
|
3197
|
+
}
|
|
3067
3198
|
this.userId = null;
|
|
3068
3199
|
this.storage.remove(USER_ID_KEY);
|
|
3069
3200
|
}
|
|
@@ -3077,6 +3208,18 @@ var SendoraSDK = class {
|
|
|
3077
3208
|
androidPackageName: this.config.androidPackageName,
|
|
3078
3209
|
linkDomain: this.config.linkDomain
|
|
3079
3210
|
});
|
|
3211
|
+
this.eventQueue = new EventQueue({
|
|
3212
|
+
post: (body) => post(
|
|
3213
|
+
{ apiUrl: this.config.apiUrl, publicKey: this.config.publicKey, debug: this.debug },
|
|
3214
|
+
"/api/v1/events",
|
|
3215
|
+
body
|
|
3216
|
+
).then((res) => res === null ? null : { ok: res.ok, status: res.status }),
|
|
3217
|
+
load: () => this.storage.get(EVENT_QUEUE_KEY) ?? null,
|
|
3218
|
+
save: (json) => this.storage.set(EVENT_QUEUE_KEY, json),
|
|
3219
|
+
debug: this.debug
|
|
3220
|
+
});
|
|
3221
|
+
this.eventQueue.hydrate();
|
|
3222
|
+
this.eventQueue.flushNow();
|
|
3080
3223
|
this.ready = true;
|
|
3081
3224
|
if (config.autoTrack !== false) {
|
|
3082
3225
|
this.engEnabled = config.autoTrack === true || config.autoTrack === void 0 || config.autoTrack.engagement !== false;
|
|
@@ -3088,8 +3231,10 @@ var SendoraSDK = class {
|
|
|
3088
3231
|
this.sessionId = id;
|
|
3089
3232
|
},
|
|
3090
3233
|
onForeground: (active) => {
|
|
3091
|
-
if (active)
|
|
3092
|
-
|
|
3234
|
+
if (active) {
|
|
3235
|
+
this.engResume();
|
|
3236
|
+
this.eventQueue?.flushNow();
|
|
3237
|
+
} else this.engFlush();
|
|
3093
3238
|
}
|
|
3094
3239
|
});
|
|
3095
3240
|
}
|
|
@@ -3169,7 +3314,7 @@ var SendoraSDK = class {
|
|
|
3169
3314
|
}
|
|
3170
3315
|
this.userId = userId;
|
|
3171
3316
|
this.storage.set(USER_ID_KEY, userId);
|
|
3172
|
-
|
|
3317
|
+
this.sendEvent("identify", {
|
|
3173
3318
|
userId,
|
|
3174
3319
|
traits: traits ?? {}
|
|
3175
3320
|
});
|
|
@@ -3186,7 +3331,7 @@ var SendoraSDK = class {
|
|
|
3186
3331
|
throw new Error(`[sendoracloud] properties exceed ${MAX_PROPERTIES_BYTES} bytes.`);
|
|
3187
3332
|
}
|
|
3188
3333
|
}
|
|
3189
|
-
|
|
3334
|
+
this.sendEvent("track", {
|
|
3190
3335
|
eventType,
|
|
3191
3336
|
properties: properties ?? {}
|
|
3192
3337
|
});
|
|
@@ -3198,7 +3343,7 @@ var SendoraSDK = class {
|
|
|
3198
3343
|
throw new Error("[sendoracloud] screen name must be a 1-256 char string.");
|
|
3199
3344
|
}
|
|
3200
3345
|
this.engFlush();
|
|
3201
|
-
|
|
3346
|
+
this.sendEvent("screen", {
|
|
3202
3347
|
eventType: "screen.viewed",
|
|
3203
3348
|
properties: { screenName: name, ...properties ?? {} }
|
|
3204
3349
|
});
|
|
@@ -3226,7 +3371,7 @@ var SendoraSDK = class {
|
|
|
3226
3371
|
if (ms < MIN_ENGAGEMENT_MS) return;
|
|
3227
3372
|
if (ms > MAX_ENGAGEMENT_MS) ms = MAX_ENGAGEMENT_MS;
|
|
3228
3373
|
try {
|
|
3229
|
-
|
|
3374
|
+
this.sendEvent("track", {
|
|
3230
3375
|
eventType: "app.engagement",
|
|
3231
3376
|
properties: { durationMs: Math.round(ms), screen: this.engScreen, sessionId: this.sessionId }
|
|
3232
3377
|
});
|
|
@@ -3810,6 +3955,7 @@ var SendoraSDK = class {
|
|
|
3810
3955
|
await this.auth.signOut().catch(() => void 0);
|
|
3811
3956
|
this.userId = null;
|
|
3812
3957
|
this.sessionId = "";
|
|
3958
|
+
this.eventQueue?.clear();
|
|
3813
3959
|
await this.storage.clearAll();
|
|
3814
3960
|
this.storage.set(ANON_KEY, this.anonId);
|
|
3815
3961
|
}
|
|
@@ -3821,34 +3967,42 @@ var SendoraSDK = class {
|
|
|
3821
3967
|
this.autoTrackCleanup = null;
|
|
3822
3968
|
}
|
|
3823
3969
|
}
|
|
3824
|
-
|
|
3970
|
+
/**
|
|
3971
|
+
* Build an event body and hand it to the persisted queue for delivery.
|
|
3972
|
+
*
|
|
3973
|
+
* ⚠ SYNCHRONOUS + NEVER THROWS (1.39.0). Before this, it was `async` and did
|
|
3974
|
+
* `await post(...)` with no try/catch; `http.ts` THROWS `NetworkTimeoutError` on
|
|
3975
|
+
* its 30 s abort, and every caller discarded the promise with `void` — so a slow
|
|
3976
|
+
* `/api/v1/events` request became an unhandled rejection in the host's crash
|
|
3977
|
+
* reporter AND the event was lost. Now the queue owns delivery: it retries with
|
|
3978
|
+
* backoff, persists across launches, and swallows every transport failure. A
|
|
3979
|
+
* rejection can no longer escape (so `engFlush`'s "never throws" try/catch is
|
|
3980
|
+
* finally true), and a slow network delays events instead of dropping them.
|
|
3981
|
+
*/
|
|
3982
|
+
sendEvent(kind, payload) {
|
|
3825
3983
|
if (!this.config) return;
|
|
3826
3984
|
if (!this.consentGranted) return;
|
|
3827
3985
|
const device = buildDeviceContext(this.config.appVersion, this.config.appBuild);
|
|
3828
|
-
|
|
3829
|
-
|
|
3830
|
-
"
|
|
3831
|
-
|
|
3832
|
-
|
|
3833
|
-
|
|
3834
|
-
|
|
3835
|
-
|
|
3836
|
-
|
|
3837
|
-
|
|
3838
|
-
|
|
3839
|
-
|
|
3840
|
-
|
|
3841
|
-
|
|
3842
|
-
|
|
3843
|
-
|
|
3844
|
-
|
|
3845
|
-
|
|
3846
|
-
...device ? { device } : {},
|
|
3847
|
-
...this.config.environment ? { environment: this.config.environment } : {},
|
|
3848
|
-
...kind === "identify" ? { traits: payload.traits } : {}
|
|
3849
|
-
}
|
|
3986
|
+
this.eventQueue?.enqueue({
|
|
3987
|
+
...this.config.projectId ? { projectId: this.config.projectId } : {},
|
|
3988
|
+
module: kind === "identify" ? "profiles" : "analytics",
|
|
3989
|
+
eventType: payload.eventType ?? (kind === "identify" ? "identify" : "track"),
|
|
3990
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
3991
|
+
...this.userId ? { userId: this.userId } : {},
|
|
3992
|
+
anonymousId: this.anonId,
|
|
3993
|
+
// Top-level sessionId so backend `events.session_id` populates (powers
|
|
3994
|
+
// the Audience "Sessions & engagement" metrics). Set by auto-track;
|
|
3995
|
+
// blank until the first auto event of the launch → omitted when empty.
|
|
3996
|
+
...this.sessionId ? { sessionId: this.sessionId } : {},
|
|
3997
|
+
properties: payload.properties ?? {},
|
|
3998
|
+
context: {
|
|
3999
|
+
platform: "react-native",
|
|
4000
|
+
sdk: { name: "@sendoracloud/sdk-react-native", version: SDK_VERSION },
|
|
4001
|
+
...device ? { device } : {},
|
|
4002
|
+
...this.config.environment ? { environment: this.config.environment } : {},
|
|
4003
|
+
...kind === "identify" ? { traits: payload.traits } : {}
|
|
3850
4004
|
}
|
|
3851
|
-
);
|
|
4005
|
+
});
|
|
3852
4006
|
}
|
|
3853
4007
|
assertReady() {
|
|
3854
4008
|
if (!this.ready || !this.config) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sendoracloud/sdk-react-native",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.39.0",
|
|
4
4
|
"description": "Sendora Cloud React Native + Expo SDK — analytics, identity, push-token registration, auth (incl. Game Center / Play Games), deep links (Branch / Firebase Dynamic Links parity). Auth + analytics + deep links work in Expo Go; push-token registration and the no-arg Game Center / Play Games sign-in require a Dev Client (EAS Build or `npx expo prebuild`).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.cjs",
|
|
@@ -56,7 +56,7 @@
|
|
|
56
56
|
"scripts": {
|
|
57
57
|
"build": "tsup src/index.ts src/contact-widget.tsx --format esm,cjs --dts --clean --external react --external react-native --external react-native-webview",
|
|
58
58
|
"typecheck": "tsc --noEmit",
|
|
59
|
-
"test": "tsx test/secure-storage.test.ts && tsx test/integration-storage.test.ts && tsx test/plugin.test.ts && tsx test/game-auth.test.ts && tsx test/link.test.ts && tsx test/session-preservation.test.ts && tsx test/credential-collision.test.ts && tsx test/token-expiry.test.ts && tsx test/unlink.test.ts"
|
|
59
|
+
"test": "tsx test/secure-storage.test.ts && tsx test/integration-storage.test.ts && tsx test/plugin.test.ts && tsx test/game-auth.test.ts && tsx test/link.test.ts && tsx test/session-preservation.test.ts && tsx test/credential-collision.test.ts && tsx test/token-expiry.test.ts && tsx test/unlink.test.ts && tsx test/deferred-match.test.ts && tsx test/anon-id-stability.test.ts && tsx test/event-queue.test.ts"
|
|
60
60
|
},
|
|
61
61
|
"devDependencies": {
|
|
62
62
|
"@types/react": "^19.2.16",
|