@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 +186 -32
- package/dist/index.d.cts +13 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.js +186 -32
- package/package.json +2 -2
package/dist/index.cjs
CHANGED
|
@@ -228,7 +228,7 @@ var Storage = class {
|
|
|
228
228
|
// package.json
|
|
229
229
|
var package_default = {
|
|
230
230
|
name: "@sendoracloud/sdk-react-native",
|
|
231
|
-
version: "1.
|
|
231
|
+
version: "1.39.0",
|
|
232
232
|
description: "Sendora Cloud React Native + Expo SDK \u2014 analytics, identity, push-token registration, auth (incl. Game Center / Play Games), deep links (Branch / Firebase Dynamic Links parity). Auth + analytics + deep links work in Expo Go; push-token registration and the no-arg Game Center / Play Games sign-in require a Dev Client (EAS Build or `npx expo prebuild`).",
|
|
233
233
|
type: "module",
|
|
234
234
|
main: "./dist/index.cjs",
|
|
@@ -284,7 +284,7 @@ var package_default = {
|
|
|
284
284
|
scripts: {
|
|
285
285
|
build: "tsup src/index.ts src/contact-widget.tsx --format esm,cjs --dts --clean --external react --external react-native --external react-native-webview",
|
|
286
286
|
typecheck: "tsc --noEmit",
|
|
287
|
-
test: "tsx test/secure-storage.test.ts && tsx test/integration-storage.test.ts && tsx test/plugin.test.ts && tsx test/game-auth.test.ts && tsx test/link.test.ts && tsx test/session-preservation.test.ts && tsx test/credential-collision.test.ts && tsx test/token-expiry.test.ts && tsx test/unlink.test.ts && 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)
|
|
3132
|
-
|
|
3274
|
+
if (active) {
|
|
3275
|
+
this.engResume();
|
|
3276
|
+
this.eventQueue?.flushNow();
|
|
3277
|
+
} else this.engFlush();
|
|
3133
3278
|
}
|
|
3134
3279
|
});
|
|
3135
3280
|
}
|
|
@@ -3209,7 +3354,7 @@ var SendoraSDK = class {
|
|
|
3209
3354
|
}
|
|
3210
3355
|
this.userId = userId;
|
|
3211
3356
|
this.storage.set(USER_ID_KEY, userId);
|
|
3212
|
-
|
|
3357
|
+
this.sendEvent("identify", {
|
|
3213
3358
|
userId,
|
|
3214
3359
|
traits: traits ?? {}
|
|
3215
3360
|
});
|
|
@@ -3226,7 +3371,7 @@ var SendoraSDK = class {
|
|
|
3226
3371
|
throw new Error(`[sendoracloud] properties exceed ${MAX_PROPERTIES_BYTES} bytes.`);
|
|
3227
3372
|
}
|
|
3228
3373
|
}
|
|
3229
|
-
|
|
3374
|
+
this.sendEvent("track", {
|
|
3230
3375
|
eventType,
|
|
3231
3376
|
properties: properties ?? {}
|
|
3232
3377
|
});
|
|
@@ -3238,7 +3383,7 @@ var SendoraSDK = class {
|
|
|
3238
3383
|
throw new Error("[sendoracloud] screen name must be a 1-256 char string.");
|
|
3239
3384
|
}
|
|
3240
3385
|
this.engFlush();
|
|
3241
|
-
|
|
3386
|
+
this.sendEvent("screen", {
|
|
3242
3387
|
eventType: "screen.viewed",
|
|
3243
3388
|
properties: { screenName: name, ...properties ?? {} }
|
|
3244
3389
|
});
|
|
@@ -3266,7 +3411,7 @@ var SendoraSDK = class {
|
|
|
3266
3411
|
if (ms < MIN_ENGAGEMENT_MS) return;
|
|
3267
3412
|
if (ms > MAX_ENGAGEMENT_MS) ms = MAX_ENGAGEMENT_MS;
|
|
3268
3413
|
try {
|
|
3269
|
-
|
|
3414
|
+
this.sendEvent("track", {
|
|
3270
3415
|
eventType: "app.engagement",
|
|
3271
3416
|
properties: { durationMs: Math.round(ms), screen: this.engScreen, sessionId: this.sessionId }
|
|
3272
3417
|
});
|
|
@@ -3850,6 +3995,7 @@ var SendoraSDK = class {
|
|
|
3850
3995
|
await this.auth.signOut().catch(() => void 0);
|
|
3851
3996
|
this.userId = null;
|
|
3852
3997
|
this.sessionId = "";
|
|
3998
|
+
this.eventQueue?.clear();
|
|
3853
3999
|
await this.storage.clearAll();
|
|
3854
4000
|
this.storage.set(ANON_KEY, this.anonId);
|
|
3855
4001
|
}
|
|
@@ -3861,34 +4007,42 @@ var SendoraSDK = class {
|
|
|
3861
4007
|
this.autoTrackCleanup = null;
|
|
3862
4008
|
}
|
|
3863
4009
|
}
|
|
3864
|
-
|
|
4010
|
+
/**
|
|
4011
|
+
* Build an event body and hand it to the persisted queue for delivery.
|
|
4012
|
+
*
|
|
4013
|
+
* ⚠ SYNCHRONOUS + NEVER THROWS (1.39.0). Before this, it was `async` and did
|
|
4014
|
+
* `await post(...)` with no try/catch; `http.ts` THROWS `NetworkTimeoutError` on
|
|
4015
|
+
* its 30 s abort, and every caller discarded the promise with `void` — so a slow
|
|
4016
|
+
* `/api/v1/events` request became an unhandled rejection in the host's crash
|
|
4017
|
+
* reporter AND the event was lost. Now the queue owns delivery: it retries with
|
|
4018
|
+
* backoff, persists across launches, and swallows every transport failure. A
|
|
4019
|
+
* rejection can no longer escape (so `engFlush`'s "never throws" try/catch is
|
|
4020
|
+
* finally true), and a slow network delays events instead of dropping them.
|
|
4021
|
+
*/
|
|
4022
|
+
sendEvent(kind, payload) {
|
|
3865
4023
|
if (!this.config) return;
|
|
3866
4024
|
if (!this.consentGranted) return;
|
|
3867
4025
|
const device = buildDeviceContext(this.config.appVersion, this.config.appBuild);
|
|
3868
|
-
|
|
3869
|
-
|
|
3870
|
-
"
|
|
3871
|
-
|
|
3872
|
-
|
|
3873
|
-
|
|
3874
|
-
|
|
3875
|
-
|
|
3876
|
-
|
|
3877
|
-
|
|
3878
|
-
|
|
3879
|
-
|
|
3880
|
-
|
|
3881
|
-
|
|
3882
|
-
|
|
3883
|
-
|
|
3884
|
-
|
|
3885
|
-
|
|
3886
|
-
...device ? { device } : {},
|
|
3887
|
-
...this.config.environment ? { environment: this.config.environment } : {},
|
|
3888
|
-
...kind === "identify" ? { traits: payload.traits } : {}
|
|
3889
|
-
}
|
|
4026
|
+
this.eventQueue?.enqueue({
|
|
4027
|
+
...this.config.projectId ? { projectId: this.config.projectId } : {},
|
|
4028
|
+
module: kind === "identify" ? "profiles" : "analytics",
|
|
4029
|
+
eventType: payload.eventType ?? (kind === "identify" ? "identify" : "track"),
|
|
4030
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
4031
|
+
...this.userId ? { userId: this.userId } : {},
|
|
4032
|
+
anonymousId: this.anonId,
|
|
4033
|
+
// Top-level sessionId so backend `events.session_id` populates (powers
|
|
4034
|
+
// the Audience "Sessions & engagement" metrics). Set by auto-track;
|
|
4035
|
+
// blank until the first auto event of the launch → omitted when empty.
|
|
4036
|
+
...this.sessionId ? { sessionId: this.sessionId } : {},
|
|
4037
|
+
properties: payload.properties ?? {},
|
|
4038
|
+
context: {
|
|
4039
|
+
platform: "react-native",
|
|
4040
|
+
sdk: { name: "@sendoracloud/sdk-react-native", version: SDK_VERSION },
|
|
4041
|
+
...device ? { device } : {},
|
|
4042
|
+
...this.config.environment ? { environment: this.config.environment } : {},
|
|
4043
|
+
...kind === "identify" ? { traits: payload.traits } : {}
|
|
3890
4044
|
}
|
|
3891
|
-
);
|
|
4045
|
+
});
|
|
3892
4046
|
}
|
|
3893
4047
|
assertReady() {
|
|
3894
4048
|
if (!this.ready || !this.config) {
|
package/dist/index.d.cts
CHANGED
|
@@ -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.
|
|
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)
|
|
3092
|
-
|
|
3234
|
+
if (active) {
|
|
3235
|
+
this.engResume();
|
|
3236
|
+
this.eventQueue?.flushNow();
|
|
3237
|
+
} else this.engFlush();
|
|
3093
3238
|
}
|
|
3094
3239
|
});
|
|
3095
3240
|
}
|
|
@@ -3169,7 +3314,7 @@ var SendoraSDK = class {
|
|
|
3169
3314
|
}
|
|
3170
3315
|
this.userId = userId;
|
|
3171
3316
|
this.storage.set(USER_ID_KEY, userId);
|
|
3172
|
-
|
|
3317
|
+
this.sendEvent("identify", {
|
|
3173
3318
|
userId,
|
|
3174
3319
|
traits: traits ?? {}
|
|
3175
3320
|
});
|
|
@@ -3186,7 +3331,7 @@ var SendoraSDK = class {
|
|
|
3186
3331
|
throw new Error(`[sendoracloud] properties exceed ${MAX_PROPERTIES_BYTES} bytes.`);
|
|
3187
3332
|
}
|
|
3188
3333
|
}
|
|
3189
|
-
|
|
3334
|
+
this.sendEvent("track", {
|
|
3190
3335
|
eventType,
|
|
3191
3336
|
properties: properties ?? {}
|
|
3192
3337
|
});
|
|
@@ -3198,7 +3343,7 @@ var SendoraSDK = class {
|
|
|
3198
3343
|
throw new Error("[sendoracloud] screen name must be a 1-256 char string.");
|
|
3199
3344
|
}
|
|
3200
3345
|
this.engFlush();
|
|
3201
|
-
|
|
3346
|
+
this.sendEvent("screen", {
|
|
3202
3347
|
eventType: "screen.viewed",
|
|
3203
3348
|
properties: { screenName: name, ...properties ?? {} }
|
|
3204
3349
|
});
|
|
@@ -3226,7 +3371,7 @@ var SendoraSDK = class {
|
|
|
3226
3371
|
if (ms < MIN_ENGAGEMENT_MS) return;
|
|
3227
3372
|
if (ms > MAX_ENGAGEMENT_MS) ms = MAX_ENGAGEMENT_MS;
|
|
3228
3373
|
try {
|
|
3229
|
-
|
|
3374
|
+
this.sendEvent("track", {
|
|
3230
3375
|
eventType: "app.engagement",
|
|
3231
3376
|
properties: { durationMs: Math.round(ms), screen: this.engScreen, sessionId: this.sessionId }
|
|
3232
3377
|
});
|
|
@@ -3810,6 +3955,7 @@ var SendoraSDK = class {
|
|
|
3810
3955
|
await this.auth.signOut().catch(() => void 0);
|
|
3811
3956
|
this.userId = null;
|
|
3812
3957
|
this.sessionId = "";
|
|
3958
|
+
this.eventQueue?.clear();
|
|
3813
3959
|
await this.storage.clearAll();
|
|
3814
3960
|
this.storage.set(ANON_KEY, this.anonId);
|
|
3815
3961
|
}
|
|
@@ -3821,34 +3967,42 @@ var SendoraSDK = class {
|
|
|
3821
3967
|
this.autoTrackCleanup = null;
|
|
3822
3968
|
}
|
|
3823
3969
|
}
|
|
3824
|
-
|
|
3970
|
+
/**
|
|
3971
|
+
* Build an event body and hand it to the persisted queue for delivery.
|
|
3972
|
+
*
|
|
3973
|
+
* ⚠ SYNCHRONOUS + NEVER THROWS (1.39.0). Before this, it was `async` and did
|
|
3974
|
+
* `await post(...)` with no try/catch; `http.ts` THROWS `NetworkTimeoutError` on
|
|
3975
|
+
* its 30 s abort, and every caller discarded the promise with `void` — so a slow
|
|
3976
|
+
* `/api/v1/events` request became an unhandled rejection in the host's crash
|
|
3977
|
+
* reporter AND the event was lost. Now the queue owns delivery: it retries with
|
|
3978
|
+
* backoff, persists across launches, and swallows every transport failure. A
|
|
3979
|
+
* rejection can no longer escape (so `engFlush`'s "never throws" try/catch is
|
|
3980
|
+
* finally true), and a slow network delays events instead of dropping them.
|
|
3981
|
+
*/
|
|
3982
|
+
sendEvent(kind, payload) {
|
|
3825
3983
|
if (!this.config) return;
|
|
3826
3984
|
if (!this.consentGranted) return;
|
|
3827
3985
|
const device = buildDeviceContext(this.config.appVersion, this.config.appBuild);
|
|
3828
|
-
|
|
3829
|
-
|
|
3830
|
-
"
|
|
3831
|
-
|
|
3832
|
-
|
|
3833
|
-
|
|
3834
|
-
|
|
3835
|
-
|
|
3836
|
-
|
|
3837
|
-
|
|
3838
|
-
|
|
3839
|
-
|
|
3840
|
-
|
|
3841
|
-
|
|
3842
|
-
|
|
3843
|
-
|
|
3844
|
-
|
|
3845
|
-
|
|
3846
|
-
...device ? { device } : {},
|
|
3847
|
-
...this.config.environment ? { environment: this.config.environment } : {},
|
|
3848
|
-
...kind === "identify" ? { traits: payload.traits } : {}
|
|
3849
|
-
}
|
|
3986
|
+
this.eventQueue?.enqueue({
|
|
3987
|
+
...this.config.projectId ? { projectId: this.config.projectId } : {},
|
|
3988
|
+
module: kind === "identify" ? "profiles" : "analytics",
|
|
3989
|
+
eventType: payload.eventType ?? (kind === "identify" ? "identify" : "track"),
|
|
3990
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
3991
|
+
...this.userId ? { userId: this.userId } : {},
|
|
3992
|
+
anonymousId: this.anonId,
|
|
3993
|
+
// Top-level sessionId so backend `events.session_id` populates (powers
|
|
3994
|
+
// the Audience "Sessions & engagement" metrics). Set by auto-track;
|
|
3995
|
+
// blank until the first auto event of the launch → omitted when empty.
|
|
3996
|
+
...this.sessionId ? { sessionId: this.sessionId } : {},
|
|
3997
|
+
properties: payload.properties ?? {},
|
|
3998
|
+
context: {
|
|
3999
|
+
platform: "react-native",
|
|
4000
|
+
sdk: { name: "@sendoracloud/sdk-react-native", version: SDK_VERSION },
|
|
4001
|
+
...device ? { device } : {},
|
|
4002
|
+
...this.config.environment ? { environment: this.config.environment } : {},
|
|
4003
|
+
...kind === "identify" ? { traits: payload.traits } : {}
|
|
3850
4004
|
}
|
|
3851
|
-
);
|
|
4005
|
+
});
|
|
3852
4006
|
}
|
|
3853
4007
|
assertReady() {
|
|
3854
4008
|
if (!this.ready || !this.config) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sendoracloud/sdk-react-native",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.39.0",
|
|
4
4
|
"description": "Sendora Cloud React Native + Expo SDK — analytics, identity, push-token registration, auth (incl. Game Center / Play Games), deep links (Branch / Firebase Dynamic Links parity). Auth + analytics + deep links work in Expo Go; push-token registration and the no-arg Game Center / Play Games sign-in require a Dev Client (EAS Build or `npx expo prebuild`).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.cjs",
|
|
@@ -56,7 +56,7 @@
|
|
|
56
56
|
"scripts": {
|
|
57
57
|
"build": "tsup src/index.ts src/contact-widget.tsx --format esm,cjs --dts --clean --external react --external react-native --external react-native-webview",
|
|
58
58
|
"typecheck": "tsc --noEmit",
|
|
59
|
-
"test": "tsx test/secure-storage.test.ts && tsx test/integration-storage.test.ts && tsx test/plugin.test.ts && tsx test/game-auth.test.ts && tsx test/link.test.ts && tsx test/session-preservation.test.ts && tsx test/credential-collision.test.ts && tsx test/token-expiry.test.ts && tsx test/unlink.test.ts && 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",
|