@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 +188 -32
- package/dist/index.d.cts +13 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.js +188 -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.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)
|
|
3132
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
}
|
|
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.
|
|
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)
|
|
3092
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
}
|
|
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.
|
|
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",
|