@akhilpulse/samadhaan-session-replay 0.3.5 → 0.3.7

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 CHANGED
@@ -27,7 +27,11 @@ export default function App() {
27
27
  config={{
28
28
  // serverUrl is optional — defaults to https://samadhaan.pulseenergy.io
29
29
  appId: "app_xxx",
30
- user: { userId: "user_123", email: "user@example.com" },
30
+ // `user` can be omitted at first (anonymous) and supplied later once the
31
+ // user logs in. Update the value passed to `config.user` after OTP login
32
+ // and the SDK automatically backfills the session with the phone/name/
33
+ // email — so the dashboard stops showing "Anonymous".
34
+ user: { userId: "user_123", phone: "+91xxxxxxxxxx", email: "user@example.com" },
31
35
  appVersion: "1.4.2",
32
36
  }}
33
37
  >
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akhilpulse/samadhaan-session-replay",
3
- "version": "0.3.5",
3
+ "version": "0.3.7",
4
4
  "description": "React Native SDK for Samadhaan Guided Support — session recording, live remote screen-share, take-control, and guided-tour overlays.",
5
5
  "main": "src/index.ts",
6
6
  "module": "src/index.ts",
@@ -47,7 +47,19 @@ const SessionReplayPixelCopy: PixelCopyNative | undefined =
47
47
  let pixelCopyDisabled = false; // turned true after a runtime failure to avoid retrying every frame
48
48
 
49
49
  const STORAGE_KEY = "@akhilpulse/samadhaan-session-replay/sessionId";
50
- const SDK_VERSION = "0.3.0";
50
+ const SDK_VERSION = "0.3.7";
51
+
52
+ // ---- Module-level session singleton --------------------------------------
53
+ // Multiple SessionReplayProvider mounts (intentional or via React StrictMode's
54
+ // double-invoke in dev) used to each race to POST /sessions before any of
55
+ // them could persist the sessionId, producing 2-4 duplicate sessions on
56
+ // startup. We now share a single in-flight init promise across every
57
+ // Provider instance in the JS runtime so they all resolve to the same
58
+ // session.
59
+ type SessionInitResult = { sessionId: string; ingestToken: string; status: string } | null;
60
+ let _activeSession: SessionInitResult = null;
61
+ let _initPromise: Promise<SessionInitResult> | null = null;
62
+ let _providerMountCount = 0;
51
63
 
52
64
  /** Default Samadhaan production endpoint. Override via `config.serverUrl` for staging. */
53
65
  export const DEFAULT_SERVER_URL = "https://samadhaan.pulseenergy.io";
@@ -138,26 +150,47 @@ export function SessionReplayProvider({ config, children }: { config: Config; ch
138
150
  // ---- Session init / resume ----
139
151
  // initTick is bumped whenever we recycle (sessionId went to null at runtime) so this effect re-fires.
140
152
  const [initTick, setInitTick] = useState(0);
153
+
154
+ // Mount-count singleton warning: if the host app accidentally renders the
155
+ // provider in two places, we still only init once (thanks to _initPromise),
156
+ // but we surface a warning so the developer knows.
157
+ useEffect(() => {
158
+ _providerMountCount += 1;
159
+ if (_providerMountCount > 1 && __DEV__) {
160
+ console.warn(
161
+ `[session-replay] SessionReplayProvider is mounted ${_providerMountCount} times. ` +
162
+ `Render it exactly once at the root of your app — the SDK will reuse a single session, ` +
163
+ `but multiple mounts waste resources.`
164
+ );
165
+ }
166
+ return () => { _providerMountCount = Math.max(0, _providerMountCount - 1); };
167
+ }, []);
168
+
141
169
  useEffect(() => {
142
170
  let cancelled = false;
143
- (async () => {
171
+
172
+ // If another Provider instance (or a previous run of this same effect
173
+ // under StrictMode) already created a session, just adopt it. No new POST.
174
+ if (_activeSession) {
175
+ setSessionId(_activeSession.sessionId);
176
+ setIngestToken(_activeSession.ingestToken);
177
+ setStatus((_activeSession.status as Ctx["status"]) || "active");
178
+ return;
179
+ }
180
+
181
+ const doInit = async (): Promise<SessionInitResult> => {
144
182
  try {
145
183
  const stored = await AsyncStorage.getItem(STORAGE_KEY);
146
- if (stored && !cancelled) {
147
- // resume via PUBLIC status endpoint (no auth needed) — also returns the ingest token
184
+ if (stored) {
148
185
  const r = await fetch(`${serverUrl}/api/guided-support/sessions/${stored}/status`);
149
186
  if (r.ok) {
150
187
  const j = await r.json();
151
- // If the stored session was already ended (server-side idle timeout while app
152
- // was backgrounded), drop it and fall through to create a fresh one.
153
188
  if (j.status === "ended") {
154
189
  try { await AsyncStorage.removeItem(STORAGE_KEY); } catch {}
155
190
  } else {
156
- if (!cancelled) { setSessionId(stored); setStatus(j.status || "active"); setIngestToken(j.ingestToken); }
157
- return;
191
+ return { sessionId: stored, ingestToken: j.ingestToken, status: j.status || "active" };
158
192
  }
159
193
  } else {
160
- // 404 / not found — clear stale id and create fresh.
161
194
  try { await AsyncStorage.removeItem(STORAGE_KEY); } catch {}
162
195
  }
163
196
  }
@@ -169,26 +202,81 @@ export function SessionReplayProvider({ config, children }: { config: Config; ch
169
202
  user: config.user,
170
203
  appVersion: config.appVersion,
171
204
  sdkVersion: SDK_VERSION,
172
- deviceInfo: {
173
- os: Platform.OS,
174
- osVersion: String(Platform.Version),
175
- },
205
+ deviceInfo: { os: Platform.OS, osVersion: String(Platform.Version) },
176
206
  }),
177
207
  });
178
208
  const j = await res.json();
179
- if (!cancelled && j.sessionId) {
180
- setSessionId(j.sessionId);
181
- setIngestToken(j.ingestToken);
182
- setStatus("active");
209
+ if (j.sessionId) {
183
210
  await AsyncStorage.setItem(STORAGE_KEY, j.sessionId);
211
+ return { sessionId: j.sessionId, ingestToken: j.ingestToken, status: "active" };
184
212
  }
185
- } catch (e) {
186
- // best-effort; SDK is non-blocking
213
+ return null;
214
+ } catch {
215
+ return null;
187
216
  }
188
- })();
217
+ };
218
+
219
+ // Coalesce concurrent inits into a single in-flight promise. Any later
220
+ // mount that arrives while this is pending will await the same promise
221
+ // instead of issuing its own POST.
222
+ if (!_initPromise) {
223
+ _initPromise = doInit().then((result) => {
224
+ _activeSession = result;
225
+ return result;
226
+ }).finally(() => {
227
+ // Clear the promise once settled so a later recycle (initTick bump
228
+ // after the server idle-ends the session) can re-init.
229
+ _initPromise = null;
230
+ });
231
+ }
232
+
233
+ _initPromise.then((result) => {
234
+ if (cancelled || !result) return;
235
+ setSessionId(result.sessionId);
236
+ setIngestToken(result.ingestToken);
237
+ setStatus((result.status as Ctx["status"]) || "active");
238
+ });
239
+
189
240
  return () => { cancelled = true; };
190
241
  }, [config.appId, serverUrl, config.appVersion, config.user, initTick]);
191
242
 
243
+ // ---- Identify: backfill user identity after login ----
244
+ // A session is usually created while the user is still anonymous (e.g. before
245
+ // OTP login). Once the host app supplies user info via config.user, we PATCH
246
+ // the existing session so the dashboard shows the real phone/name/email
247
+ // instead of "Anonymous". We dedupe on the serialized identity so this only
248
+ // fires when the identity actually changes, and retry on failure.
249
+ const lastIdentitySentRef = useRef<string | null>(null);
250
+ const lastIdentitySessionRef = useRef<string | null>(null);
251
+ useEffect(() => {
252
+ if (!sessionId || !ingestToken) return;
253
+ // A recycled session (server idle-end -> new session) must re-send identity,
254
+ // so reset the dedupe key whenever the session id changes.
255
+ if (lastIdentitySessionRef.current !== sessionId) {
256
+ lastIdentitySessionRef.current = sessionId;
257
+ lastIdentitySentRef.current = null;
258
+ }
259
+ const u = config.user;
260
+ if (!u) return;
261
+ const hasIdentity = !!(u.userId || u.userName || u.email || u.phone);
262
+ if (!hasIdentity) return;
263
+ const key = JSON.stringify({ userId: u.userId, userName: u.userName, email: u.email, phone: u.phone, metadata: u.metadata });
264
+ if (lastIdentitySentRef.current === key) return;
265
+ lastIdentitySentRef.current = key;
266
+ fetch(`${serverUrl}/api/guided-support/sessions/${sessionId}`, {
267
+ method: "PATCH",
268
+ headers: { "Content-Type": "application/json", "x-ingest-token": ingestToken },
269
+ body: JSON.stringify({ user: u }),
270
+ }).then((res) => {
271
+ // Treat non-2xx (e.g. transient 401/5xx) as failure so we retry instead
272
+ // of permanently deduping on an identity the server never stored.
273
+ if (!res.ok) lastIdentitySentRef.current = null;
274
+ }).catch(() => {
275
+ // allow retry on the next render/identity change
276
+ lastIdentitySentRef.current = null;
277
+ });
278
+ }, [sessionId, ingestToken, serverUrl, config.user]);
279
+
192
280
  // ---- Keyboard tracking (iOS auto-pause) ----
193
281
  useEffect(() => {
194
282
  const showSub = Keyboard.addListener(Platform.OS === "ios" ? "keyboardWillShow" : "keyboardDidShow", () => setKeyboardVisible(true));