@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 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.36.0",
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 link match. When neither `fingerprintHash` nor
2631
- * `installReferrer` is supplied, the SDK auto-discovers:
2632
- * - Android: probes `react-native-play-install-referrer` (preferred — 100%
2633
- * accurate when present).
2634
- * - Otherwise: computes the canonical device fingerprint hash.
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
- let fingerprintHash = input.fingerprintHash;
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
- this.anonId = uuid();
3106
- this.storage.set(ANON_KEY, this.anonId);
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) this.engResume();
3132
- else this.engFlush();
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
- void this.sendEvent("identify", {
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
- void this.sendEvent("track", {
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
- void this.sendEvent("screen", {
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
- void this.sendEvent("track", {
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
- async sendEvent(kind, payload) {
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
- await post(
3869
- { apiUrl: this.config.apiUrl, publicKey: this.config.publicKey, debug: this.debug },
3870
- "/api/v1/events",
3871
- {
3872
- ...this.config.projectId ? { projectId: this.config.projectId } : {},
3873
- module: kind === "identify" ? "profiles" : "analytics",
3874
- eventType: payload.eventType ?? (kind === "identify" ? "identify" : "track"),
3875
- timestamp: (/* @__PURE__ */ new Date()).toISOString(),
3876
- ...this.userId ? { userId: this.userId } : {},
3877
- anonymousId: this.anonId,
3878
- // Top-level sessionId so backend `events.session_id` populates (powers
3879
- // the Audience "Sessions & engagement" metrics). Set by auto-track;
3880
- // blank until the first auto event of the launch → omitted when empty.
3881
- ...this.sessionId ? { sessionId: this.sessionId } : {},
3882
- properties: payload.properties ?? {},
3883
- context: {
3884
- platform: "react-native",
3885
- sdk: { name: "@sendoracloud/sdk-react-native", version: SDK_VERSION },
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 when the SDK switches from one identity to another via
501
- * signIn() or non-anonymous signUp(). The parent rotates anonId
502
- * + drops any pending events so the new user can't carry over
503
- * orphaned attribution.
504
- */
505
- onAnonymousWipe: () => Promise<void>;
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
- /** Hex-encoded SHA-256 — pass output of `computeDeviceFingerprint()`. */
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. Identical byte-for-byte across the
1441
- * RN / iOS / Android SDKs so a click logged from one platform and an
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
- * Input: `${platform}|${screenW}x${screenH}|${timezone}|${locale}`
1445
- * Output: lowercase hex SHA-256.
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 link match. When neither `fingerprintHash` nor
1515
- * `installReferrer` is supplied, the SDK auto-discovers:
1516
- * - Android: probes `react-native-play-install-referrer` (preferred — 100%
1517
- * accurate when present).
1518
- * - Otherwise: computes the canonical device fingerprint hash.
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 when the SDK switches from one identity to another via
501
- * signIn() or non-anonymous signUp(). The parent rotates anonId
502
- * + drops any pending events so the new user can't carry over
503
- * orphaned attribution.
504
- */
505
- onAnonymousWipe: () => Promise<void>;
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
- /** Hex-encoded SHA-256 — pass output of `computeDeviceFingerprint()`. */
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. Identical byte-for-byte across the
1441
- * RN / iOS / Android SDKs so a click logged from one platform and an
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
- * Input: `${platform}|${screenW}x${screenH}|${timezone}|${locale}`
1445
- * Output: lowercase hex SHA-256.
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 link match. When neither `fingerprintHash` nor
1515
- * `installReferrer` is supplied, the SDK auto-discovers:
1516
- * - Android: probes `react-native-play-install-referrer` (preferred — 100%
1517
- * accurate when present).
1518
- * - Otherwise: computes the canonical device fingerprint hash.
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.36.0",
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 link match. When neither `fingerprintHash` nor
2591
- * `installReferrer` is supplied, the SDK auto-discovers:
2592
- * - Android: probes `react-native-play-install-referrer` (preferred — 100%
2593
- * accurate when present).
2594
- * - Otherwise: computes the canonical device fingerprint hash.
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
- let fingerprintHash = input.fingerprintHash;
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
- this.anonId = uuid();
3066
- this.storage.set(ANON_KEY, this.anonId);
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) this.engResume();
3092
- else this.engFlush();
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
- void this.sendEvent("identify", {
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
- void this.sendEvent("track", {
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
- void this.sendEvent("screen", {
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
- void this.sendEvent("track", {
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
- async sendEvent(kind, payload) {
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
- await post(
3829
- { apiUrl: this.config.apiUrl, publicKey: this.config.publicKey, debug: this.debug },
3830
- "/api/v1/events",
3831
- {
3832
- ...this.config.projectId ? { projectId: this.config.projectId } : {},
3833
- module: kind === "identify" ? "profiles" : "analytics",
3834
- eventType: payload.eventType ?? (kind === "identify" ? "identify" : "track"),
3835
- timestamp: (/* @__PURE__ */ new Date()).toISOString(),
3836
- ...this.userId ? { userId: this.userId } : {},
3837
- anonymousId: this.anonId,
3838
- // Top-level sessionId so backend `events.session_id` populates (powers
3839
- // the Audience "Sessions & engagement" metrics). Set by auto-track;
3840
- // blank until the first auto event of the launch → omitted when empty.
3841
- ...this.sessionId ? { sessionId: this.sessionId } : {},
3842
- properties: payload.properties ?? {},
3843
- context: {
3844
- platform: "react-native",
3845
- sdk: { name: "@sendoracloud/sdk-react-native", version: SDK_VERSION },
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.36.0",
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",