@sendoracloud/sdk-react-native 1.38.0 → 1.39.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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.38.0",
231
+ version: "1.39.1",
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 && tsx test/deferred-match.test.ts && tsx test/anon-id-stability.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",
@@ -2855,10 +2855,140 @@ function installAutoTrack(args) {
2855
2855
  };
2856
2856
  }
2857
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
+ if (this.q.length > 1) this.q.push(this.q.shift());
2962
+ this.persist();
2963
+ this.failures += 1;
2964
+ break;
2965
+ }
2966
+ } finally {
2967
+ this.flushing = false;
2968
+ }
2969
+ if (this.q.length > 0) {
2970
+ const backoff = Math.min(MAX_BACKOFF_MS, BASE_BACKOFF_MS * 2 ** Math.max(0, this.failures - 1));
2971
+ this.scheduleFlush(backoff);
2972
+ }
2973
+ }
2974
+ /** Test/introspection only. */
2975
+ get size() {
2976
+ return this.q.length;
2977
+ }
2978
+ };
2979
+ function classify(res) {
2980
+ if (res === null) return "retry";
2981
+ if (res.ok) return "ok";
2982
+ const s = res.status;
2983
+ if (s === 408 || s === 429 || s >= 500) return "retry";
2984
+ return "drop";
2985
+ }
2986
+
2858
2987
  // src/index.ts
2859
2988
  var DEFAULT_API_URL = "https://api.sendoracloud.com";
2860
2989
  var ANON_KEY = "anon_id";
2861
2990
  var USER_ID_KEY = "user_id";
2991
+ var EVENT_QUEUE_KEY = "event_queue";
2862
2992
  var SCHEMA_VERSION_KEY = "schema_version";
2863
2993
  var SCHEMA_VERSION = "1";
2864
2994
  var AUTH_HYDRATE_KEYS = [
@@ -3005,6 +3135,7 @@ var SendoraSDK = class {
3005
3135
  this.debug = false;
3006
3136
  this.sessionId = "";
3007
3137
  this.autoTrackCleanup = null;
3138
+ this.eventQueue = null;
3008
3139
  // --- Engagement time (foreground-only, Wave 75) ---
3009
3140
  this.engEnabled = false;
3010
3141
  this.engScreen = null;
@@ -3064,6 +3195,7 @@ var SendoraSDK = class {
3064
3195
  ANON_KEY,
3065
3196
  USER_ID_KEY,
3066
3197
  SCHEMA_VERSION_KEY,
3198
+ EVENT_QUEUE_KEY,
3067
3199
  ...AUTH_HYDRATE_KEYS
3068
3200
  ]);
3069
3201
  if (!this.storage.get(SCHEMA_VERSION_KEY)) {
@@ -3104,6 +3236,7 @@ var SendoraSDK = class {
3104
3236
  this.anonId = uuid();
3105
3237
  this.storage.set(ANON_KEY, this.anonId);
3106
3238
  }
3239
+ if (reason === "account_deleted") this.eventQueue?.clear();
3107
3240
  this.userId = null;
3108
3241
  this.storage.remove(USER_ID_KEY);
3109
3242
  }
@@ -3117,6 +3250,18 @@ var SendoraSDK = class {
3117
3250
  androidPackageName: this.config.androidPackageName,
3118
3251
  linkDomain: this.config.linkDomain
3119
3252
  });
3253
+ this.eventQueue = new EventQueue({
3254
+ post: (body) => post(
3255
+ { apiUrl: this.config.apiUrl, publicKey: this.config.publicKey, debug: this.debug },
3256
+ "/api/v1/events",
3257
+ body
3258
+ ).then((res) => res === null ? null : { ok: res.ok, status: res.status }),
3259
+ load: () => this.storage.get(EVENT_QUEUE_KEY) ?? null,
3260
+ save: (json) => this.storage.set(EVENT_QUEUE_KEY, json),
3261
+ debug: this.debug
3262
+ });
3263
+ this.eventQueue.hydrate();
3264
+ this.eventQueue.flushNow();
3120
3265
  this.ready = true;
3121
3266
  if (config.autoTrack !== false) {
3122
3267
  this.engEnabled = config.autoTrack === true || config.autoTrack === void 0 || config.autoTrack.engagement !== false;
@@ -3128,8 +3273,10 @@ var SendoraSDK = class {
3128
3273
  this.sessionId = id;
3129
3274
  },
3130
3275
  onForeground: (active) => {
3131
- if (active) this.engResume();
3132
- else this.engFlush();
3276
+ if (active) {
3277
+ this.engResume();
3278
+ this.eventQueue?.flushNow();
3279
+ } else this.engFlush();
3133
3280
  }
3134
3281
  });
3135
3282
  }
@@ -3209,7 +3356,7 @@ var SendoraSDK = class {
3209
3356
  }
3210
3357
  this.userId = userId;
3211
3358
  this.storage.set(USER_ID_KEY, userId);
3212
- void this.sendEvent("identify", {
3359
+ this.sendEvent("identify", {
3213
3360
  userId,
3214
3361
  traits: traits ?? {}
3215
3362
  });
@@ -3226,7 +3373,7 @@ var SendoraSDK = class {
3226
3373
  throw new Error(`[sendoracloud] properties exceed ${MAX_PROPERTIES_BYTES} bytes.`);
3227
3374
  }
3228
3375
  }
3229
- void this.sendEvent("track", {
3376
+ this.sendEvent("track", {
3230
3377
  eventType,
3231
3378
  properties: properties ?? {}
3232
3379
  });
@@ -3238,7 +3385,7 @@ var SendoraSDK = class {
3238
3385
  throw new Error("[sendoracloud] screen name must be a 1-256 char string.");
3239
3386
  }
3240
3387
  this.engFlush();
3241
- void this.sendEvent("screen", {
3388
+ this.sendEvent("screen", {
3242
3389
  eventType: "screen.viewed",
3243
3390
  properties: { screenName: name, ...properties ?? {} }
3244
3391
  });
@@ -3266,7 +3413,7 @@ var SendoraSDK = class {
3266
3413
  if (ms < MIN_ENGAGEMENT_MS) return;
3267
3414
  if (ms > MAX_ENGAGEMENT_MS) ms = MAX_ENGAGEMENT_MS;
3268
3415
  try {
3269
- void this.sendEvent("track", {
3416
+ this.sendEvent("track", {
3270
3417
  eventType: "app.engagement",
3271
3418
  properties: { durationMs: Math.round(ms), screen: this.engScreen, sessionId: this.sessionId }
3272
3419
  });
@@ -3850,6 +3997,7 @@ var SendoraSDK = class {
3850
3997
  await this.auth.signOut().catch(() => void 0);
3851
3998
  this.userId = null;
3852
3999
  this.sessionId = "";
4000
+ this.eventQueue?.clear();
3853
4001
  await this.storage.clearAll();
3854
4002
  this.storage.set(ANON_KEY, this.anonId);
3855
4003
  }
@@ -3861,34 +4009,42 @@ var SendoraSDK = class {
3861
4009
  this.autoTrackCleanup = null;
3862
4010
  }
3863
4011
  }
3864
- async sendEvent(kind, payload) {
4012
+ /**
4013
+ * Build an event body and hand it to the persisted queue for delivery.
4014
+ *
4015
+ * ⚠ SYNCHRONOUS + NEVER THROWS (1.39.0). Before this, it was `async` and did
4016
+ * `await post(...)` with no try/catch; `http.ts` THROWS `NetworkTimeoutError` on
4017
+ * its 30 s abort, and every caller discarded the promise with `void` — so a slow
4018
+ * `/api/v1/events` request became an unhandled rejection in the host's crash
4019
+ * reporter AND the event was lost. Now the queue owns delivery: it retries with
4020
+ * backoff, persists across launches, and swallows every transport failure. A
4021
+ * rejection can no longer escape (so `engFlush`'s "never throws" try/catch is
4022
+ * finally true), and a slow network delays events instead of dropping them.
4023
+ */
4024
+ sendEvent(kind, payload) {
3865
4025
  if (!this.config) return;
3866
4026
  if (!this.consentGranted) return;
3867
4027
  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
- }
4028
+ this.eventQueue?.enqueue({
4029
+ ...this.config.projectId ? { projectId: this.config.projectId } : {},
4030
+ module: kind === "identify" ? "profiles" : "analytics",
4031
+ eventType: payload.eventType ?? (kind === "identify" ? "identify" : "track"),
4032
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
4033
+ ...this.userId ? { userId: this.userId } : {},
4034
+ anonymousId: this.anonId,
4035
+ // Top-level sessionId so backend `events.session_id` populates (powers
4036
+ // the Audience "Sessions & engagement" metrics). Set by auto-track;
4037
+ // blank until the first auto event of the launch → omitted when empty.
4038
+ ...this.sessionId ? { sessionId: this.sessionId } : {},
4039
+ properties: payload.properties ?? {},
4040
+ context: {
4041
+ platform: "react-native",
4042
+ sdk: { name: "@sendoracloud/sdk-react-native", version: SDK_VERSION },
4043
+ ...device ? { device } : {},
4044
+ ...this.config.environment ? { environment: this.config.environment } : {},
4045
+ ...kind === "identify" ? { traits: payload.traits } : {}
3890
4046
  }
3891
- );
4047
+ });
3892
4048
  }
3893
4049
  assertReady() {
3894
4050
  if (!this.ready || !this.config) {
package/dist/index.d.cts CHANGED
@@ -1624,6 +1624,7 @@ declare class SendoraSDK {
1624
1624
  private debug;
1625
1625
  private sessionId;
1626
1626
  private autoTrackCleanup;
1627
+ private eventQueue;
1627
1628
  private engEnabled;
1628
1629
  private engScreen;
1629
1630
  private engAccumMs;
@@ -2128,6 +2129,18 @@ declare class SendoraSDK {
2128
2129
  reset(): Promise<void>;
2129
2130
  /** Tear down auto-track listeners. Call in test harnesses. */
2130
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
+ */
2131
2144
  private sendEvent;
2132
2145
  private assertReady;
2133
2146
  }
package/dist/index.d.ts CHANGED
@@ -1624,6 +1624,7 @@ declare class SendoraSDK {
1624
1624
  private debug;
1625
1625
  private sessionId;
1626
1626
  private autoTrackCleanup;
1627
+ private eventQueue;
1627
1628
  private engEnabled;
1628
1629
  private engScreen;
1629
1630
  private engAccumMs;
@@ -2128,6 +2129,18 @@ declare class SendoraSDK {
2128
2129
  reset(): Promise<void>;
2129
2130
  /** Tear down auto-track listeners. Call in test harnesses. */
2130
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
+ */
2131
2144
  private sendEvent;
2132
2145
  private assertReady;
2133
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.38.0",
186
+ version: "1.39.1",
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 && tsx test/deferred-match.test.ts && tsx test/anon-id-stability.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",
@@ -2815,10 +2815,140 @@ function installAutoTrack(args) {
2815
2815
  };
2816
2816
  }
2817
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
+ if (this.q.length > 1) this.q.push(this.q.shift());
2922
+ this.persist();
2923
+ this.failures += 1;
2924
+ break;
2925
+ }
2926
+ } finally {
2927
+ this.flushing = false;
2928
+ }
2929
+ if (this.q.length > 0) {
2930
+ const backoff = Math.min(MAX_BACKOFF_MS, BASE_BACKOFF_MS * 2 ** Math.max(0, this.failures - 1));
2931
+ this.scheduleFlush(backoff);
2932
+ }
2933
+ }
2934
+ /** Test/introspection only. */
2935
+ get size() {
2936
+ return this.q.length;
2937
+ }
2938
+ };
2939
+ function classify(res) {
2940
+ if (res === null) return "retry";
2941
+ if (res.ok) return "ok";
2942
+ const s = res.status;
2943
+ if (s === 408 || s === 429 || s >= 500) return "retry";
2944
+ return "drop";
2945
+ }
2946
+
2818
2947
  // src/index.ts
2819
2948
  var DEFAULT_API_URL = "https://api.sendoracloud.com";
2820
2949
  var ANON_KEY = "anon_id";
2821
2950
  var USER_ID_KEY = "user_id";
2951
+ var EVENT_QUEUE_KEY = "event_queue";
2822
2952
  var SCHEMA_VERSION_KEY = "schema_version";
2823
2953
  var SCHEMA_VERSION = "1";
2824
2954
  var AUTH_HYDRATE_KEYS = [
@@ -2965,6 +3095,7 @@ var SendoraSDK = class {
2965
3095
  this.debug = false;
2966
3096
  this.sessionId = "";
2967
3097
  this.autoTrackCleanup = null;
3098
+ this.eventQueue = null;
2968
3099
  // --- Engagement time (foreground-only, Wave 75) ---
2969
3100
  this.engEnabled = false;
2970
3101
  this.engScreen = null;
@@ -3024,6 +3155,7 @@ var SendoraSDK = class {
3024
3155
  ANON_KEY,
3025
3156
  USER_ID_KEY,
3026
3157
  SCHEMA_VERSION_KEY,
3158
+ EVENT_QUEUE_KEY,
3027
3159
  ...AUTH_HYDRATE_KEYS
3028
3160
  ]);
3029
3161
  if (!this.storage.get(SCHEMA_VERSION_KEY)) {
@@ -3064,6 +3196,7 @@ var SendoraSDK = class {
3064
3196
  this.anonId = uuid();
3065
3197
  this.storage.set(ANON_KEY, this.anonId);
3066
3198
  }
3199
+ if (reason === "account_deleted") this.eventQueue?.clear();
3067
3200
  this.userId = null;
3068
3201
  this.storage.remove(USER_ID_KEY);
3069
3202
  }
@@ -3077,6 +3210,18 @@ var SendoraSDK = class {
3077
3210
  androidPackageName: this.config.androidPackageName,
3078
3211
  linkDomain: this.config.linkDomain
3079
3212
  });
3213
+ this.eventQueue = new EventQueue({
3214
+ post: (body) => post(
3215
+ { apiUrl: this.config.apiUrl, publicKey: this.config.publicKey, debug: this.debug },
3216
+ "/api/v1/events",
3217
+ body
3218
+ ).then((res) => res === null ? null : { ok: res.ok, status: res.status }),
3219
+ load: () => this.storage.get(EVENT_QUEUE_KEY) ?? null,
3220
+ save: (json) => this.storage.set(EVENT_QUEUE_KEY, json),
3221
+ debug: this.debug
3222
+ });
3223
+ this.eventQueue.hydrate();
3224
+ this.eventQueue.flushNow();
3080
3225
  this.ready = true;
3081
3226
  if (config.autoTrack !== false) {
3082
3227
  this.engEnabled = config.autoTrack === true || config.autoTrack === void 0 || config.autoTrack.engagement !== false;
@@ -3088,8 +3233,10 @@ var SendoraSDK = class {
3088
3233
  this.sessionId = id;
3089
3234
  },
3090
3235
  onForeground: (active) => {
3091
- if (active) this.engResume();
3092
- else this.engFlush();
3236
+ if (active) {
3237
+ this.engResume();
3238
+ this.eventQueue?.flushNow();
3239
+ } else this.engFlush();
3093
3240
  }
3094
3241
  });
3095
3242
  }
@@ -3169,7 +3316,7 @@ var SendoraSDK = class {
3169
3316
  }
3170
3317
  this.userId = userId;
3171
3318
  this.storage.set(USER_ID_KEY, userId);
3172
- void this.sendEvent("identify", {
3319
+ this.sendEvent("identify", {
3173
3320
  userId,
3174
3321
  traits: traits ?? {}
3175
3322
  });
@@ -3186,7 +3333,7 @@ var SendoraSDK = class {
3186
3333
  throw new Error(`[sendoracloud] properties exceed ${MAX_PROPERTIES_BYTES} bytes.`);
3187
3334
  }
3188
3335
  }
3189
- void this.sendEvent("track", {
3336
+ this.sendEvent("track", {
3190
3337
  eventType,
3191
3338
  properties: properties ?? {}
3192
3339
  });
@@ -3198,7 +3345,7 @@ var SendoraSDK = class {
3198
3345
  throw new Error("[sendoracloud] screen name must be a 1-256 char string.");
3199
3346
  }
3200
3347
  this.engFlush();
3201
- void this.sendEvent("screen", {
3348
+ this.sendEvent("screen", {
3202
3349
  eventType: "screen.viewed",
3203
3350
  properties: { screenName: name, ...properties ?? {} }
3204
3351
  });
@@ -3226,7 +3373,7 @@ var SendoraSDK = class {
3226
3373
  if (ms < MIN_ENGAGEMENT_MS) return;
3227
3374
  if (ms > MAX_ENGAGEMENT_MS) ms = MAX_ENGAGEMENT_MS;
3228
3375
  try {
3229
- void this.sendEvent("track", {
3376
+ this.sendEvent("track", {
3230
3377
  eventType: "app.engagement",
3231
3378
  properties: { durationMs: Math.round(ms), screen: this.engScreen, sessionId: this.sessionId }
3232
3379
  });
@@ -3810,6 +3957,7 @@ var SendoraSDK = class {
3810
3957
  await this.auth.signOut().catch(() => void 0);
3811
3958
  this.userId = null;
3812
3959
  this.sessionId = "";
3960
+ this.eventQueue?.clear();
3813
3961
  await this.storage.clearAll();
3814
3962
  this.storage.set(ANON_KEY, this.anonId);
3815
3963
  }
@@ -3821,34 +3969,42 @@ var SendoraSDK = class {
3821
3969
  this.autoTrackCleanup = null;
3822
3970
  }
3823
3971
  }
3824
- async sendEvent(kind, payload) {
3972
+ /**
3973
+ * Build an event body and hand it to the persisted queue for delivery.
3974
+ *
3975
+ * ⚠ SYNCHRONOUS + NEVER THROWS (1.39.0). Before this, it was `async` and did
3976
+ * `await post(...)` with no try/catch; `http.ts` THROWS `NetworkTimeoutError` on
3977
+ * its 30 s abort, and every caller discarded the promise with `void` — so a slow
3978
+ * `/api/v1/events` request became an unhandled rejection in the host's crash
3979
+ * reporter AND the event was lost. Now the queue owns delivery: it retries with
3980
+ * backoff, persists across launches, and swallows every transport failure. A
3981
+ * rejection can no longer escape (so `engFlush`'s "never throws" try/catch is
3982
+ * finally true), and a slow network delays events instead of dropping them.
3983
+ */
3984
+ sendEvent(kind, payload) {
3825
3985
  if (!this.config) return;
3826
3986
  if (!this.consentGranted) return;
3827
3987
  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
- }
3988
+ this.eventQueue?.enqueue({
3989
+ ...this.config.projectId ? { projectId: this.config.projectId } : {},
3990
+ module: kind === "identify" ? "profiles" : "analytics",
3991
+ eventType: payload.eventType ?? (kind === "identify" ? "identify" : "track"),
3992
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
3993
+ ...this.userId ? { userId: this.userId } : {},
3994
+ anonymousId: this.anonId,
3995
+ // Top-level sessionId so backend `events.session_id` populates (powers
3996
+ // the Audience "Sessions & engagement" metrics). Set by auto-track;
3997
+ // blank until the first auto event of the launch → omitted when empty.
3998
+ ...this.sessionId ? { sessionId: this.sessionId } : {},
3999
+ properties: payload.properties ?? {},
4000
+ context: {
4001
+ platform: "react-native",
4002
+ sdk: { name: "@sendoracloud/sdk-react-native", version: SDK_VERSION },
4003
+ ...device ? { device } : {},
4004
+ ...this.config.environment ? { environment: this.config.environment } : {},
4005
+ ...kind === "identify" ? { traits: payload.traits } : {}
3850
4006
  }
3851
- );
4007
+ });
3852
4008
  }
3853
4009
  assertReady() {
3854
4010
  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.38.0",
3
+ "version": "1.39.1",
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 && tsx test/deferred-match.test.ts && tsx test/anon-id-stability.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",