@lucida-analytics/react-native 0.1.0 → 0.1.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.d.ts CHANGED
@@ -37,6 +37,8 @@ interface AsyncStorageLike {
37
37
  multiGet(keys: readonly string[]): Promise<ReadonlyArray<readonly [string, string | null]>>;
38
38
  setItem(key: string, value: string): Promise<void>;
39
39
  removeItem(key: string): Promise<void>;
40
+ /** The real module has it; used to find spilled replay chunks (`lc_replay.<id>`) to hydrate. Without it they are not reloaded after a restart. */
41
+ getAllKeys?(): Promise<readonly string[]>;
40
42
  }
41
43
  /** Keys the SDK persists; hydrated up front because the core's Storage port is synchronous. */
42
44
  declare const PERSISTED_KEYS: readonly ["lc_identity", "lc_session", "lc_props", "lc_groups", "lc_consent", "lc_flags", "lc_offline", "lc_app_version", "lc_guide_seen", "lc_person_identified"];
@@ -72,8 +74,8 @@ interface QueueCipher {
72
74
  /** null when the value cannot be decrypted (key rotated, corrupt): the queue then starts empty. */
73
75
  decrypt(cipher: string): string | null;
74
76
  }
75
- /** Decorator: values under `keys` are stored encrypted, everything else passes through. */
76
- declare function encryptedStorage(inner: Storage, cipher: QueueCipher, keys: readonly string[]): Storage;
77
+ /** Decorator: values under `keys` (or a key starting with one of `prefixes`) are stored encrypted, everything else passes through. */
78
+ declare function encryptedStorage(inner: Storage, cipher: QueueCipher, keys: readonly string[], prefixes?: readonly string[]): Storage;
77
79
 
78
80
  interface RageTapConfig {
79
81
  enabled?: boolean;
@@ -315,6 +317,17 @@ interface ReplayConfig {
315
317
  throttleMs?: number;
316
318
  /** Hold chunks until the session has been recorded this long; a session that ends sooner (or `stop()`) is never sent. Default 0 (off). */
317
319
  minDurationMs?: number;
320
+ /**
321
+ * Disk budget for chunks that wait (min duration, offline, upload backoff); over it the oldest whole session is dropped
322
+ * and `onSpoolDrop` fires. Default 10 MiB. Needs `asyncStorage` to survive a restart.
323
+ */
324
+ spoolBudgetBytes?: number;
325
+ /** A whole session was dropped because the spool exceeded its budget (also logged in debug mode). */
326
+ onSpoolDrop?: (info: {
327
+ sessionId: string;
328
+ chunks: number;
329
+ bytes: number;
330
+ }) => void;
318
331
  /** Fetch `GET /v1/config` and honour the project kill switch, sample rate and minimum duration (tightening only). Default true. */
319
332
  useRemoteConfig?: boolean;
320
333
  /** Start recording only while this flag is on. */
@@ -352,6 +365,8 @@ interface ReplayService {
352
365
  setScreen(name: string): void;
353
366
  /** A tap at screen point (x, y); ignored without consent, before the first snapshot, while paused or on a gated screen. */
354
367
  tap(x: number, y: number): void;
368
+ /** Erase every spilled chunk (opt-out, reset). */
369
+ clearSpool(): void;
355
370
  /** One capture pass (the interval calls this); exposed for tests. */
356
371
  tick(): Promise<void>;
357
372
  /** P-M46: a swipe passed the touch slop; its start structure is captured now. */
@@ -363,11 +378,14 @@ interface ReplayService {
363
378
  }
364
379
  interface ReplayDeps {
365
380
  core: Core;
381
+ /** Where waiting chunks are spilled (the client's storage, encrypted with the queue cipher when one is set). */
382
+ storage: Storage;
366
383
  fetch: typeof fetch;
367
384
  apiHost: string;
368
385
  apiKey: string;
369
386
  config: ReplayConfig;
370
387
  now?: () => number;
388
+ log?: (...a: unknown[]) => void;
371
389
  /** Reads `config.linkedFlag`. */
372
390
  isFlagOn?: (key: string) => boolean;
373
391
  }
@@ -577,7 +595,7 @@ interface NativeConfig {
577
595
  }
578
596
 
579
597
  declare const LIB = "lucida-react-native";
580
- declare const LIB_VERSION = "0.0.0";
598
+ declare const LIB_VERSION = "0.1.1";
581
599
  /**
582
600
  * Hydrates storage, then builds the client. Nothing is persisted before consent in opt_in mode;
583
601
  * `$app_opened` fires on creation and on every foreground, `$app_backgrounded` flushes the queue.
@@ -640,6 +658,42 @@ declare function withBaggage(existing: string | null | undefined, sessionId: str
640
658
  /** A fetch that adds the tracing headers when the target host matches; everything else passes through untouched. */
641
659
  declare function tracedFetch(fetchImpl: typeof fetch, headersFor: (url: string, baggage?: string | null) => Record<string, string>): typeof fetch;
642
660
 
661
+ interface SpoolEntry {
662
+ id: number;
663
+ sid: string;
664
+ seq: number;
665
+ /** Waiting for the minimum session duration (dropped if the session ends first); false = waiting for upload. */
666
+ held: boolean;
667
+ /** Body size in bytes. */
668
+ n: number;
669
+ /** Number of parts the body is split into (`lc_replay.<id>.<i>`). */
670
+ p: number;
671
+ /** Body length in UTF-16 units: a torn or truncated body no longer adds up and is discarded. */
672
+ len: number;
673
+ }
674
+ /**
675
+ * Durable, ordered spill for replay chunks that cannot be sent yet (min duration, offline, upload backoff).
676
+ * `lc_replay` is the index (JSON list of entries), `lc_replay.<id>.<i>` holds part i of one chunk body (no api key), split
677
+ * into parts under 1 MB. Write order: `lc_replay.pending` names the entry in flight, then its parts, then the index, then
678
+ * pending is cleared; a crash in between leaves parts the index never lists, which load deletes. Pass a storage
679
+ * wrapped with `encryptedStorage(..., [SPOOL_KEY], [`${SPOOL_KEY}.`])` to encrypt at rest. Over `budget` bytes the oldest
680
+ * whole session is dropped (never single chunks, so a kept session has no hole) and `onDrop` reports it.
681
+ */
682
+ declare function createReplaySpool(storage: Storage, budget?: number, onDrop?: (sid: string, chunks: number, bytes: number) => void): {
683
+ entries: () => readonly SpoolEntry[];
684
+ totalBytes: () => number;
685
+ add(sid: string, seq: number, held: boolean, body: string): void;
686
+ /** The whole body, or null when a part is missing or the length does not add up (torn write): the caller discards it. */
687
+ read(e: SpoolEntry): string | null;
688
+ remove(e: SpoolEntry): void;
689
+ /** Held chunks of every session except `keep`; returned so the caller can give their seq numbers back. */
690
+ removeHeld(keep?: string): SpoolEntry[];
691
+ /** The session reached its minimum duration: its held chunks become plain pending uploads. */
692
+ release(sid: string): void;
693
+ nextSeq: (sid: string) => number;
694
+ clear(): void;
695
+ };
696
+
643
697
  /**
644
698
  * P-M17 masked screens and P-M46 text patterns. The native encoders apply both (they receive `maskScreen` and
645
699
  * `maskTextPatterns` with every capture, screenshots included); [maskSnapshotText] re-applies the patterns to the
@@ -781,4 +835,4 @@ interface FeatureGateProps {
781
835
  }
782
836
  declare function FeatureGate({ flag, variant, fallback, children }: FeatureGateProps): react.JSX.Element;
783
837
 
784
- export { type AppStateLike, type AppStateStatus, type AsyncStorageLike, type Autocapture, type AutocaptureConfig, type Bootstrap, type CaptureOptions, DEAD_SWIPE_MIN_DISTANCE, type DeadSwipeInput, type ExposureKey, FeatureGate, type FeatureGateProps, type FiberLike, type FlagOptions, type Flags, GuideAnchor, type GuideAnchorSpec, type GuideButton, type GuideStep, type GuideTokens, type GuideView, type GuidesDeps, type GuidesService, type InstallInfo, LIB, LIB_VERSION, LucidaGuides, LucidaMask, LucidaProvider, type LucidaProviderProps, LucidaTouchCapture, LucidaUnmask, type Measurable, type MobileGuide, type NativeCipherModule, type NativeClient, type NativeConfig, type NativeInstallModule, type NativeReplayModule, type NativeSensitiveModule, type NavigationRefLike, PERSISTED_KEYS, PersonMode, type QueueCipher, REPLAY_MARKERS, type RageTapConfig, type ReplayConfig, type ReplayControl, type ReplayService, SENSITIVE_MAX_PLAIN_BYTES, type SensitiveCapture, type SwipeDirection, type TapElement, type TouchEventLike, appLifecycle, appStoreInstallProps, asyncStorageAdapter, bubbleOrigin, chainFromFiber, compilePatterns, createAutocapture, createClient, createGuides, createReplay, createSensitiveCapture, deadSwipeDecision, effectiveMinDurationMs, effectiveSampleRate, encryptSensitive, encryptedStorage, exposureKeyOf, fetchTransport, guideAnchor, guideButtons, guideTokens, installReferrerProps, maskPatterns, maskSnapshotText, matches as matchGuideScreen, measureAnchor, memoryStorage, nativeQueueCipher, registerAnchor, replayMarker, resolveInstallProps, runHooks, sampleHook, screenAllowed, screenExcluded, screenMasked, screenTracker, splitBatches, swipeDirection, touchHandler, tracedFetch, tracingHeaders, truncateUtf8, useActiveFeatureFlags, useCapture, useFeatureFlag, useFeatureFlagPayload, useFeatureFlagVariant, useLucida, useReplay, useScreen, withBaggage, withTimeout };
838
+ export { type AppStateLike, type AppStateStatus, type AsyncStorageLike, type Autocapture, type AutocaptureConfig, type Bootstrap, type CaptureOptions, DEAD_SWIPE_MIN_DISTANCE, type DeadSwipeInput, type ExposureKey, FeatureGate, type FeatureGateProps, type FiberLike, type FlagOptions, type Flags, GuideAnchor, type GuideAnchorSpec, type GuideButton, type GuideStep, type GuideTokens, type GuideView, type GuidesDeps, type GuidesService, type InstallInfo, LIB, LIB_VERSION, LucidaGuides, LucidaMask, LucidaProvider, type LucidaProviderProps, LucidaTouchCapture, LucidaUnmask, type Measurable, type MobileGuide, type NativeCipherModule, type NativeClient, type NativeConfig, type NativeInstallModule, type NativeReplayModule, type NativeSensitiveModule, type NavigationRefLike, PERSISTED_KEYS, PersonMode, type QueueCipher, REPLAY_MARKERS, type RageTapConfig, type ReplayConfig, type ReplayControl, type ReplayService, SENSITIVE_MAX_PLAIN_BYTES, type SensitiveCapture, type SpoolEntry, type SwipeDirection, type TapElement, type TouchEventLike, appLifecycle, appStoreInstallProps, asyncStorageAdapter, bubbleOrigin, chainFromFiber, compilePatterns, createAutocapture, createClient, createGuides, createReplay, createReplaySpool, createSensitiveCapture, deadSwipeDecision, effectiveMinDurationMs, effectiveSampleRate, encryptSensitive, encryptedStorage, exposureKeyOf, fetchTransport, guideAnchor, guideButtons, guideTokens, installReferrerProps, maskPatterns, maskSnapshotText, matches as matchGuideScreen, measureAnchor, memoryStorage, nativeQueueCipher, registerAnchor, replayMarker, resolveInstallProps, runHooks, sampleHook, screenAllowed, screenExcluded, screenMasked, screenTracker, splitBatches, swipeDirection, touchHandler, tracedFetch, tracingHeaders, truncateUtf8, useActiveFeatureFlags, useCapture, useFeatureFlag, useFeatureFlagPayload, useFeatureFlagVariant, useLucida, useReplay, useScreen, withBaggage, withTimeout };
package/dist/index.js CHANGED
@@ -16,13 +16,13 @@ var PERSISTED_KEYS = [
16
16
  ];
17
17
  function asyncStorageAdapter(as) {
18
18
  const mem = /* @__PURE__ */ new Map();
19
- const ready = as.multiGet(PERSISTED_KEYS).then(
20
- (rows) => {
21
- for (const [k, v] of rows) if (v !== null) mem.set(k, v);
22
- },
23
- () => void 0
24
- // unreadable storage: start empty rather than fail init
25
- );
19
+ const load = async (keys) => {
20
+ for (const [k, v] of await as.multiGet(keys)) if (v !== null) mem.set(k, v);
21
+ };
22
+ const ready = load(PERSISTED_KEYS).then(async () => {
23
+ const all = await as.getAllKeys?.() ?? [];
24
+ await load(all.filter((k) => k === "lc_replay" || k.startsWith("lc_replay.")));
25
+ }).catch(() => void 0);
26
26
  const swallow = (p) => void p.catch(() => void 0);
27
27
  return {
28
28
  ready,
@@ -104,14 +104,15 @@ function withTimeout(inner, ms) {
104
104
  })
105
105
  };
106
106
  }
107
- function encryptedStorage(inner, cipher, keys) {
107
+ function encryptedStorage(inner, cipher, keys, prefixes = []) {
108
108
  const enc = new Set(keys);
109
+ const covers = (k) => enc.has(k) || prefixes.some((p) => k.startsWith(p));
109
110
  return {
110
111
  get: (k) => {
111
112
  const v = inner.get(k);
112
- return v !== null && enc.has(k) ? cipher.decrypt(v) : v;
113
+ return v !== null && covers(k) ? cipher.decrypt(v) : v;
113
114
  },
114
- set: (k, v) => inner.set(k, enc.has(k) ? cipher.encrypt(v) : v),
115
+ set: (k, v) => inner.set(k, covers(k) ? cipher.encrypt(v) : v),
115
116
  remove: (k) => inner.remove(k)
116
117
  };
117
118
  }
@@ -235,6 +236,7 @@ function appStoreInstallProps(json) {
235
236
  }
236
237
  return out;
237
238
  }
239
+ var retryableStatus = (s) => s === 404 || s === 408 || s === 429 || s >= 500;
238
240
  async function resolveInstallProps(native, d) {
239
241
  let fallback = {};
240
242
  try {
@@ -252,7 +254,8 @@ async function resolveInstallProps(native, d) {
252
254
  try {
253
255
  const r = await d.fetch(ASA_URL, { method: "POST", headers: { "Content-Type": "text/plain" }, body: info.token, signal: ctl?.signal ?? null });
254
256
  if (r.ok) return appStoreInstallProps(await r.json());
255
- if (r.status !== 404) break;
257
+ if (!retryableStatus(r.status)) break;
258
+ } catch {
256
259
  } finally {
257
260
  if (timer) clearTimeout(timer);
258
261
  }
@@ -823,6 +826,114 @@ function maskSnapshotText(node, patterns) {
823
826
  return out;
824
827
  }
825
828
 
829
+ // src/replay-spool.ts
830
+ var SPOOL_KEY = "lc_replay";
831
+ var DEFAULT_SPOOL_BUDGET_BYTES = 10 * 1024 * 1024;
832
+ var SPOOL_PART_CHARS = 3e5;
833
+ var PENDING_KEY = "lc_replay.pending";
834
+ var byteLength = (s) => {
835
+ let n = 0;
836
+ for (let i = 0; i < s.length; i++) {
837
+ const c = s.charCodeAt(i);
838
+ n += c < 128 ? 1 : c < 2048 ? 2 : c >= 55296 && c < 56320 ? (i++, 4) : 3;
839
+ }
840
+ return n;
841
+ };
842
+ function createReplaySpool(storage, budget = DEFAULT_SPOOL_BUDGET_BYTES, onDrop) {
843
+ let items = [];
844
+ let nextId = 0;
845
+ const partKey = (id, i) => `${SPOOL_KEY}.${id}.${i}`;
846
+ const removeParts = (id, count) => {
847
+ for (let i = 0; i < count; i++) storage.remove(partKey(id, i));
848
+ };
849
+ const complete = (e) => {
850
+ for (let i = 0; i < e.p; i++) if (storage.get(partKey(e.id, i)) === null) return false;
851
+ return true;
852
+ };
853
+ let listed = [];
854
+ try {
855
+ const raw = storage.get(SPOOL_KEY);
856
+ const parsed = raw === null ? [] : JSON.parse(raw);
857
+ if (Array.isArray(parsed)) listed = parsed;
858
+ } catch {
859
+ listed = [];
860
+ }
861
+ items = listed.filter((e) => typeof e.p === "number" && complete(e));
862
+ for (const e of listed) if (!items.includes(e)) removeParts(e.id, typeof e.p === "number" ? e.p : 0);
863
+ const pending = storage.get(PENDING_KEY);
864
+ if (pending !== null) {
865
+ const [pid, pcount] = pending.split(":").map(Number);
866
+ if (pid !== void 0 && pcount !== void 0 && !items.some((e) => e.id === pid)) removeParts(pid, pcount);
867
+ storage.remove(PENDING_KEY);
868
+ }
869
+ nextId = Math.max(listed.reduce((m, e) => Math.max(m, e.id + 1), 0), items.reduce((m, e) => Math.max(m, e.id + 1), 0));
870
+ if (items.length !== listed.length) storage.set(SPOOL_KEY, JSON.stringify(items));
871
+ const save = () => items.length === 0 ? storage.remove(SPOOL_KEY) : storage.set(SPOOL_KEY, JSON.stringify(items));
872
+ const drop = (gone) => {
873
+ for (const e of gone) removeParts(e.id, e.p);
874
+ items = items.filter((e) => !gone.includes(e));
875
+ };
876
+ return {
877
+ entries: () => items,
878
+ totalBytes: () => items.reduce((a, e) => a + e.n, 0),
879
+ add(sid, seq, held, body) {
880
+ const parts = [];
881
+ for (let at = 0; at < body.length || parts.length === 0; ) {
882
+ let end = Math.min(body.length, at + SPOOL_PART_CHARS);
883
+ const last = body.charCodeAt(end - 1);
884
+ if (end < body.length && last >= 55296 && last < 56320) end -= 1;
885
+ parts.push(body.slice(at, end));
886
+ at = end;
887
+ }
888
+ const e = { id: nextId++, sid, seq, held, n: byteLength(body), p: parts.length, len: body.length };
889
+ storage.set(PENDING_KEY, `${e.id}:${e.p}`);
890
+ parts.forEach((part, i) => storage.set(partKey(e.id, i), part));
891
+ items.push(e);
892
+ const dropped = [];
893
+ while (items.length > 0 && items.reduce((a, x) => a + x.n, 0) > budget) {
894
+ const first = items[0];
895
+ const gone = items.filter((x) => x.sid === first.sid);
896
+ drop(gone);
897
+ dropped.push([first.sid, gone.length, gone.reduce((a, x) => a + x.n, 0)]);
898
+ }
899
+ save();
900
+ storage.remove(PENDING_KEY);
901
+ for (const [s, c, b] of dropped) onDrop?.(s, c, b);
902
+ },
903
+ /** The whole body, or null when a part is missing or the length does not add up (torn write): the caller discards it. */
904
+ read(e) {
905
+ let out = "";
906
+ for (let i = 0; i < e.p; i++) {
907
+ const part = storage.get(partKey(e.id, i));
908
+ if (part === null) return null;
909
+ out += part;
910
+ }
911
+ return out.length === e.len ? out : null;
912
+ },
913
+ remove(e) {
914
+ drop([e]);
915
+ save();
916
+ },
917
+ /** Held chunks of every session except `keep`; returned so the caller can give their seq numbers back. */
918
+ removeHeld(keep) {
919
+ const gone = items.filter((e) => e.held && e.sid !== keep);
920
+ drop(gone);
921
+ save();
922
+ return gone;
923
+ },
924
+ /** The session reached its minimum duration: its held chunks become plain pending uploads. */
925
+ release(sid) {
926
+ items = items.map((e) => e.held && e.sid === sid ? { ...e, held: false } : e);
927
+ save();
928
+ },
929
+ nextSeq: (sid) => items.filter((e) => e.sid === sid).reduce((m, e) => Math.max(m, e.seq), -1) + 1,
930
+ clear() {
931
+ drop([...items]);
932
+ save();
933
+ }
934
+ };
935
+ }
936
+
826
937
  // src/sensitive.ts
827
938
  import { sha256Hex } from "@lucida-analytics/sdk-core";
828
939
  var SENSITIVE_MAX_PLAIN_BYTES = 190;
@@ -913,8 +1024,10 @@ function createReplay(d) {
913
1024
  let remote = null;
914
1025
  let remoteLoaded = false;
915
1026
  let sessionStart = 0;
916
- let held = [];
917
- const maxHeld = 20;
1027
+ const spool = createReplaySpool(d.storage, config.spoolBudgetBytes, (sessionId, chunks, bytes) => {
1028
+ d.log?.(`replay spool over budget: dropped session ${sessionId} (${chunks} chunks, ${bytes} bytes)`);
1029
+ config.onSpoolDrop?.({ sessionId, chunks, bytes });
1030
+ });
918
1031
  let touches = [];
919
1032
  let tapTarget = 0;
920
1033
  let tapTree;
@@ -991,15 +1104,38 @@ function createReplay(d) {
991
1104
  core.capture("$sensitive_capture", p);
992
1105
  return true;
993
1106
  }
994
- async function send(events, sessionId, windowId) {
995
- const body = JSON.stringify({ api_key: d.apiKey, session_id: sessionId, window_id: windowId, seq, events });
1107
+ function store(events, sessionId, windowId, held) {
1108
+ const body = JSON.stringify({ session_id: sessionId, window_id: windowId, seq, events });
996
1109
  if (body.length > (config.maxEventBytes ?? 5e6)) return;
997
- try {
998
- const r = await d.fetch(`${d.apiHost}/v1/replay`, { method: "POST", headers: { "Content-Type": "application/json" }, body });
999
- if (r.ok) seq += 1;
1000
- else lastFp = null;
1001
- } catch {
1002
- lastFp = null;
1110
+ spool.add(sessionId, seq, held, body);
1111
+ seq += 1;
1112
+ }
1113
+ async function drain() {
1114
+ for (const e of [...spool.entries()]) {
1115
+ if (e.held) continue;
1116
+ const stored = spool.read(e);
1117
+ if (stored === null) {
1118
+ spool.remove(e);
1119
+ continue;
1120
+ }
1121
+ let status = 0;
1122
+ try {
1123
+ const r = await d.fetch(`${d.apiHost}/v1/replay`, {
1124
+ method: "POST",
1125
+ headers: { "Content-Type": "application/json" },
1126
+ body: JSON.stringify({ api_key: d.apiKey, ...JSON.parse(stored) })
1127
+ });
1128
+ status = r.ok ? 200 : r.status;
1129
+ } catch {
1130
+ }
1131
+ if (status === 200) spool.remove(e);
1132
+ else if (status === 0 || status === 408 || status === 429 || status >= 500) {
1133
+ lastFp = null;
1134
+ return;
1135
+ } else {
1136
+ spool.remove(e);
1137
+ d.log?.(`replay chunk rejected with ${status}; dropped`);
1138
+ }
1003
1139
  }
1004
1140
  }
1005
1141
  async function tick() {
@@ -1007,7 +1143,7 @@ function createReplay(d) {
1007
1143
  lastFp = null;
1008
1144
  hasSnapshot = false;
1009
1145
  touches = [];
1010
- held = [];
1146
+ spool.clear();
1011
1147
  return;
1012
1148
  }
1013
1149
  await loadRemote();
@@ -1046,10 +1182,10 @@ function createReplay(d) {
1046
1182
  const { sessionId, windowId } = core.session.touch();
1047
1183
  const rotated = sessionId !== lastSession;
1048
1184
  if (rotated) {
1049
- seq = 0;
1050
1185
  lastSession = sessionId;
1051
1186
  sessionStart = now();
1052
- held = [];
1187
+ spool.removeHeld(sessionId);
1188
+ seq = spool.nextSeq(sessionId);
1053
1189
  }
1054
1190
  const fp = fingerprint(events);
1055
1191
  const ts = now();
@@ -1066,16 +1202,12 @@ function createReplay(d) {
1066
1202
  tapTree = events.find((e) => e.type === 2)?.data.node;
1067
1203
  hasSnapshot = true;
1068
1204
  }
1069
- if (out.length === 0) return;
1070
1205
  const minMs = effectiveMinDurationMs(config.minDurationMs ?? 0, remote);
1071
- if (minMs > 0 && now() - sessionStart < minMs) {
1072
- held.push(out);
1073
- if (held.length > maxHeld) held.shift();
1074
- return;
1075
- }
1076
- const backlog = held;
1077
- held = [];
1078
- for (const chunk of [...backlog, out]) await send(chunk, sessionId, windowId);
1206
+ const holding = minMs > 0 && now() - sessionStart < minMs;
1207
+ if (out.length > 0) store(out, sessionId, windowId, holding);
1208
+ if (holding) return;
1209
+ spool.release(sessionId);
1210
+ await drain();
1079
1211
  }
1080
1212
  return {
1081
1213
  start() {
@@ -1086,7 +1218,8 @@ function createReplay(d) {
1086
1218
  if (timer === void 0) return;
1087
1219
  (config.clearInterval ?? ((h) => clearInterval(h)))(timer);
1088
1220
  timer = void 0;
1089
- held = [];
1221
+ const back = spool.removeHeld().filter((e) => e.sid === lastSession).map((e) => e.seq);
1222
+ if (back.length > 0) seq = Math.min(...back);
1090
1223
  },
1091
1224
  pause() {
1092
1225
  paused = true;
@@ -1106,6 +1239,7 @@ function createReplay(d) {
1106
1239
  data: { source: 2, type: 2, id: hitTest(tapTree, x, y, tapTarget), x, y }
1107
1240
  });
1108
1241
  },
1242
+ clearSpool: () => spool.clear(),
1109
1243
  tick,
1110
1244
  swipeBegan() {
1111
1245
  if (config.captureDeadSwipes === false || !touchesRecording()) return;
@@ -1119,8 +1253,8 @@ function createReplay(d) {
1119
1253
 
1120
1254
  // src/client.ts
1121
1255
  var LIB = "lucida-react-native";
1122
- var LIB_VERSION = "0.0.0";
1123
- var DEFAULT_HOST = "https://app.lucida.dev";
1256
+ var LIB_VERSION = "0.1.1";
1257
+ var DEFAULT_HOST = "https://capture.lucidace.com";
1124
1258
  var VERSION_KEY = "lc_app_version";
1125
1259
  var noop = { send: async () => ({ status: 200 }) };
1126
1260
  async function createClient(apiKey, config = {}) {
@@ -1137,7 +1271,7 @@ async function createClient(apiKey, config = {}) {
1137
1271
  await a.ready;
1138
1272
  storage = a.storage;
1139
1273
  }
1140
- if (config.queueCipher) storage = encryptedStorage(storage, config.queueCipher, ["lc_offline"]);
1274
+ if (config.queueCipher) storage = encryptedStorage(storage, config.queueCipher, ["lc_offline", "lc_replay"], ["lc_replay."]);
1141
1275
  let transport = config.transport ?? fetchTransport(fetchImpl, config.requestTimeoutMs);
1142
1276
  if (config.maxBatchSize) transport = splitBatches(transport, config.maxBatchSize);
1143
1277
  if (config.transport && config.requestTimeoutMs) transport = withTimeout(transport, config.requestTimeoutMs);
@@ -1237,7 +1371,7 @@ async function createClient(apiKey, config = {}) {
1237
1371
  if (c === "identify" || c === "reset" || c === "group") load();
1238
1372
  });
1239
1373
  }
1240
- const replay = config.replay?.enabled && !config.disabled ? createReplay({ core, fetch: fetchImpl, apiHost, apiKey, config: config.replay, isFlagOn: (k) => {
1374
+ const replay = config.replay?.enabled && !config.disabled ? createReplay({ core, storage, log, fetch: fetchImpl, apiHost, apiKey, config: config.replay, isFlagOn: (k) => {
1241
1375
  const v = flags.all()[k];
1242
1376
  return v !== void 0 && v !== false;
1243
1377
  } }) : void 0;
@@ -1280,6 +1414,7 @@ async function createClient(apiKey, config = {}) {
1280
1414
  },
1281
1415
  reset() {
1282
1416
  person.reset();
1417
+ replay?.clearSpool();
1283
1418
  core.reset({ reuseAnonymousId: !!config.reuseAnonymousId });
1284
1419
  },
1285
1420
  register: (p) => core.register(p),
@@ -1287,7 +1422,10 @@ async function createClient(apiKey, config = {}) {
1287
1422
  unregister: (k) => core.unregister(k),
1288
1423
  group: (t, k, p) => core.group(t, k, p),
1289
1424
  optIn: () => core.optIn(),
1290
- optOut: () => core.optOut(),
1425
+ optOut: () => {
1426
+ replay?.clearSpool();
1427
+ core.optOut();
1428
+ },
1291
1429
  getFlag: (k) => flags.get(k),
1292
1430
  getFlagPayload: (k) => flags.payload(k),
1293
1431
  getFlagResult: (k, o) => flags.result(k, o),
@@ -1719,6 +1857,7 @@ export {
1719
1857
  createClient,
1720
1858
  createGuides,
1721
1859
  createReplay,
1860
+ createReplaySpool,
1722
1861
  createSensitiveCapture,
1723
1862
  deadSwipeDecision,
1724
1863
  effectiveMinDurationMs,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lucida-analytics/react-native",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "license": "Apache-2.0",
5
5
  "repository": {
6
6
  "type": "git",
@@ -28,7 +28,7 @@
28
28
  "react": ">=18"
29
29
  },
30
30
  "dependencies": {
31
- "@lucida-analytics/sdk-core": "0.1.0"
31
+ "@lucida-analytics/sdk-core": "0.1.1"
32
32
  },
33
33
  "devDependencies": {
34
34
  "@types/node": "^22.0.0",