@sendoracloud/sdk-react-native 0.10.3 → 0.11.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/README.md +55 -1
- package/dist/index.cjs +139 -10
- package/dist/index.d.cts +44 -3
- package/dist/index.d.ts +44 -3
- package/dist/index.js +146 -10
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -2,14 +2,26 @@
|
|
|
2
2
|
|
|
3
3
|
Official React Native + Expo SDK for [SendoraCloud](https://sendoracloud.com). Works in Expo Go, managed dev clients, and bare React Native apps.
|
|
4
4
|
|
|
5
|
+
> 📖 **First time on React Native?** Read the [5-minute quickstart](https://sendoracloud.com/docs/quickstart-react-native) — it covers the Hermes CSPRNG polyfill, anonymous-first auth, push token receipts, and the demo end-to-end push send.
|
|
6
|
+
|
|
5
7
|
## Install
|
|
6
8
|
|
|
7
9
|
```bash
|
|
8
10
|
npx expo install @sendoracloud/sdk-react-native @react-native-async-storage/async-storage
|
|
11
|
+
npm install react-native-get-random-values
|
|
9
12
|
```
|
|
10
13
|
|
|
11
14
|
`@react-native-async-storage/async-storage` is a required peer dependency — used to persist the anonymous device id across app restarts.
|
|
12
15
|
|
|
16
|
+
`react-native-get-random-values` is a required runtime polyfill — Hermes (and JavaScriptCore) doesn't expose `crypto.getRandomValues` reliably, and Sendora refuses to mint anonymous IDs from `Math.random`. Add this as the **first import** in your entry file (`index.js` or `index.tsx`):
|
|
17
|
+
|
|
18
|
+
```ts
|
|
19
|
+
// MUST be the first import in your entry file
|
|
20
|
+
import "react-native-get-random-values";
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
If you forget, `init()` throws with a paste-ready remediation block — it never fails silently.
|
|
24
|
+
|
|
13
25
|
## Quick start
|
|
14
26
|
|
|
15
27
|
```ts
|
|
@@ -36,11 +48,16 @@ SendoraCloud.screen("Pricing");
|
|
|
36
48
|
// Register the device push token (get it from expo-notifications)
|
|
37
49
|
import * as Notifications from "expo-notifications";
|
|
38
50
|
const { data } = await Notifications.getExpoPushTokenAsync();
|
|
39
|
-
await SendoraCloud.registerPushToken({
|
|
51
|
+
const receipt = await SendoraCloud.registerPushToken({
|
|
40
52
|
token: data,
|
|
41
53
|
platform: Platform.OS as "ios" | "android",
|
|
54
|
+
// bundleId is OPTIONAL but strongly recommended for multi-env apps:
|
|
55
|
+
// Sendora uses it to route to the right APNs / FCM credentials so a
|
|
56
|
+
// dev token never receives a prod push.
|
|
42
57
|
bundleId: "com.yourcompany.app",
|
|
43
58
|
});
|
|
59
|
+
// Returns { tokenId, created } — log it for debugging fan-out issues.
|
|
60
|
+
console.log("registered:", receipt.tokenId, "new?", receipt.created);
|
|
44
61
|
|
|
45
62
|
// On logout
|
|
46
63
|
SendoraCloud.reset();
|
|
@@ -69,9 +86,46 @@ SendoraCloud.reset();
|
|
|
69
86
|
environment?: "prod" | "staging" | "dev"; // Routing hint.
|
|
70
87
|
projectId?: string; // Optional project scoping.
|
|
71
88
|
consentedByDefault?: boolean; // Defaults to true.
|
|
89
|
+
/**
|
|
90
|
+
* Auto-collect lifecycle events. Default true. Object form:
|
|
91
|
+
* { appOpen?, sessionStart?, appBackground? } for granular control.
|
|
92
|
+
* - appOpen — `app.opened` once per init.
|
|
93
|
+
* - sessionStart — `session.start` once per session (idle >sessionIdleMs).
|
|
94
|
+
* - appBackground — AppState 'change' fires `app.foregrounded` /
|
|
95
|
+
* `app.backgrounded`.
|
|
96
|
+
* Note: `screen.viewed` is NOT auto — call `SendoraCloud.screen(name)`
|
|
97
|
+
* from your navigation listener (RN has no canonical router).
|
|
98
|
+
*/
|
|
99
|
+
autoTrack?: boolean | { appOpen?: boolean; sessionStart?: boolean; appBackground?: boolean };
|
|
100
|
+
sessionIdleMs?: number; // Idle threshold for session expiry. Default 30 min.
|
|
101
|
+
}
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
## Stability
|
|
105
|
+
|
|
106
|
+
This SDK is on the **0.10.x line**. Patch bumps (0.10.x → 0.10.y) are backwards-compatible; minor bumps may include breaking changes — always read the [CHANGELOG](https://github.com/sendoracloud/sdk-react-native/blob/main/CHANGELOG.md) before upgrading. The next major (1.0) is targeted once the HttpOnly-cookie SSR auth + auto-trait extraction features have soaked in production for two consecutive months without a schema change. Once 1.0 ships, semver is strict.
|
|
107
|
+
|
|
108
|
+
## JWT verification (custom backend)
|
|
109
|
+
|
|
110
|
+
If your backend verifies Sendora-issued access tokens directly (rather than calling Sendora APIs that re-verify on every hit), use the OIDC-standard JWKS auto-discovery pattern — never hardcode the JWKS URL:
|
|
111
|
+
|
|
112
|
+
```ts
|
|
113
|
+
import { createRemoteJWKSet, jwtVerify, decodeJwt } from "jose";
|
|
114
|
+
|
|
115
|
+
const JWKS_BY_ISS = new Map<string, ReturnType<typeof createRemoteJWKSet>>();
|
|
116
|
+
function getJwks(iss: string) {
|
|
117
|
+
if (!JWKS_BY_ISS.has(iss)) {
|
|
118
|
+
JWKS_BY_ISS.set(iss, createRemoteJWKSet(new URL(`${iss}/.well-known/jwks.json`)));
|
|
119
|
+
}
|
|
120
|
+
return JWKS_BY_ISS.get(iss)!;
|
|
72
121
|
}
|
|
122
|
+
|
|
123
|
+
const claims = decodeJwt(token);
|
|
124
|
+
const { payload } = await jwtVerify(token, getJwks(claims.iss as string));
|
|
73
125
|
```
|
|
74
126
|
|
|
127
|
+
The `iss` claim is a per-org Sendora URL (e.g. `https://api.sendoracloud.com/api/v1/auth-service/<orgId>`); appending `/.well-known/jwks.json` resolves on the same host. This means a customer can rotate signing keys without your code redeploying. **Don't** copy a hardcoded JWKS URL from a tutorial — it'll break in 12 months when an org rotates.
|
|
128
|
+
|
|
75
129
|
## Security
|
|
76
130
|
|
|
77
131
|
- **Secret keys refused on the client.** The SDK only accepts `pk_(live|test)_<alphanumerics>` keys — `sk_*` (any case) or malformed values throw at init. Secret keys must stay on your backend.
|
package/dist/index.cjs
CHANGED
|
@@ -799,8 +799,88 @@ function decodeJwtPayload(token) {
|
|
|
799
799
|
}
|
|
800
800
|
}
|
|
801
801
|
|
|
802
|
+
// src/auto-track.ts
|
|
803
|
+
var DEFAULT_IDLE_MS = 30 * 60 * 1e3;
|
|
804
|
+
function resolveFlags(cfg) {
|
|
805
|
+
if (cfg === false) {
|
|
806
|
+
return { appOpen: false, sessionStart: false, appBackground: false };
|
|
807
|
+
}
|
|
808
|
+
if (cfg === true || cfg === void 0) {
|
|
809
|
+
return { appOpen: true, sessionStart: true, appBackground: true };
|
|
810
|
+
}
|
|
811
|
+
return {
|
|
812
|
+
appOpen: cfg.appOpen ?? true,
|
|
813
|
+
sessionStart: cfg.sessionStart ?? true,
|
|
814
|
+
appBackground: cfg.appBackground ?? true
|
|
815
|
+
};
|
|
816
|
+
}
|
|
817
|
+
function installAutoTrack(args) {
|
|
818
|
+
const flags = resolveFlags(args.config.autoTrack);
|
|
819
|
+
const idleMs = args.config.sessionIdleMs ?? DEFAULT_IDLE_MS;
|
|
820
|
+
let lastActivity = Date.now();
|
|
821
|
+
const cleanups = [];
|
|
822
|
+
function newSessionId() {
|
|
823
|
+
return `sess_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`;
|
|
824
|
+
}
|
|
825
|
+
function ensureSession(emitStart) {
|
|
826
|
+
const now = Date.now();
|
|
827
|
+
let id = args.getSessionId();
|
|
828
|
+
if (!id || now - lastActivity > idleMs) {
|
|
829
|
+
id = newSessionId();
|
|
830
|
+
args.setSessionId(id);
|
|
831
|
+
if (emitStart && flags.sessionStart) {
|
|
832
|
+
args.fire("session.start", { sessionId: id });
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
lastActivity = now;
|
|
836
|
+
return id;
|
|
837
|
+
}
|
|
838
|
+
const sessionId = ensureSession(
|
|
839
|
+
/* emitStart */
|
|
840
|
+
flags.sessionStart
|
|
841
|
+
);
|
|
842
|
+
if (flags.appOpen) {
|
|
843
|
+
args.fire("app.opened", { sessionId });
|
|
844
|
+
}
|
|
845
|
+
if (flags.appBackground) {
|
|
846
|
+
let AppState = null;
|
|
847
|
+
try {
|
|
848
|
+
AppState = require("react-native").AppState;
|
|
849
|
+
} catch {
|
|
850
|
+
AppState = null;
|
|
851
|
+
}
|
|
852
|
+
if (AppState && typeof AppState.addEventListener === "function") {
|
|
853
|
+
const handler = (state) => {
|
|
854
|
+
ensureSession(true);
|
|
855
|
+
if (state === "background" || state === "inactive") {
|
|
856
|
+
args.fire("app.backgrounded", { sessionId: args.getSessionId() });
|
|
857
|
+
} else if (state === "active") {
|
|
858
|
+
args.fire("app.foregrounded", { sessionId: args.getSessionId() });
|
|
859
|
+
}
|
|
860
|
+
};
|
|
861
|
+
const sub = AppState.addEventListener("change", handler);
|
|
862
|
+
cleanups.push(() => {
|
|
863
|
+
if (typeof sub === "function") {
|
|
864
|
+
return;
|
|
865
|
+
}
|
|
866
|
+
if (sub && typeof sub.remove === "function") {
|
|
867
|
+
sub.remove();
|
|
868
|
+
}
|
|
869
|
+
});
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
return () => {
|
|
873
|
+
for (const fn of cleanups) {
|
|
874
|
+
try {
|
|
875
|
+
fn();
|
|
876
|
+
} catch {
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
};
|
|
880
|
+
}
|
|
881
|
+
|
|
802
882
|
// src/index.ts
|
|
803
|
-
var SDK_VERSION = "0.
|
|
883
|
+
var SDK_VERSION = "0.11.0";
|
|
804
884
|
var DEFAULT_API_URL = "https://api.sendoracloud.com";
|
|
805
885
|
var ANON_KEY = "anon_id";
|
|
806
886
|
var USER_ID_KEY = "user_id";
|
|
@@ -816,15 +896,33 @@ var MAX_PUSH_METADATA_BYTES = 4096;
|
|
|
816
896
|
var MAX_USER_ID_LEN = 256;
|
|
817
897
|
var MAX_TRAITS_BYTES = 32 * 1024;
|
|
818
898
|
var MAX_PROPERTIES_BYTES = 32 * 1024;
|
|
899
|
+
function assertCSPRNG() {
|
|
900
|
+
const g = globalThis;
|
|
901
|
+
if (g.crypto?.getRandomValues) {
|
|
902
|
+
return g.crypto;
|
|
903
|
+
}
|
|
904
|
+
const msg = [
|
|
905
|
+
"[sendoracloud] crypto.getRandomValues is unavailable in this runtime.",
|
|
906
|
+
"",
|
|
907
|
+
"Sendora refuses to mint anonymous IDs from Math.random \u2014 predictable IDs",
|
|
908
|
+
"are a security floor regression, not a tradeoff.",
|
|
909
|
+
"",
|
|
910
|
+
"Fix (React Native + Expo):",
|
|
911
|
+
" 1) npm install react-native-get-random-values",
|
|
912
|
+
" 2) Add this as the FIRST line of your app's entry file (index.js or",
|
|
913
|
+
" index.tsx \u2014 before any other import including Sendora):",
|
|
914
|
+
"",
|
|
915
|
+
" import 'react-native-get-random-values';",
|
|
916
|
+
"",
|
|
917
|
+
" 3) Reload the JS bundle (full restart, not Fast Refresh).",
|
|
918
|
+
"",
|
|
919
|
+
"Docs: https://sendoracloud.com/docs/quickstart-react-native#csprng"
|
|
920
|
+
].join("\n");
|
|
921
|
+
throw new Error(msg);
|
|
922
|
+
}
|
|
819
923
|
function uuid() {
|
|
820
924
|
const bytes = new Uint8Array(16);
|
|
821
|
-
|
|
822
|
-
if (!g.crypto?.getRandomValues) {
|
|
823
|
-
throw new Error(
|
|
824
|
-
"[sendoracloud] crypto.getRandomValues unavailable \u2014 refusing to mint IDs without a CSPRNG. Upgrade Hermes / your RN runtime."
|
|
825
|
-
);
|
|
826
|
-
}
|
|
827
|
-
g.crypto.getRandomValues(bytes);
|
|
925
|
+
assertCSPRNG().getRandomValues(bytes);
|
|
828
926
|
bytes[6] = bytes[6] & 15 | 64;
|
|
829
927
|
bytes[8] = bytes[8] & 63 | 128;
|
|
830
928
|
const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
|
|
@@ -846,9 +944,12 @@ var SendoraSDK = class {
|
|
|
846
944
|
this.userId = null;
|
|
847
945
|
this.consentGranted = true;
|
|
848
946
|
this.debug = false;
|
|
947
|
+
this.sessionId = "";
|
|
948
|
+
this.autoTrackCleanup = null;
|
|
849
949
|
}
|
|
850
950
|
/** Initialize the SDK. Must be awaited at app startup before identify/track. */
|
|
851
951
|
async init(config) {
|
|
952
|
+
assertCSPRNG();
|
|
852
953
|
const rawKey = String(config.apiKey ?? config.publicKey ?? "").trim();
|
|
853
954
|
if (/^sk_/i.test(rawKey) || /sk_(prod|staging|dev|live|test)_/i.test(rawKey)) {
|
|
854
955
|
throw new Error(
|
|
@@ -919,6 +1020,20 @@ var SendoraSDK = class {
|
|
|
919
1020
|
});
|
|
920
1021
|
this.auth.hydrate();
|
|
921
1022
|
this.ready = true;
|
|
1023
|
+
if (config.autoTrack !== false) {
|
|
1024
|
+
this.autoTrackCleanup = installAutoTrack({
|
|
1025
|
+
config: this.config,
|
|
1026
|
+
fire: (eventType, properties) => this.track(eventType, properties),
|
|
1027
|
+
getSessionId: () => this.sessionId,
|
|
1028
|
+
setSessionId: (id) => {
|
|
1029
|
+
this.sessionId = id;
|
|
1030
|
+
}
|
|
1031
|
+
});
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
/** Current logical session id, if auto-track is enabled. */
|
|
1035
|
+
getSessionId() {
|
|
1036
|
+
return this.sessionId;
|
|
922
1037
|
}
|
|
923
1038
|
/** Stable anonymous identifier for this install — survives app restarts via AsyncStorage. */
|
|
924
1039
|
getAnonymousId() {
|
|
@@ -988,7 +1103,9 @@ var SendoraSDK = class {
|
|
|
988
1103
|
*/
|
|
989
1104
|
async registerPushToken(reg) {
|
|
990
1105
|
this.assertReady();
|
|
991
|
-
if (!this.config)
|
|
1106
|
+
if (!this.config) {
|
|
1107
|
+
throw new Error("[sendoracloud] init() must complete before registerPushToken().");
|
|
1108
|
+
}
|
|
992
1109
|
if (!reg.token || byteLength(reg.token) > MAX_PUSH_TOKEN_BYTES) {
|
|
993
1110
|
throw new Error("[sendoracloud] push token is empty or exceeds 4KB.");
|
|
994
1111
|
}
|
|
@@ -998,7 +1115,7 @@ var SendoraSDK = class {
|
|
|
998
1115
|
throw new Error("[sendoracloud] push token metadata exceeds 4KB.");
|
|
999
1116
|
}
|
|
1000
1117
|
}
|
|
1001
|
-
await post(
|
|
1118
|
+
const data = await post(
|
|
1002
1119
|
{ apiUrl: this.config.apiUrl, publicKey: this.config.publicKey, debug: this.debug },
|
|
1003
1120
|
"/api/v1/push/tokens",
|
|
1004
1121
|
{
|
|
@@ -1011,6 +1128,10 @@ var SendoraSDK = class {
|
|
|
1011
1128
|
metadata: reg.metadata ?? {}
|
|
1012
1129
|
}
|
|
1013
1130
|
);
|
|
1131
|
+
if (!data?.tokenId) {
|
|
1132
|
+
throw new Error("[sendoracloud] push token registered but server returned no tokenId \u2014 upgrade backend to >= s58.16.");
|
|
1133
|
+
}
|
|
1134
|
+
return { tokenId: data.tokenId, created: data.created === true };
|
|
1014
1135
|
}
|
|
1015
1136
|
/**
|
|
1016
1137
|
* Clear identity + rotate the anonymous id. Call on logout. Awaits
|
|
@@ -1020,9 +1141,17 @@ var SendoraSDK = class {
|
|
|
1020
1141
|
async reset() {
|
|
1021
1142
|
this.userId = null;
|
|
1022
1143
|
this.anonId = uuid();
|
|
1144
|
+
this.sessionId = "";
|
|
1023
1145
|
await this.storage.clearAll();
|
|
1024
1146
|
this.storage.set(ANON_KEY, this.anonId);
|
|
1025
1147
|
}
|
|
1148
|
+
/** Tear down auto-track listeners. Call in test harnesses. */
|
|
1149
|
+
destroy() {
|
|
1150
|
+
if (this.autoTrackCleanup) {
|
|
1151
|
+
this.autoTrackCleanup();
|
|
1152
|
+
this.autoTrackCleanup = null;
|
|
1153
|
+
}
|
|
1154
|
+
}
|
|
1026
1155
|
async sendEvent(kind, payload) {
|
|
1027
1156
|
if (!this.config) return;
|
|
1028
1157
|
if (!this.consentGranted) return;
|
package/dist/index.d.cts
CHANGED
|
@@ -309,6 +309,27 @@ interface SendoraConfig {
|
|
|
309
309
|
consentedByDefault?: boolean;
|
|
310
310
|
/** Verbose error logging (auth-error codes + Zod field errors). Default false. */
|
|
311
311
|
debug?: boolean;
|
|
312
|
+
/**
|
|
313
|
+
* Auto-collect lifecycle events. Mirrors Firebase Analytics' auto-collected
|
|
314
|
+
* surface. `true` = all enabled (default). Pass an object for granular
|
|
315
|
+
* control or `false` to disable.
|
|
316
|
+
*
|
|
317
|
+
* - `appOpen` — fire `app.opened` on init. Default true.
|
|
318
|
+
* - `sessionStart` — emit `session.start` once per logical session
|
|
319
|
+
* (idle >`sessionIdleMs` closes session). Default true.
|
|
320
|
+
* - `appBackground` — emit `app.backgrounded`/`app.foregrounded` on
|
|
321
|
+
* AppState transitions. Default true.
|
|
322
|
+
*
|
|
323
|
+
* Note: screen view tracking is NOT auto — RN has no canonical router.
|
|
324
|
+
* Call `SendoraCloud.screen(name)` from your navigation listener.
|
|
325
|
+
*/
|
|
326
|
+
autoTrack?: boolean | {
|
|
327
|
+
appOpen?: boolean;
|
|
328
|
+
sessionStart?: boolean;
|
|
329
|
+
appBackground?: boolean;
|
|
330
|
+
};
|
|
331
|
+
/** Idle threshold (ms) for session expiry; default 30 minutes. */
|
|
332
|
+
sessionIdleMs?: number;
|
|
312
333
|
}
|
|
313
334
|
type TraitValue = string | number | boolean | null | undefined;
|
|
314
335
|
interface IdentifyTraits {
|
|
@@ -324,11 +345,25 @@ type PushPlatform = "ios" | "android";
|
|
|
324
345
|
interface PushTokenRegistration {
|
|
325
346
|
token: string;
|
|
326
347
|
platform: PushPlatform;
|
|
327
|
-
/**
|
|
348
|
+
/**
|
|
349
|
+
* App bundle / package identifier, for fan-out to the right credentials.
|
|
350
|
+
* Optional but strongly recommended for multi-env apps so dev / staging
|
|
351
|
+
* tokens never get a prod APNs / FCM push.
|
|
352
|
+
*/
|
|
328
353
|
bundleId?: string;
|
|
329
354
|
/** Arbitrary device metadata stored alongside the token. */
|
|
330
355
|
metadata?: Record<string, string>;
|
|
331
356
|
}
|
|
357
|
+
/**
|
|
358
|
+
* Returned by `registerPushToken` so the caller can log a receipt or run
|
|
359
|
+
* a follow-up `testPush(userId)` against the freshly stored token. `created`
|
|
360
|
+
* distinguishes a brand-new token row from an idempotent re-registration of
|
|
361
|
+
* a token Sendora already had on file.
|
|
362
|
+
*/
|
|
363
|
+
interface PushTokenReceipt {
|
|
364
|
+
tokenId: string;
|
|
365
|
+
created: boolean;
|
|
366
|
+
}
|
|
332
367
|
|
|
333
368
|
/**
|
|
334
369
|
* @sendoracloud/sdk-react-native — React Native + Expo SDK.
|
|
@@ -363,10 +398,14 @@ declare class SendoraSDK {
|
|
|
363
398
|
private userId;
|
|
364
399
|
private consentGranted;
|
|
365
400
|
private debug;
|
|
401
|
+
private sessionId;
|
|
402
|
+
private autoTrackCleanup;
|
|
366
403
|
/** Lazy-initialised in init() once we know the apiUrl + publicKey. */
|
|
367
404
|
auth: Auth;
|
|
368
405
|
/** Initialize the SDK. Must be awaited at app startup before identify/track. */
|
|
369
406
|
init(config: SendoraConfig): Promise<void>;
|
|
407
|
+
/** Current logical session id, if auto-track is enabled. */
|
|
408
|
+
getSessionId(): string;
|
|
370
409
|
/** Stable anonymous identifier for this install — survives app restarts via AsyncStorage. */
|
|
371
410
|
getAnonymousId(): string;
|
|
372
411
|
/** Current identified user id, if any. */
|
|
@@ -385,16 +424,18 @@ declare class SendoraSDK {
|
|
|
385
424
|
* obtaining the token — typically via `expo-notifications` or
|
|
386
425
|
* `@react-native-firebase/messaging`.
|
|
387
426
|
*/
|
|
388
|
-
registerPushToken(reg: PushTokenRegistration): Promise<
|
|
427
|
+
registerPushToken(reg: PushTokenRegistration): Promise<PushTokenReceipt>;
|
|
389
428
|
/**
|
|
390
429
|
* Clear identity + rotate the anonymous id. Call on logout. Awaits
|
|
391
430
|
* the AsyncStorage writes so a caller who kills the app immediately
|
|
392
431
|
* after doesn't leave stale identity on disk.
|
|
393
432
|
*/
|
|
394
433
|
reset(): Promise<void>;
|
|
434
|
+
/** Tear down auto-track listeners. Call in test harnesses. */
|
|
435
|
+
destroy(): void;
|
|
395
436
|
private sendEvent;
|
|
396
437
|
private assertReady;
|
|
397
438
|
}
|
|
398
439
|
declare const SendoraCloud: SendoraSDK;
|
|
399
440
|
|
|
400
|
-
export { Auth, AuthError, type AuthTokens, type AuthUser, EmailAlreadyTakenError, type IdentifyTraits, type PushTokenRegistration, SendoraCloud, type SendoraConfig, type TrackProperties, SendoraCloud as default };
|
|
441
|
+
export { Auth, AuthError, type AuthTokens, type AuthUser, EmailAlreadyTakenError, type IdentifyTraits, type PushTokenReceipt, type PushTokenRegistration, SendoraCloud, type SendoraConfig, type TrackProperties, SendoraCloud as default };
|
package/dist/index.d.ts
CHANGED
|
@@ -309,6 +309,27 @@ interface SendoraConfig {
|
|
|
309
309
|
consentedByDefault?: boolean;
|
|
310
310
|
/** Verbose error logging (auth-error codes + Zod field errors). Default false. */
|
|
311
311
|
debug?: boolean;
|
|
312
|
+
/**
|
|
313
|
+
* Auto-collect lifecycle events. Mirrors Firebase Analytics' auto-collected
|
|
314
|
+
* surface. `true` = all enabled (default). Pass an object for granular
|
|
315
|
+
* control or `false` to disable.
|
|
316
|
+
*
|
|
317
|
+
* - `appOpen` — fire `app.opened` on init. Default true.
|
|
318
|
+
* - `sessionStart` — emit `session.start` once per logical session
|
|
319
|
+
* (idle >`sessionIdleMs` closes session). Default true.
|
|
320
|
+
* - `appBackground` — emit `app.backgrounded`/`app.foregrounded` on
|
|
321
|
+
* AppState transitions. Default true.
|
|
322
|
+
*
|
|
323
|
+
* Note: screen view tracking is NOT auto — RN has no canonical router.
|
|
324
|
+
* Call `SendoraCloud.screen(name)` from your navigation listener.
|
|
325
|
+
*/
|
|
326
|
+
autoTrack?: boolean | {
|
|
327
|
+
appOpen?: boolean;
|
|
328
|
+
sessionStart?: boolean;
|
|
329
|
+
appBackground?: boolean;
|
|
330
|
+
};
|
|
331
|
+
/** Idle threshold (ms) for session expiry; default 30 minutes. */
|
|
332
|
+
sessionIdleMs?: number;
|
|
312
333
|
}
|
|
313
334
|
type TraitValue = string | number | boolean | null | undefined;
|
|
314
335
|
interface IdentifyTraits {
|
|
@@ -324,11 +345,25 @@ type PushPlatform = "ios" | "android";
|
|
|
324
345
|
interface PushTokenRegistration {
|
|
325
346
|
token: string;
|
|
326
347
|
platform: PushPlatform;
|
|
327
|
-
/**
|
|
348
|
+
/**
|
|
349
|
+
* App bundle / package identifier, for fan-out to the right credentials.
|
|
350
|
+
* Optional but strongly recommended for multi-env apps so dev / staging
|
|
351
|
+
* tokens never get a prod APNs / FCM push.
|
|
352
|
+
*/
|
|
328
353
|
bundleId?: string;
|
|
329
354
|
/** Arbitrary device metadata stored alongside the token. */
|
|
330
355
|
metadata?: Record<string, string>;
|
|
331
356
|
}
|
|
357
|
+
/**
|
|
358
|
+
* Returned by `registerPushToken` so the caller can log a receipt or run
|
|
359
|
+
* a follow-up `testPush(userId)` against the freshly stored token. `created`
|
|
360
|
+
* distinguishes a brand-new token row from an idempotent re-registration of
|
|
361
|
+
* a token Sendora already had on file.
|
|
362
|
+
*/
|
|
363
|
+
interface PushTokenReceipt {
|
|
364
|
+
tokenId: string;
|
|
365
|
+
created: boolean;
|
|
366
|
+
}
|
|
332
367
|
|
|
333
368
|
/**
|
|
334
369
|
* @sendoracloud/sdk-react-native — React Native + Expo SDK.
|
|
@@ -363,10 +398,14 @@ declare class SendoraSDK {
|
|
|
363
398
|
private userId;
|
|
364
399
|
private consentGranted;
|
|
365
400
|
private debug;
|
|
401
|
+
private sessionId;
|
|
402
|
+
private autoTrackCleanup;
|
|
366
403
|
/** Lazy-initialised in init() once we know the apiUrl + publicKey. */
|
|
367
404
|
auth: Auth;
|
|
368
405
|
/** Initialize the SDK. Must be awaited at app startup before identify/track. */
|
|
369
406
|
init(config: SendoraConfig): Promise<void>;
|
|
407
|
+
/** Current logical session id, if auto-track is enabled. */
|
|
408
|
+
getSessionId(): string;
|
|
370
409
|
/** Stable anonymous identifier for this install — survives app restarts via AsyncStorage. */
|
|
371
410
|
getAnonymousId(): string;
|
|
372
411
|
/** Current identified user id, if any. */
|
|
@@ -385,16 +424,18 @@ declare class SendoraSDK {
|
|
|
385
424
|
* obtaining the token — typically via `expo-notifications` or
|
|
386
425
|
* `@react-native-firebase/messaging`.
|
|
387
426
|
*/
|
|
388
|
-
registerPushToken(reg: PushTokenRegistration): Promise<
|
|
427
|
+
registerPushToken(reg: PushTokenRegistration): Promise<PushTokenReceipt>;
|
|
389
428
|
/**
|
|
390
429
|
* Clear identity + rotate the anonymous id. Call on logout. Awaits
|
|
391
430
|
* the AsyncStorage writes so a caller who kills the app immediately
|
|
392
431
|
* after doesn't leave stale identity on disk.
|
|
393
432
|
*/
|
|
394
433
|
reset(): Promise<void>;
|
|
434
|
+
/** Tear down auto-track listeners. Call in test harnesses. */
|
|
435
|
+
destroy(): void;
|
|
395
436
|
private sendEvent;
|
|
396
437
|
private assertReady;
|
|
397
438
|
}
|
|
398
439
|
declare const SendoraCloud: SendoraSDK;
|
|
399
440
|
|
|
400
|
-
export { Auth, AuthError, type AuthTokens, type AuthUser, EmailAlreadyTakenError, type IdentifyTraits, type PushTokenRegistration, SendoraCloud, type SendoraConfig, type TrackProperties, SendoraCloud as default };
|
|
441
|
+
export { Auth, AuthError, type AuthTokens, type AuthUser, EmailAlreadyTakenError, type IdentifyTraits, type PushTokenReceipt, type PushTokenRegistration, SendoraCloud, type SendoraConfig, type TrackProperties, SendoraCloud as default };
|
package/dist/index.js
CHANGED
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
|
|
2
|
+
get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
|
|
3
|
+
}) : x)(function(x) {
|
|
4
|
+
if (typeof require !== "undefined") return require.apply(this, arguments);
|
|
5
|
+
throw Error('Dynamic require of "' + x + '" is not supported');
|
|
6
|
+
});
|
|
7
|
+
|
|
1
8
|
// src/storage.ts
|
|
2
9
|
var PREFIX = "sendora_";
|
|
3
10
|
var asyncStoragePromise = null;
|
|
@@ -759,8 +766,88 @@ function decodeJwtPayload(token) {
|
|
|
759
766
|
}
|
|
760
767
|
}
|
|
761
768
|
|
|
769
|
+
// src/auto-track.ts
|
|
770
|
+
var DEFAULT_IDLE_MS = 30 * 60 * 1e3;
|
|
771
|
+
function resolveFlags(cfg) {
|
|
772
|
+
if (cfg === false) {
|
|
773
|
+
return { appOpen: false, sessionStart: false, appBackground: false };
|
|
774
|
+
}
|
|
775
|
+
if (cfg === true || cfg === void 0) {
|
|
776
|
+
return { appOpen: true, sessionStart: true, appBackground: true };
|
|
777
|
+
}
|
|
778
|
+
return {
|
|
779
|
+
appOpen: cfg.appOpen ?? true,
|
|
780
|
+
sessionStart: cfg.sessionStart ?? true,
|
|
781
|
+
appBackground: cfg.appBackground ?? true
|
|
782
|
+
};
|
|
783
|
+
}
|
|
784
|
+
function installAutoTrack(args) {
|
|
785
|
+
const flags = resolveFlags(args.config.autoTrack);
|
|
786
|
+
const idleMs = args.config.sessionIdleMs ?? DEFAULT_IDLE_MS;
|
|
787
|
+
let lastActivity = Date.now();
|
|
788
|
+
const cleanups = [];
|
|
789
|
+
function newSessionId() {
|
|
790
|
+
return `sess_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`;
|
|
791
|
+
}
|
|
792
|
+
function ensureSession(emitStart) {
|
|
793
|
+
const now = Date.now();
|
|
794
|
+
let id = args.getSessionId();
|
|
795
|
+
if (!id || now - lastActivity > idleMs) {
|
|
796
|
+
id = newSessionId();
|
|
797
|
+
args.setSessionId(id);
|
|
798
|
+
if (emitStart && flags.sessionStart) {
|
|
799
|
+
args.fire("session.start", { sessionId: id });
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
lastActivity = now;
|
|
803
|
+
return id;
|
|
804
|
+
}
|
|
805
|
+
const sessionId = ensureSession(
|
|
806
|
+
/* emitStart */
|
|
807
|
+
flags.sessionStart
|
|
808
|
+
);
|
|
809
|
+
if (flags.appOpen) {
|
|
810
|
+
args.fire("app.opened", { sessionId });
|
|
811
|
+
}
|
|
812
|
+
if (flags.appBackground) {
|
|
813
|
+
let AppState = null;
|
|
814
|
+
try {
|
|
815
|
+
AppState = __require("react-native").AppState;
|
|
816
|
+
} catch {
|
|
817
|
+
AppState = null;
|
|
818
|
+
}
|
|
819
|
+
if (AppState && typeof AppState.addEventListener === "function") {
|
|
820
|
+
const handler = (state) => {
|
|
821
|
+
ensureSession(true);
|
|
822
|
+
if (state === "background" || state === "inactive") {
|
|
823
|
+
args.fire("app.backgrounded", { sessionId: args.getSessionId() });
|
|
824
|
+
} else if (state === "active") {
|
|
825
|
+
args.fire("app.foregrounded", { sessionId: args.getSessionId() });
|
|
826
|
+
}
|
|
827
|
+
};
|
|
828
|
+
const sub = AppState.addEventListener("change", handler);
|
|
829
|
+
cleanups.push(() => {
|
|
830
|
+
if (typeof sub === "function") {
|
|
831
|
+
return;
|
|
832
|
+
}
|
|
833
|
+
if (sub && typeof sub.remove === "function") {
|
|
834
|
+
sub.remove();
|
|
835
|
+
}
|
|
836
|
+
});
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
return () => {
|
|
840
|
+
for (const fn of cleanups) {
|
|
841
|
+
try {
|
|
842
|
+
fn();
|
|
843
|
+
} catch {
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
};
|
|
847
|
+
}
|
|
848
|
+
|
|
762
849
|
// src/index.ts
|
|
763
|
-
var SDK_VERSION = "0.
|
|
850
|
+
var SDK_VERSION = "0.11.0";
|
|
764
851
|
var DEFAULT_API_URL = "https://api.sendoracloud.com";
|
|
765
852
|
var ANON_KEY = "anon_id";
|
|
766
853
|
var USER_ID_KEY = "user_id";
|
|
@@ -776,15 +863,33 @@ var MAX_PUSH_METADATA_BYTES = 4096;
|
|
|
776
863
|
var MAX_USER_ID_LEN = 256;
|
|
777
864
|
var MAX_TRAITS_BYTES = 32 * 1024;
|
|
778
865
|
var MAX_PROPERTIES_BYTES = 32 * 1024;
|
|
866
|
+
function assertCSPRNG() {
|
|
867
|
+
const g = globalThis;
|
|
868
|
+
if (g.crypto?.getRandomValues) {
|
|
869
|
+
return g.crypto;
|
|
870
|
+
}
|
|
871
|
+
const msg = [
|
|
872
|
+
"[sendoracloud] crypto.getRandomValues is unavailable in this runtime.",
|
|
873
|
+
"",
|
|
874
|
+
"Sendora refuses to mint anonymous IDs from Math.random \u2014 predictable IDs",
|
|
875
|
+
"are a security floor regression, not a tradeoff.",
|
|
876
|
+
"",
|
|
877
|
+
"Fix (React Native + Expo):",
|
|
878
|
+
" 1) npm install react-native-get-random-values",
|
|
879
|
+
" 2) Add this as the FIRST line of your app's entry file (index.js or",
|
|
880
|
+
" index.tsx \u2014 before any other import including Sendora):",
|
|
881
|
+
"",
|
|
882
|
+
" import 'react-native-get-random-values';",
|
|
883
|
+
"",
|
|
884
|
+
" 3) Reload the JS bundle (full restart, not Fast Refresh).",
|
|
885
|
+
"",
|
|
886
|
+
"Docs: https://sendoracloud.com/docs/quickstart-react-native#csprng"
|
|
887
|
+
].join("\n");
|
|
888
|
+
throw new Error(msg);
|
|
889
|
+
}
|
|
779
890
|
function uuid() {
|
|
780
891
|
const bytes = new Uint8Array(16);
|
|
781
|
-
|
|
782
|
-
if (!g.crypto?.getRandomValues) {
|
|
783
|
-
throw new Error(
|
|
784
|
-
"[sendoracloud] crypto.getRandomValues unavailable \u2014 refusing to mint IDs without a CSPRNG. Upgrade Hermes / your RN runtime."
|
|
785
|
-
);
|
|
786
|
-
}
|
|
787
|
-
g.crypto.getRandomValues(bytes);
|
|
892
|
+
assertCSPRNG().getRandomValues(bytes);
|
|
788
893
|
bytes[6] = bytes[6] & 15 | 64;
|
|
789
894
|
bytes[8] = bytes[8] & 63 | 128;
|
|
790
895
|
const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
|
|
@@ -806,9 +911,12 @@ var SendoraSDK = class {
|
|
|
806
911
|
this.userId = null;
|
|
807
912
|
this.consentGranted = true;
|
|
808
913
|
this.debug = false;
|
|
914
|
+
this.sessionId = "";
|
|
915
|
+
this.autoTrackCleanup = null;
|
|
809
916
|
}
|
|
810
917
|
/** Initialize the SDK. Must be awaited at app startup before identify/track. */
|
|
811
918
|
async init(config) {
|
|
919
|
+
assertCSPRNG();
|
|
812
920
|
const rawKey = String(config.apiKey ?? config.publicKey ?? "").trim();
|
|
813
921
|
if (/^sk_/i.test(rawKey) || /sk_(prod|staging|dev|live|test)_/i.test(rawKey)) {
|
|
814
922
|
throw new Error(
|
|
@@ -879,6 +987,20 @@ var SendoraSDK = class {
|
|
|
879
987
|
});
|
|
880
988
|
this.auth.hydrate();
|
|
881
989
|
this.ready = true;
|
|
990
|
+
if (config.autoTrack !== false) {
|
|
991
|
+
this.autoTrackCleanup = installAutoTrack({
|
|
992
|
+
config: this.config,
|
|
993
|
+
fire: (eventType, properties) => this.track(eventType, properties),
|
|
994
|
+
getSessionId: () => this.sessionId,
|
|
995
|
+
setSessionId: (id) => {
|
|
996
|
+
this.sessionId = id;
|
|
997
|
+
}
|
|
998
|
+
});
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
/** Current logical session id, if auto-track is enabled. */
|
|
1002
|
+
getSessionId() {
|
|
1003
|
+
return this.sessionId;
|
|
882
1004
|
}
|
|
883
1005
|
/** Stable anonymous identifier for this install — survives app restarts via AsyncStorage. */
|
|
884
1006
|
getAnonymousId() {
|
|
@@ -948,7 +1070,9 @@ var SendoraSDK = class {
|
|
|
948
1070
|
*/
|
|
949
1071
|
async registerPushToken(reg) {
|
|
950
1072
|
this.assertReady();
|
|
951
|
-
if (!this.config)
|
|
1073
|
+
if (!this.config) {
|
|
1074
|
+
throw new Error("[sendoracloud] init() must complete before registerPushToken().");
|
|
1075
|
+
}
|
|
952
1076
|
if (!reg.token || byteLength(reg.token) > MAX_PUSH_TOKEN_BYTES) {
|
|
953
1077
|
throw new Error("[sendoracloud] push token is empty or exceeds 4KB.");
|
|
954
1078
|
}
|
|
@@ -958,7 +1082,7 @@ var SendoraSDK = class {
|
|
|
958
1082
|
throw new Error("[sendoracloud] push token metadata exceeds 4KB.");
|
|
959
1083
|
}
|
|
960
1084
|
}
|
|
961
|
-
await post(
|
|
1085
|
+
const data = await post(
|
|
962
1086
|
{ apiUrl: this.config.apiUrl, publicKey: this.config.publicKey, debug: this.debug },
|
|
963
1087
|
"/api/v1/push/tokens",
|
|
964
1088
|
{
|
|
@@ -971,6 +1095,10 @@ var SendoraSDK = class {
|
|
|
971
1095
|
metadata: reg.metadata ?? {}
|
|
972
1096
|
}
|
|
973
1097
|
);
|
|
1098
|
+
if (!data?.tokenId) {
|
|
1099
|
+
throw new Error("[sendoracloud] push token registered but server returned no tokenId \u2014 upgrade backend to >= s58.16.");
|
|
1100
|
+
}
|
|
1101
|
+
return { tokenId: data.tokenId, created: data.created === true };
|
|
974
1102
|
}
|
|
975
1103
|
/**
|
|
976
1104
|
* Clear identity + rotate the anonymous id. Call on logout. Awaits
|
|
@@ -980,9 +1108,17 @@ var SendoraSDK = class {
|
|
|
980
1108
|
async reset() {
|
|
981
1109
|
this.userId = null;
|
|
982
1110
|
this.anonId = uuid();
|
|
1111
|
+
this.sessionId = "";
|
|
983
1112
|
await this.storage.clearAll();
|
|
984
1113
|
this.storage.set(ANON_KEY, this.anonId);
|
|
985
1114
|
}
|
|
1115
|
+
/** Tear down auto-track listeners. Call in test harnesses. */
|
|
1116
|
+
destroy() {
|
|
1117
|
+
if (this.autoTrackCleanup) {
|
|
1118
|
+
this.autoTrackCleanup();
|
|
1119
|
+
this.autoTrackCleanup = null;
|
|
1120
|
+
}
|
|
1121
|
+
}
|
|
986
1122
|
async sendEvent(kind, payload) {
|
|
987
1123
|
if (!this.config) return;
|
|
988
1124
|
if (!this.consentGranted) return;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sendoracloud/sdk-react-native",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.0",
|
|
4
4
|
"description": "Sendora Cloud React Native + Expo SDK — analytics, identity, push-token registration, auth. Expo Go compatible, no native modules beyond AsyncStorage.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.cjs",
|