@sendoracloud/sdk-react-native 1.38.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.38.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 && 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,139 @@ 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
+ 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
+
2858
2986
  // src/index.ts
2859
2987
  var DEFAULT_API_URL = "https://api.sendoracloud.com";
2860
2988
  var ANON_KEY = "anon_id";
2861
2989
  var USER_ID_KEY = "user_id";
2990
+ var EVENT_QUEUE_KEY = "event_queue";
2862
2991
  var SCHEMA_VERSION_KEY = "schema_version";
2863
2992
  var SCHEMA_VERSION = "1";
2864
2993
  var AUTH_HYDRATE_KEYS = [
@@ -3005,6 +3134,7 @@ var SendoraSDK = class {
3005
3134
  this.debug = false;
3006
3135
  this.sessionId = "";
3007
3136
  this.autoTrackCleanup = null;
3137
+ this.eventQueue = null;
3008
3138
  // --- Engagement time (foreground-only, Wave 75) ---
3009
3139
  this.engEnabled = false;
3010
3140
  this.engScreen = null;
@@ -3064,6 +3194,7 @@ var SendoraSDK = class {
3064
3194
  ANON_KEY,
3065
3195
  USER_ID_KEY,
3066
3196
  SCHEMA_VERSION_KEY,
3197
+ EVENT_QUEUE_KEY,
3067
3198
  ...AUTH_HYDRATE_KEYS
3068
3199
  ]);
3069
3200
  if (!this.storage.get(SCHEMA_VERSION_KEY)) {
@@ -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
@@ -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.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 && 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,139 @@ 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
+ 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
+
2818
2946
  // src/index.ts
2819
2947
  var DEFAULT_API_URL = "https://api.sendoracloud.com";
2820
2948
  var ANON_KEY = "anon_id";
2821
2949
  var USER_ID_KEY = "user_id";
2950
+ var EVENT_QUEUE_KEY = "event_queue";
2822
2951
  var SCHEMA_VERSION_KEY = "schema_version";
2823
2952
  var SCHEMA_VERSION = "1";
2824
2953
  var AUTH_HYDRATE_KEYS = [
@@ -2965,6 +3094,7 @@ var SendoraSDK = class {
2965
3094
  this.debug = false;
2966
3095
  this.sessionId = "";
2967
3096
  this.autoTrackCleanup = null;
3097
+ this.eventQueue = null;
2968
3098
  // --- Engagement time (foreground-only, Wave 75) ---
2969
3099
  this.engEnabled = false;
2970
3100
  this.engScreen = null;
@@ -3024,6 +3154,7 @@ var SendoraSDK = class {
3024
3154
  ANON_KEY,
3025
3155
  USER_ID_KEY,
3026
3156
  SCHEMA_VERSION_KEY,
3157
+ EVENT_QUEUE_KEY,
3027
3158
  ...AUTH_HYDRATE_KEYS
3028
3159
  ]);
3029
3160
  if (!this.storage.get(SCHEMA_VERSION_KEY)) {
@@ -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.38.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 && 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",