react-native-revnix 1.5.0 → 1.7.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.
Files changed (49) hide show
  1. package/README.md +16 -0
  2. package/android/build.gradle +3 -0
  3. package/android/consumer-rules.pro +1 -0
  4. package/android/src/main/kotlin/com/revnix/reactnative/RevnixStoreModule.kt +43 -0
  5. package/dist/blocks/locale.js +7 -0
  6. package/dist/blocks/selection.d.ts +7 -1
  7. package/dist/blocks/selection.js +9 -0
  8. package/dist/blocks/tags.d.ts +3 -0
  9. package/dist/blocks/tags.js +33 -0
  10. package/dist/blocks/types.d.ts +45 -3
  11. package/dist/blocks/types.js +1 -0
  12. package/dist/client.d.ts +50 -11
  13. package/dist/client.js +408 -119
  14. package/dist/esm/blocks/locale.js +7 -0
  15. package/dist/esm/blocks/selection.js +8 -0
  16. package/dist/esm/blocks/tags.js +31 -0
  17. package/dist/esm/blocks/types.js +1 -0
  18. package/dist/esm/client.js +407 -119
  19. package/dist/esm/store/index.js +7 -0
  20. package/dist/esm/ui/RevnixBlockPaywall.js +103 -22
  21. package/dist/esm/ui/RevnixPaywall.js +7 -0
  22. package/dist/esm/ui/index.js +1 -1
  23. package/dist/esm/version.js +1 -1
  24. package/dist/index.d.ts +1 -1
  25. package/dist/store/index.d.ts +8 -0
  26. package/dist/store/index.js +10 -1
  27. package/dist/types.d.ts +32 -0
  28. package/dist/ui/RevnixBlockPaywall.js +100 -19
  29. package/dist/ui/RevnixPaywall.d.ts +6 -2
  30. package/dist/ui/RevnixPaywall.js +8 -0
  31. package/dist/ui/index.d.ts +1 -1
  32. package/dist/ui/index.js +2 -1
  33. package/dist/version.d.ts +1 -1
  34. package/dist/version.js +1 -1
  35. package/ios/RevnixStore.m +4 -0
  36. package/ios/RevnixStore.swift +29 -0
  37. package/package.json +1 -1
  38. package/src/blocks/locale.ts +7 -0
  39. package/src/blocks/selection.ts +13 -0
  40. package/src/blocks/tags.ts +38 -0
  41. package/src/blocks/types.ts +51 -2
  42. package/src/client.ts +478 -154
  43. package/src/index.ts +1 -0
  44. package/src/store/index.ts +15 -0
  45. package/src/types.ts +30 -0
  46. package/src/ui/RevnixBlockPaywall.tsx +186 -20
  47. package/src/ui/RevnixPaywall.tsx +15 -1
  48. package/src/ui/index.ts +1 -0
  49. package/src/version.ts +1 -1
package/src/client.ts CHANGED
@@ -45,6 +45,7 @@ import type {
45
45
  IdentifyResult,
46
46
  IdentifyStrategy,
47
47
  IdentityInfo,
48
+ IntegrityEvidence,
48
49
  LastDeepLink,
49
50
  MmpAttribution,
50
51
  PaywallInteraction,
@@ -67,7 +68,20 @@ const PENDING_PURCHASES_KEY = "revnix.pendingPurchases";
67
68
  const PLACEMENTS_CACHE_KEY = "revnix.placements";
68
69
  const LAST_ATTRIBUTION_KEY = "revnix.lastAttribution";
69
70
  const LAST_PUSH_TOKEN_KEY = "revnix.lastPushToken";
71
+ const LAST_AD_ID_KEY = "revnix.lastAdId";
72
+ const EVENT_QUEUE_KEY = "revnix.eventQueue";
73
+ const PLACEMENTS_INVALIDATED_AT_KEY = "revnix.placementsInvalidatedAt";
70
74
  const DEFAULT_TIMEOUT_MS = 10_000;
75
+ const INTEGRITY_TIMEOUT_MS = 10_000;
76
+ const EVENT_QUEUE_CAP = 1000;
77
+ const EVENT_BATCH_SIZE = 100;
78
+ const EVENT_FLUSH_THRESHOLD = 20;
79
+ const EVENT_FLUSH_DELAY_MS = 10_000;
80
+ const EVENT_RETRY_HOLD_MS = 30_000;
81
+ const PLACEMENT_FRESH_MS = 24 * 60 * 60 * 1000;
82
+ const CACHED_ENTITLEMENTS_TIMEOUT_MS = 3_000;
83
+ const PURCHASE_QUEUE_CAP = 100;
84
+ const PURCHASE_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
71
85
 
72
86
  // REV-204 (L10): how many customers keep a cached entitlements snapshot.
73
87
  const ENTITLEMENTS_CACHE_MAX = 4;
@@ -105,6 +119,57 @@ const CLOCK_ROLLBACK_TOLERANCE_MS = 5 * 60 * 1000;
105
119
  interface PendingPurchase {
106
120
  customerId: string;
107
121
  input: RegisterPurchaseInput;
122
+ queuedAt?: number;
123
+ }
124
+
125
+ interface PlacementCacheEntry {
126
+ data: PlacementResolution;
127
+ fetchedAt: number;
128
+ customerId?: string;
129
+ }
130
+
131
+ interface QueuedEvent {
132
+ path: string;
133
+ body: Record<string, unknown>;
134
+ }
135
+
136
+ function batchResults(
137
+ response: unknown,
138
+ path: string
139
+ ): Array<{ status: number; error?: string }> {
140
+ const results = (response as { results?: unknown } | null)?.results;
141
+ if (
142
+ Array.isArray(results) &&
143
+ results.every(
144
+ (r) => typeof r === "object" && r !== null && typeof r.status === "number"
145
+ )
146
+ ) {
147
+ return results;
148
+ }
149
+ throw new RevnixError(0, `Revnix: server returned an ill-shaped batch response for ${path}`);
150
+ }
151
+
152
+ export function paywallImageUrls(value: unknown): string[] {
153
+ const found = new Set<string>();
154
+ const visit = (node: unknown): void => {
155
+ if (Array.isArray(node)) {
156
+ node.forEach(visit);
157
+ return;
158
+ }
159
+ if (typeof node !== "object" || node === null) return;
160
+ const o = node as Record<string, unknown>;
161
+ const background = o.background as { image?: { url?: unknown } } | null | undefined;
162
+ for (const url of [
163
+ o.heroImageUrl,
164
+ o.type === "image" ? o.url : undefined,
165
+ background?.image?.url,
166
+ ]) {
167
+ if (typeof url === "string" && url.length > 0) found.add(url);
168
+ }
169
+ Object.values(o).forEach(visit);
170
+ };
171
+ visit(value);
172
+ return [...found];
108
173
  }
109
174
 
110
175
  interface InstallResponse {
@@ -138,6 +203,21 @@ const jittered = (ms: number) => ms * (0.8 + Math.random() * 0.4);
138
203
  // half a minute; long enough that gating five screens costs one request.
139
204
  const DEFAULT_ENTITLEMENTS_TTL_MS = 30_000;
140
205
 
206
+ const fitsWithin = (value: unknown, max: number): value is string =>
207
+ typeof value === "string" && value.length > 0 && value.length <= max;
208
+
209
+ function wellFormedIntegrity(evidence: unknown): IntegrityEvidence | undefined {
210
+ if (typeof evidence !== "object" || evidence === null) return undefined;
211
+ const { platform, keyId, attestation, token } = evidence as Record<string, unknown>;
212
+ if (platform === "ios" && fitsWithin(keyId, 128) && fitsWithin(attestation, 16_384)) {
213
+ return { platform, keyId, attestation };
214
+ }
215
+ if (platform === "android" && fitsWithin(token, 16_384)) {
216
+ return { platform, token };
217
+ }
218
+ return undefined;
219
+ }
220
+
141
221
  export class RevnixClient {
142
222
  private readonly apiKey: string;
143
223
  private readonly baseUrl: string;
@@ -182,6 +262,11 @@ export class RevnixClient {
182
262
  private readonly onImplicitPaywall?: RevnixConfig["onImplicitPaywall"];
183
263
  private readonly onDeferredDeepLink?: RevnixConfig["onDeferredDeepLink"];
184
264
  private readonly onAttribution?: RevnixConfig["onAttribution"];
265
+ private readonly deviceIntegrity?: RevnixConfig["deviceIntegrity"];
266
+ private integrityRequest: {
267
+ customerId: string;
268
+ body: Promise<{ integrity?: IntegrityEvidence }>;
269
+ } | null = null;
185
270
  private deferredDeepLinkClaimed = false;
186
271
  private landingUrl: string | null = null;
187
272
  private lastDeepLinkWrite: Promise<void> = Promise.resolve();
@@ -213,6 +298,9 @@ export class RevnixClient {
213
298
  * /v1/installs uses, and independent of the device header (which an app
214
299
  * can turn off entirely). */
215
300
  private freshInstall = false;
301
+ private eventFlush: Promise<void> | null = null;
302
+ private eventTimer: ReturnType<typeof setTimeout> | null = null;
303
+ private eventRetryAt = 0;
216
304
 
217
305
  constructor(config: RevnixConfig) {
218
306
  if (!config.apiKey) throw new Error("Revnix: apiKey is required");
@@ -251,6 +339,7 @@ export class RevnixClient {
251
339
  this.onDiagnostic = config.onDiagnostic;
252
340
  this.onDeferredDeepLink = config.onDeferredDeepLink;
253
341
  this.onAttribution = config.onAttribution;
342
+ this.deviceIntegrity = config.deviceIntegrity;
254
343
  this.readYourWritesDelaysMs =
255
344
  config.readYourWritesDelaysMs ?? [250, 500, 1000, 2000];
256
345
  this.customerIdPromise = this.loadCustomerId(config.customerId);
@@ -259,6 +348,7 @@ export class RevnixClient {
259
348
  void this.retryPendingPurchases().catch((err) =>
260
349
  this.diag("retryPendingPurchases", err)
261
350
  );
351
+ if (config.advertisingId) this.reportAdvertisingId(config.advertisingId);
262
352
  const store = getStoreAdapter();
263
353
  if (store) {
264
354
  this.stopStoreObserver = store.observe((tx) =>
@@ -283,7 +373,11 @@ export class RevnixClient {
283
373
  config.implicitPlacements ?? config.onImplicitPaywall !== undefined;
284
374
  this.sessionTimeoutMs =
285
375
  config.sessionTimeoutMs ?? DEFAULT_SESSION_TIMEOUT_MS;
286
- if (this.implicitEnabled) this.startImplicit(config.lifecycle);
376
+ this.subscribeLifecycle(config.lifecycle);
377
+ if (this.implicitEnabled) this.startImplicit();
378
+ void this.readEvents().then((events) => {
379
+ if (events.length > 0) this.startEventFlush();
380
+ });
287
381
  const { document, location } = globalThis as {
288
382
  document?: unknown;
289
383
  location?: { href: string; search: string };
@@ -396,6 +490,7 @@ export class RevnixClient {
396
490
  const customerId = await this.customerIdPromise;
397
491
  try {
398
492
  const result = await this.postPurchase(customerId, input);
493
+ await this.invalidatePlacements();
399
494
  // Identity resolution may have merged this device's id away (restore
400
495
  // after login), so the server's id is the one to keep using.
401
496
  //
@@ -522,23 +617,28 @@ export class RevnixClient {
522
617
 
523
618
  /** C7: retry purchases that failed to register and were persisted locally.
524
619
  * Runs automatically at construction; call again when connectivity returns.
525
- * Idempotent and safe to call repeatedly. */
620
+ * Idempotent and safe to call repeatedly. Purchases queued more than 30
621
+ * days ago are dropped with a diagnostic. */
526
622
  async retryPendingPurchases(): Promise<void> {
623
+ const now = Date.now();
624
+ await this.mutateQueue((queue) =>
625
+ queue.some((q) => q.queuedAt === undefined)
626
+ ? queue.map((q) => ({ ...q, queuedAt: q.queuedAt ?? now }))
627
+ : queue
628
+ );
527
629
  const queue = await this.readPurchaseQueue();
528
630
  for (const item of queue) {
529
631
  if (this.disposed) return;
530
- try {
531
- await this.postPurchase(item.customerId, item.input);
532
- // Delivered — remove it.
533
- } catch (err) {
534
- if (worthQueueing(err)) {
535
- // REV-200 (M26): a queued purchase that STILL cannot land is the
536
- // most important background failure there is — report it.
632
+ if (now - (item.queuedAt ?? now) > PURCHASE_MAX_AGE_MS) {
633
+ this.diag("retryPendingPurchases", new Error(`expired ${pendingKey(item.input)}`));
634
+ } else {
635
+ try {
636
+ await this.postPurchase(item.customerId, item.input);
637
+ await this.invalidatePlacements();
638
+ } catch (err) {
537
639
  this.diag("retryPendingPurchases", err);
538
- continue; // transient — keep for next time
640
+ if (worthQueueing(err)) continue;
539
641
  }
540
- // Permanent rejection — remove it.
541
- this.diag("retryPendingPurchases", err);
542
642
  }
543
643
  // Remove THIS item from the queue as it stands now, not from the
544
644
  // snapshot iterated above: each postPurchase can spend seconds in a
@@ -600,11 +700,15 @@ export class RevnixClient {
600
700
  /** The authoritative network read: no TTL, no cache fallback — throws so
601
701
  * the caller sees the real failure (waitForEntitlements needs 429s). */
602
702
  private async fetchFreshEntitlements(
603
- customerId: string
703
+ customerId: string,
704
+ timeoutMs?: number
604
705
  ): Promise<CustomerEntitlements> {
605
706
  const fresh = await this.request<CustomerEntitlements>(
606
707
  "GET",
607
- `/v1/customers/${encodeURIComponent(customerId)}/entitlements`
708
+ `/v1/customers/${encodeURIComponent(customerId)}/entitlements`,
709
+ undefined,
710
+ undefined,
711
+ timeoutMs
608
712
  );
609
713
  await this.writeEntitlementsCache(customerId, fresh);
610
714
  const result = { ...fresh, stale: false, fetchedAt: Date.now() };
@@ -615,8 +719,14 @@ export class RevnixClient {
615
719
  private async fetchEntitlementsWithFallback(
616
720
  customerId: string
617
721
  ): Promise<CustomerEntitlements> {
722
+ const hasCache = (await this.readEntitlementsCache(customerId)) !== null;
618
723
  try {
619
- return await this.fetchFreshEntitlements(customerId);
724
+ return await this.fetchFreshEntitlements(
725
+ customerId,
726
+ hasCache
727
+ ? Math.min(CACHED_ENTITLEMENTS_TIMEOUT_MS, this.requestTimeoutMs)
728
+ : undefined
729
+ );
620
730
  } catch (err) {
621
731
  // REV-198 (CTO-R2 M22): the cache exists to ride out failures that
622
732
  // might succeed next time — network, timeout, 429, 5xx. A deliberate
@@ -740,54 +850,82 @@ export class RevnixClient {
740
850
  }
741
851
 
742
852
  /** Resolve a named in-app location to the currently published offering —
743
- * the one-line paywall call. REV-157: reads the network first and caches
744
- * the result; if that read FAILS (offline / incident) it falls back to the
745
- * last cached resolution with `stale: true`, so the paywall renders the
746
- * last published version rather than not at all. Throws only when there
747
- * is nothing cached. */
853
+ * the one-line paywall call. Served instantly from a cache under 24 h old
854
+ * that was fetched for this customer since the last `setAttributes` or
855
+ * purchase, refreshed in the background. Otherwise reads the network and
856
+ * caches the result; if that read FAILS (offline / incident) it falls back
857
+ * to the last cached resolution with `stale: true`, so the paywall renders
858
+ * the last published version rather than not at all. Throws only when
859
+ * there is nothing cached. A deliberate rejection seen by the background
860
+ * refresh drops the cache, so the next resolve throws it. */
748
861
  async resolvePlacement(placementKey: string): Promise<PlacementResolution> {
749
862
  const customerId = await this.customerIdPromise;
863
+ const cached = await this.readPlacementCache(placementKey, customerId);
864
+ const age = Date.now() - (cached?.fetchedAt ?? 0);
865
+ if (
866
+ cached &&
867
+ cached.customerId === customerId &&
868
+ cached.fetchedAt > (await this.placementsInvalidatedAt()) &&
869
+ age >= 0 &&
870
+ age <= PLACEMENT_FRESH_MS
871
+ ) {
872
+ void this.refreshPlacement(placementKey);
873
+ return { ...cached.data, stale: false, fetchedAt: cached.fetchedAt };
874
+ }
750
875
  try {
751
- // REV-219: identify the caller so a running experiment on this
752
- // placement can assign a (sticky, deterministic) variant. Servers
753
- // without experiments simply ignore the parameter. An EPHEMERAL anon
754
- // id (memory-only storage — new id every launch) is deliberately NOT
755
- // sent: each launch would enroll a phantom customer and flip the same
756
- // user between variants; the server serves the placement default
757
- // when no customer is identified (same guard class as M5 installs).
758
- const path = `/v1/placements/${encodeURIComponent(placementKey)}/offering`;
759
- // REV-268: the device facts ride along so the server can evaluate
760
- // targeting rules against THIS device on THIS request, and store them
761
- // as device.* attributes. Not sent for an ephemeral anon id: the server
762
- // would have no customer to keep them on, and it serves such callers
763
- // the untargeted default anyway.
764
- const deviceHeader = this.ephemeralAnonId
765
- ? null
766
- : await this.deviceHeaderPromise;
767
- const fresh = await this.request<PlacementResolution>(
768
- "GET",
769
- this.ephemeralAnonId
770
- ? path
771
- : `${path}?customer=${encodeURIComponent(customerId)}`,
772
- undefined,
773
- deviceHeader ? { [DEVICE_HEADER]: deviceHeader } : undefined
774
- );
775
- await this.writePlacementCache(placementKey, fresh, customerId);
776
- return { ...fresh, stale: false, fetchedAt: Date.now() };
876
+ return await this.fetchPlacement(placementKey);
777
877
  } catch (err) {
778
878
  // REV-198 (M22): same rule as getEntitlements — an unpublished catalog
779
879
  // (409) or deleted placement (404) is the server's deliberate answer,
780
880
  // and caching over it meant an unpublished paywall kept rendering
781
881
  // fleet-wide with no way to pull it.
782
882
  if (err instanceof RevnixError && !err.isRetryable) throw err;
783
- const cached = await this.readPlacementCache(placementKey, customerId);
784
- if (cached) {
785
- return { ...cached.data, stale: true, fetchedAt: cached.fetchedAt };
883
+ const fallback = await this.readPlacementCache(placementKey, customerId);
884
+ if (fallback) {
885
+ return { ...fallback.data, stale: true, fetchedAt: fallback.fetchedAt };
786
886
  }
787
887
  throw err;
788
888
  }
789
889
  }
790
890
 
891
+ /** Fetch these placements now so the next `resolvePlacement` answers from
892
+ * the cache. Never throws; failures report to `onDiagnostic`. Paywall
893
+ * images are warmed by `prefetchPaywallImages` from
894
+ * `react-native-revnix/ui`. */
895
+ async preload(placementKeys: string[]): Promise<void> {
896
+ await Promise.all(placementKeys.map((key) => this.refreshPlacement(key)));
897
+ }
898
+
899
+ private async refreshPlacement(placementKey: string): Promise<void> {
900
+ try {
901
+ await this.fetchPlacement(placementKey);
902
+ } catch (err) {
903
+ if (err instanceof RevnixError && !err.isRetryable) {
904
+ await this.deletePlacementCache(placementKey);
905
+ }
906
+ this.diag("resolvePlacement", err);
907
+ }
908
+ }
909
+
910
+ private async fetchPlacement(placementKey: string): Promise<PlacementResolution> {
911
+ const customerId = await this.customerIdPromise;
912
+ const fetchedAt = Date.now();
913
+ const path = `/v1/placements/${encodeURIComponent(placementKey)}/offering`;
914
+ const deviceHeader = this.ephemeralAnonId
915
+ ? null
916
+ : await this.deviceHeaderPromise;
917
+ const fresh = await this.request<PlacementResolution>(
918
+ "GET",
919
+ this.ephemeralAnonId
920
+ ? path
921
+ : `${path}?customer=${encodeURIComponent(customerId)}`,
922
+ undefined,
923
+ deviceHeader ? { [DEVICE_HEADER]: deviceHeader } : undefined
924
+ );
925
+ await this.writePlacementCache(placementKey, fresh, customerId, fetchedAt);
926
+ return { ...fresh, stale: false, fetchedAt: Date.now() };
927
+ }
928
+
791
929
  /** REV-157: the last resolution cached for `placementKey`, with NO network
792
930
  * call — for an instant, offline-safe paywall at launch. Always
793
931
  * `stale: true`; follow with resolvePlacement() to revalidate. Null if
@@ -896,7 +1034,8 @@ export class RevnixClient {
896
1034
  * - `restore` — restore purchases was tapped
897
1035
  * - `error` — the paywall itself failed (config, products, render)
898
1036
  *
899
- * Fire-and-forget like the other beacons: never blocks, never throws.
1037
+ * Queued and sent in batches (see `flushEvents`): resolves once queued,
1038
+ * never throws.
900
1039
  *
901
1040
  * `viewId` is the id `logPaywallShown` returned for THIS display. Passing
902
1041
  * it is what threads the whole life of one impression together and puts
@@ -913,7 +1052,7 @@ export class RevnixClient {
913
1052
  * which caps the report at one per display per event type. Pass one per
914
1053
  * occurrence (and reuse it across your own retries) to record every
915
1054
  * occurrence — e.g. a customer who fails, retries and fails again. */
916
- logPaywallEvent(
1055
+ async logPaywallEvent(
917
1056
  event: PaywallInteraction,
918
1057
  viewId: string,
919
1058
  options?: {
@@ -926,29 +1065,8 @@ export class RevnixClient {
926
1065
  /** Idempotency key for this occurrence; defaults to `viewId`. */
927
1066
  eventId?: string;
928
1067
  }
929
- ): void {
1068
+ ): Promise<void> {
930
1069
  if (options?.placementKey === PREVIEW_PLACEMENT_KEY) return;
931
- void this.customerIdPromise
932
- .then((customerId) =>
933
- this.request("POST", "/v1/paywalls/events", {
934
- customerId,
935
- viewId,
936
- event,
937
- occurredAt: Date.now(),
938
- sdkVersion: SDK_VERSION,
939
- ...(options?.eventId ? { eventId: options.eventId } : {}),
940
- ...(options?.placementKey ? { placementKey: options.placementKey } : {}),
941
- ...(options?.paywallId ? { paywallId: options.paywallId } : {}),
942
- ...(options?.productId ? { productId: options.productId } : {}),
943
- ...(options?.code ? { code: options.code } : {}),
944
- ...(options?.message
945
- ? { message: options.message.slice(0, 1024) }
946
- : {}),
947
- })
948
- )
949
- .catch((err) => this.diag("logPaywallEvent", err));
950
- // REV-272: backing out of the store sheet is the `transaction_abandon`
951
- // moment. Reuses the paywall.purchase_abandoned event just reported.
952
1070
  if (event === "purchase_abandoned") {
953
1071
  this.fireImplicitFromPaywall(
954
1072
  "transaction_abandon",
@@ -956,6 +1074,23 @@ export class RevnixClient {
956
1074
  options?.paywallId
957
1075
  );
958
1076
  }
1077
+ const customerId = await this.customerIdPromise;
1078
+ await this.enqueueEvent({
1079
+ path: "/v1/paywalls/events",
1080
+ body: {
1081
+ customerId,
1082
+ viewId,
1083
+ event,
1084
+ eventId: options?.eventId ?? viewId,
1085
+ occurredAt: Date.now(),
1086
+ sdkVersion: SDK_VERSION,
1087
+ ...(options?.placementKey ? { placementKey: options.placementKey } : {}),
1088
+ ...(options?.paywallId ? { paywallId: options.paywallId } : {}),
1089
+ ...(options?.productId ? { productId: options.productId } : {}),
1090
+ ...(options?.code ? { code: options.code } : {}),
1091
+ ...(options?.message ? { message: options.message.slice(0, 1024) } : {}),
1092
+ },
1093
+ });
959
1094
  }
960
1095
 
961
1096
  /** Reports one ad impression's payout (POST /v1/ad-revenue).
@@ -984,28 +1119,136 @@ export class RevnixClient {
984
1119
  }
985
1120
 
986
1121
  /** Reports one custom in-app event (POST /v1/events), not for purchases.
987
- * Fire-and-forget like the other beacons: never blocks, never throws. */
988
- track(
1122
+ * Queued and sent in batches (see `flushEvents`): resolves once queued,
1123
+ * never throws. */
1124
+ async track(
989
1125
  event: string,
990
1126
  properties?: Record<string, string | number | boolean>,
991
1127
  options?: { eventId?: string }
992
- ): void {
1128
+ ): Promise<void> {
993
1129
  if (!/^[a-z0-9_]{1,64}$/.test(event)) {
994
1130
  this.diag("track", new Error(`invalid event name: ${event}`));
995
1131
  return;
996
1132
  }
997
1133
  const eventId = options?.eventId ?? generateEventId();
998
- void this.customerIdPromise
999
- .then((customerId) =>
1000
- this.request("POST", "/v1/events", {
1001
- customerId,
1002
- event,
1003
- eventId,
1004
- occurredAt: Date.now(),
1005
- ...(properties ? { properties } : {}),
1006
- })
1007
- )
1008
- .catch((err) => this.diag("track", err));
1134
+ const customerId = await this.customerIdPromise;
1135
+ await this.enqueueEvent({
1136
+ path: "/v1/events",
1137
+ body: {
1138
+ customerId,
1139
+ event,
1140
+ eventId,
1141
+ occurredAt: Date.now(),
1142
+ ...(properties ? { properties } : {}),
1143
+ },
1144
+ });
1145
+ }
1146
+
1147
+ /** Send every queued `track` and paywall interaction event now. Events are
1148
+ * otherwise sent in batches: at 20 queued, 10 s after the first, when the
1149
+ * app goes to the background, and on the next launch. Never throws; a
1150
+ * transient failure keeps the events and holds further sends for 30 s. */
1151
+ async flushEvents(): Promise<void> {
1152
+ await this.eventFlush;
1153
+ this.startEventFlush();
1154
+ await this.eventFlush;
1155
+ }
1156
+
1157
+ private async readEvents(): Promise<QueuedEvent[]> {
1158
+ try {
1159
+ const parsed = JSON.parse((await this.storage.getItem(EVENT_QUEUE_KEY)) ?? "[]");
1160
+ return Array.isArray(parsed) ? (parsed as QueuedEvent[]) : [];
1161
+ } catch {
1162
+ return [];
1163
+ }
1164
+ }
1165
+
1166
+ private mutateEvents(mutate: (events: QueuedEvent[]) => QueuedEvent[]): Promise<number> {
1167
+ return this.withStorageLock(async () => {
1168
+ const next = mutate(await this.readEvents());
1169
+ try {
1170
+ await this.storage.setItem(EVENT_QUEUE_KEY, JSON.stringify(next));
1171
+ } catch (err) {
1172
+ this.diag("storageWrite", err);
1173
+ }
1174
+ return next.length;
1175
+ });
1176
+ }
1177
+
1178
+ // ponytail: the whole queue is re-serialized per event; keep it in memory if 1000-item offline queues show up in profiles.
1179
+ private async enqueueEvent(event: QueuedEvent): Promise<void> {
1180
+ const count = await this.mutateEvents((events) => {
1181
+ const kept = events.slice(Math.max(0, events.length - EVENT_QUEUE_CAP + 1));
1182
+ if (kept.length < events.length) {
1183
+ this.diag("eventQueue", new Error("queue full, dropped the oldest event"));
1184
+ }
1185
+ return [...kept, event];
1186
+ });
1187
+ if (count >= EVENT_FLUSH_THRESHOLD) this.startEventFlush();
1188
+ else this.armEventTimer(EVENT_FLUSH_DELAY_MS);
1189
+ }
1190
+
1191
+ private armEventTimer(ms: number): void {
1192
+ if (this.eventTimer !== null || this.disposed) return;
1193
+ this.eventTimer = setTimeout(() => {
1194
+ this.eventTimer = null;
1195
+ this.startEventFlush();
1196
+ }, ms);
1197
+ }
1198
+
1199
+ private startEventFlush(): void {
1200
+ if (this.eventFlush !== null || this.disposed) return;
1201
+ const hold = this.eventRetryAt - Date.now();
1202
+ if (hold > 0) {
1203
+ this.armEventTimer(hold);
1204
+ return;
1205
+ }
1206
+ this.eventFlush = this.runEventFlush().finally(() => {
1207
+ this.eventFlush = null;
1208
+ });
1209
+ }
1210
+
1211
+ private async runEventFlush(): Promise<void> {
1212
+ const byPath = new Map<string, QueuedEvent[]>();
1213
+ for (const event of await this.withStorageLock(() => this.readEvents())) {
1214
+ byPath.set(event.path, [...(byPath.get(event.path) ?? []), event]);
1215
+ }
1216
+ for (const [path, events] of byPath) {
1217
+ for (let start = 0; start < events.length; start += EVENT_BATCH_SIZE) {
1218
+ const chunk = events.slice(start, start + EVENT_BATCH_SIZE);
1219
+ try {
1220
+ const response = await this.request<unknown>("POST", path, {
1221
+ events: chunk.map((e) => e.body),
1222
+ });
1223
+ batchResults(response, path).forEach((result, i) => {
1224
+ if (result.status < 400) return;
1225
+ this.diag(
1226
+ "eventQueue",
1227
+ new RevnixError(
1228
+ result.status,
1229
+ `dropped ${path} event ${String(chunk[i]?.body.eventId)}: ${result.status} ${result.error ?? ""}`
1230
+ )
1231
+ );
1232
+ });
1233
+ await this.removeEvents(chunk);
1234
+ } catch (err) {
1235
+ if (worthQueueing(err)) {
1236
+ this.eventRetryAt = Date.now() + EVENT_RETRY_HOLD_MS;
1237
+ this.diag("eventQueue", err);
1238
+ return;
1239
+ }
1240
+ await this.removeEvents(chunk);
1241
+ this.diag("eventQueue", err);
1242
+ }
1243
+ }
1244
+ }
1245
+ }
1246
+
1247
+ private async removeEvents(sent: QueuedEvent[]): Promise<void> {
1248
+ const sentJson = new Set(sent.map((e) => JSON.stringify(e)));
1249
+ await this.mutateEvents((events) =>
1250
+ events.filter((e) => !sentJson.has(JSON.stringify(e)))
1251
+ );
1009
1252
  }
1010
1253
 
1011
1254
  /** PT11: forward an MMP's attribution callback (Adjust, AppsFlyer, …) so
@@ -1029,6 +1272,7 @@ export class RevnixClient {
1029
1272
  ...(attribution.campaign ? { campaign: attribution.campaign } : {}),
1030
1273
  ...(attribution.adGroup ? { adGroup: attribution.adGroup } : {}),
1031
1274
  ...(attribution.creative ? { creative: attribution.creative } : {}),
1275
+ ...(await this.integrityFor(customerId)),
1032
1276
  });
1033
1277
  await this.storage.setItem(LAST_ATTRIBUTION_KEY, payload);
1034
1278
  })
@@ -1065,6 +1309,23 @@ export class RevnixClient {
1065
1309
  .catch((err) => this.diag("setPushToken", err));
1066
1310
  }
1067
1311
 
1312
+ private reportAdvertisingId(
1313
+ source: NonNullable<RevnixConfig["advertisingId"]>
1314
+ ): void {
1315
+ void Promise.resolve()
1316
+ .then(source)
1317
+ .then(async (value) => {
1318
+ const id = value || null;
1319
+ const customerId = await this.customerIdPromise;
1320
+ const payload = JSON.stringify({ customerId, id });
1321
+ const last = await this.storage.getItem(LAST_AD_ID_KEY);
1322
+ if (last === payload || (id === null && last === null)) return;
1323
+ await this.setAttributes({ gps_adid: id });
1324
+ await this.storage.setItem(LAST_AD_ID_KEY, payload);
1325
+ })
1326
+ .catch((err) => this.diag("advertisingId", err));
1327
+ }
1328
+
1068
1329
  /** REV-272: the two moments that happen ON a paywall, with the loop guard
1069
1330
  * applied. `fromPaywallId` travels so the server can refuse to hand back
1070
1331
  * the very paywall being dismissed — the backstop for a host that reports
@@ -1112,6 +1373,7 @@ export class RevnixClient {
1112
1373
  `/v1/customers/${encodeURIComponent(customerId)}/attributes`,
1113
1374
  { attributes }
1114
1375
  );
1376
+ await this.invalidatePlacements();
1115
1377
  }
1116
1378
 
1117
1379
  /** REV-272: hand the SDK the URL that opened your app, from wherever you
@@ -1300,13 +1562,14 @@ export class RevnixClient {
1300
1562
  // one-launch phantoms. Same guard, same reason, as triggerImplicit.
1301
1563
  if (this.ephemeralAnonId) return;
1302
1564
  void this.customerIdPromise
1303
- .then((customerId) =>
1565
+ .then(async (customerId) =>
1304
1566
  this.request<InstallResponse>("POST", "/v1/installs", {
1305
1567
  customerId,
1306
1568
  occurredAt: Date.now(),
1307
1569
  sdkVersion: SDK_VERSION,
1308
1570
  installReferrer: referrer.slice(0, 1024),
1309
1571
  ...this.installMetadata,
1572
+ ...(await this.integrityFor(customerId)),
1310
1573
  })
1311
1574
  )
1312
1575
  .then((body) => {
@@ -1341,13 +1604,14 @@ export class RevnixClient {
1341
1604
  if (this.disposed || token.trim().length === 0) return Promise.resolve(undefined);
1342
1605
  if (this.ephemeralAnonId) return Promise.resolve(undefined);
1343
1606
  return this.customerIdPromise
1344
- .then((customerId) =>
1607
+ .then(async (customerId) =>
1345
1608
  this.request<InstallResponse>("POST", "/v1/installs", {
1346
1609
  customerId,
1347
1610
  occurredAt: Date.now(),
1348
1611
  sdkVersion: SDK_VERSION,
1349
1612
  attributionToken: token.slice(0, 2048),
1350
1613
  ...this.installMetadata,
1614
+ ...(await this.integrityFor(customerId)),
1351
1615
  })
1352
1616
  )
1353
1617
  .then((body) => {
@@ -1492,6 +1756,19 @@ export class RevnixClient {
1492
1756
  return extra;
1493
1757
  }
1494
1758
 
1759
+ private subscribeLifecycle(configured: RevnixConfig["lifecycle"]): void {
1760
+ if (configured === false) return;
1761
+ const lifecycle: RevnixLifecycle | null = configured ?? browserLifecycle();
1762
+ if (!lifecycle) return;
1763
+ try {
1764
+ this.lifecycleUnsubscribe = lifecycle.onStateChange((state) =>
1765
+ this.onAppState(state)
1766
+ );
1767
+ } catch (err) {
1768
+ this.diag("lifecycleSubscribe", err);
1769
+ }
1770
+ }
1771
+
1495
1772
  /** REV-272: the launch-time moments, plus the foreground listener that
1496
1773
  * `session_start` is built on.
1497
1774
  *
@@ -1500,7 +1777,7 @@ export class RevnixClient {
1500
1777
  * install too when this launch generated the anonymous id. All three are
1501
1778
  * independent placements, so an app may have any subset configured and the
1502
1779
  * others cost nothing. */
1503
- private startImplicit(configured: RevnixConfig["lifecycle"]): void {
1780
+ private startImplicit(): void {
1504
1781
  // freshInstall is set inside loadCustomerId, which the constructor kicked
1505
1782
  // off but did not await — so read it after that promise settles rather
1506
1783
  // than now, when it is still false on a genuine first launch.
@@ -1510,25 +1787,6 @@ export class RevnixClient {
1510
1787
  // are still reported — a launch is a launch whether or not a paywall
1511
1788
  // showed — but an app that configured all three must not have three
1512
1789
  // paywalls pushed onto its first frame.
1513
- // Subscribed BEFORE the batch, which can take a full network timeout per
1514
- // moment when offline: a customer who backgrounds the app during that
1515
- // window and comes back an hour later is a session, and missing the
1516
- // background transition would lose it.
1517
- if (configured !== false) {
1518
- const lifecycle: RevnixLifecycle | null =
1519
- configured ?? browserLifecycle();
1520
- if (lifecycle) {
1521
- try {
1522
- this.lifecycleUnsubscribe = lifecycle.onStateChange((state) =>
1523
- this.onAppState(state)
1524
- );
1525
- } catch (err) {
1526
- // A broken adapter costs session detection, not the client.
1527
- this.diag("lifecycleSubscribe", err);
1528
- }
1529
- }
1530
- }
1531
-
1532
1790
  const sessionExtra = this.consumePreviousSessionMs().catch(
1533
1791
  (err: unknown) => {
1534
1792
  this.diag("session", err);
@@ -1568,6 +1826,8 @@ export class RevnixClient {
1568
1826
 
1569
1827
  private onAppState(state: RevnixAppState): void {
1570
1828
  if (this.disposed) return;
1829
+ if (state === "background") this.startEventFlush();
1830
+ if (!this.implicitEnabled) return;
1571
1831
  if (state === "background") {
1572
1832
  // First report wins: a platform that repeats "background" (a paused
1573
1833
  // Activity re-reporting) must not keep resetting the clock forward.
@@ -1619,6 +1879,8 @@ export class RevnixClient {
1619
1879
  this.lifecycleUnsubscribe = null;
1620
1880
  this.stopStoreObserver?.();
1621
1881
  this.stopStoreObserver = null;
1882
+ if (this.eventTimer !== null) clearTimeout(this.eventTimer);
1883
+ this.eventTimer = null;
1622
1884
  }
1623
1885
 
1624
1886
  /** A block paywall reporting a paint string it could not read. Routed to the
@@ -1704,12 +1966,17 @@ export class RevnixClient {
1704
1966
  /** Fire-and-forget install report (REV-093). Never blocks startup and
1705
1967
  * never throws — the backend absorbs retries idempotently per id. */
1706
1968
  private reportInstall(customerId: string): void {
1707
- void this.request<InstallResponse>("POST", "/v1/installs", {
1708
- customerId,
1709
- occurredAt: Date.now(),
1710
- sdkVersion: SDK_VERSION,
1711
- ...this.installMetadata,
1712
- })
1969
+ const occurredAt = Date.now();
1970
+ void this.integrityFor(customerId)
1971
+ .then((integrity) =>
1972
+ this.request<InstallResponse>("POST", "/v1/installs", {
1973
+ customerId,
1974
+ occurredAt,
1975
+ sdkVersion: SDK_VERSION,
1976
+ ...this.installMetadata,
1977
+ ...integrity,
1978
+ })
1979
+ )
1713
1980
  .then((body) => {
1714
1981
  if (!this.ephemeralAnonId) this.refreshAttribution();
1715
1982
  return this.deliverDeferredDeepLink(body.deferredDeepLink);
@@ -1717,6 +1984,38 @@ export class RevnixClient {
1717
1984
  .catch((err) => this.diag("reportInstall", err));
1718
1985
  }
1719
1986
 
1987
+ private integrityFor(
1988
+ customerId: string
1989
+ ): Promise<{ integrity?: IntegrityEvidence }> {
1990
+ const source = this.deviceIntegrity;
1991
+ if (!source) return Promise.resolve({});
1992
+ if (this.integrityRequest?.customerId !== customerId) {
1993
+ let timer: ReturnType<typeof setTimeout> | undefined;
1994
+ const timeout = new Promise<never>((_, reject) => {
1995
+ timer = setTimeout(
1996
+ () => reject(new Error("device integrity timed out")),
1997
+ INTEGRITY_TIMEOUT_MS
1998
+ );
1999
+ });
2000
+ const body = Promise.race([
2001
+ Promise.resolve().then(() => source(customerId)),
2002
+ timeout,
2003
+ ])
2004
+ .then((evidence) => {
2005
+ const integrity = wellFormedIntegrity(evidence);
2006
+ if (!integrity) throw new Error("device integrity unavailable");
2007
+ return { integrity };
2008
+ })
2009
+ .catch((err: unknown) => {
2010
+ this.diag("deviceIntegrity", err);
2011
+ return {};
2012
+ })
2013
+ .finally(() => clearTimeout(timer));
2014
+ this.integrityRequest = { customerId, body };
2015
+ }
2016
+ return this.integrityRequest.body;
2017
+ }
2018
+
1720
2019
  /** REV-268: assemble and encode the device facts once. Runtime-detected
1721
2020
  * facts first, the app's `device` over them, then the three the SDK owns.
1722
2021
  * `installedAt` is the first launch this storage ever saw — written on
@@ -1867,25 +2166,13 @@ export class RevnixClient {
1867
2166
  // common no-experiment offline paywall keeps working across identity
1868
2167
  // changes.
1869
2168
 
1870
- private async readPlacementMap(): Promise<
1871
- Record<
1872
- string,
1873
- { data: PlacementResolution; fetchedAt: number; customerId?: string }
1874
- >
1875
- > {
2169
+ private async readPlacementMap(): Promise<Record<string, PlacementCacheEntry>> {
1876
2170
  try {
1877
2171
  const raw = await this.storage.getItem(PLACEMENTS_CACHE_KEY);
1878
2172
  if (!raw) return {};
1879
2173
  const parsed = JSON.parse(raw);
1880
2174
  return parsed && typeof parsed === "object" && !Array.isArray(parsed)
1881
- ? (parsed as Record<
1882
- string,
1883
- {
1884
- data: PlacementResolution;
1885
- fetchedAt: number;
1886
- customerId?: string;
1887
- }
1888
- >)
2175
+ ? (parsed as Record<string, PlacementCacheEntry>)
1889
2176
  : {};
1890
2177
  } catch {
1891
2178
  return {};
@@ -1895,7 +2182,7 @@ export class RevnixClient {
1895
2182
  private async readPlacementCache(
1896
2183
  placementKey: string,
1897
2184
  customerId: string
1898
- ): Promise<{ data: PlacementResolution; fetchedAt: number } | null> {
2185
+ ): Promise<PlacementCacheEntry | null> {
1899
2186
  const map = await this.readPlacementMap();
1900
2187
  const entry = map[placementKey];
1901
2188
  if (!entry) return null;
@@ -1913,40 +2200,74 @@ export class RevnixClient {
1913
2200
  return entry;
1914
2201
  }
1915
2202
 
1916
- private async writePlacementCache(
2203
+ private writePlacementCache(
1917
2204
  placementKey: string,
1918
2205
  data: PlacementResolution,
1919
- customerId: string
2206
+ customerId: string,
2207
+ fetchedAt: number
2208
+ ): Promise<void> {
2209
+ return this.mutatePlacements((map) => {
2210
+ map[placementKey] = { data, fetchedAt, customerId };
2211
+ });
2212
+ }
2213
+
2214
+ private deletePlacementCache(placementKey: string): Promise<void> {
2215
+ return this.mutatePlacements((map) => {
2216
+ delete map[placementKey];
2217
+ });
2218
+ }
2219
+
2220
+ private mutatePlacements(
2221
+ mutate: (map: Record<string, PlacementCacheEntry>) => void
1920
2222
  ): Promise<void> {
2223
+ return this.withStorageLock(async () => {
2224
+ try {
2225
+ const map = await this.readPlacementMap();
2226
+ mutate(map);
2227
+ await this.storage.setItem(PLACEMENTS_CACHE_KEY, JSON.stringify(map));
2228
+ } catch (err) {
2229
+ this.diag("storageWrite", err);
2230
+ }
2231
+ });
2232
+ }
2233
+
2234
+ private async placementsInvalidatedAt(): Promise<number> {
1921
2235
  try {
1922
- const map = await this.readPlacementMap();
1923
- map[placementKey] = { data, fetchedAt: Date.now(), customerId };
1924
- await this.storage.setItem(PLACEMENTS_CACHE_KEY, JSON.stringify(map));
2236
+ return Number(await this.storage.getItem(PLACEMENTS_INVALIDATED_AT_KEY)) || 0;
2237
+ } catch {
2238
+ return 0;
2239
+ }
2240
+ }
2241
+
2242
+ private async invalidatePlacements(): Promise<void> {
2243
+ try {
2244
+ await this.storage.setItem(PLACEMENTS_INVALIDATED_AT_KEY, String(Date.now()));
1925
2245
  } catch (err) {
1926
- // Best-effort, but reported (REV-200 M26).
1927
2246
  this.diag("storageWrite", err);
1928
2247
  }
1929
2248
  }
1930
2249
 
1931
2250
  // ——— C7: persisted purchase retry queue ———
1932
2251
 
1933
- // Serializes every queue read-modify-write. Storage adapters may be async,
1934
- // so two interleaved mutations (an enqueue during the retry pass, retry
1935
- // passes from constructor + a manual call) would otherwise clobber each
1936
- // other's writes and lose purchases.
1937
- private queueLock: Promise<void> = Promise.resolve();
2252
+ private storageLock: Promise<void> = Promise.resolve();
2253
+
2254
+ private withStorageLock<T>(task: () => Promise<T>): Promise<T> {
2255
+ const run = this.storageLock.then(task);
2256
+ this.storageLock = run.then(
2257
+ () => {},
2258
+ () => {}
2259
+ );
2260
+ return run;
2261
+ }
1938
2262
 
1939
2263
  private mutateQueue(
1940
2264
  mutate: (queue: PendingPurchase[]) => PendingPurchase[]
1941
2265
  ): Promise<void> {
1942
- const run = this.queueLock.then(async () => {
2266
+ return this.withStorageLock(async () => {
1943
2267
  const queue = await this.readPurchaseQueue();
1944
2268
  const next = mutate(queue);
1945
2269
  if (next !== queue) await this.writePurchaseQueue(next);
1946
2270
  });
1947
- // The chain must survive a failed mutation or every later one deadlocks.
1948
- this.queueLock = run.catch(() => {});
1949
- return run;
1950
2271
  }
1951
2272
 
1952
2273
  private async readPurchaseQueue(): Promise<PendingPurchase[]> {
@@ -1974,26 +2295,29 @@ export class RevnixClient {
1974
2295
  customerId: string,
1975
2296
  input: RegisterPurchaseInput
1976
2297
  ): Promise<void> {
1977
- // Repeated failures of the same purchase collapse to one queued entry.
1978
- return this.mutateQueue((queue) =>
1979
- queue.some((q) => pendingKey(q.input) === pendingKey(input))
1980
- ? queue
1981
- : [...queue, { customerId, input }]
1982
- );
2298
+ return this.mutateQueue((queue) => {
2299
+ if (queue.some((q) => pendingKey(q.input) === pendingKey(input))) return queue;
2300
+ const kept = queue.slice(Math.max(0, queue.length - PURCHASE_QUEUE_CAP + 1));
2301
+ for (const dropped of queue.slice(0, queue.length - kept.length)) {
2302
+ this.diag("registerPurchase", new Error(`queue full, dropped ${pendingKey(dropped.input)}`));
2303
+ }
2304
+ return [...kept, { customerId, input, queuedAt: Date.now() }];
2305
+ });
1983
2306
  }
1984
2307
 
1985
2308
  private async request<T>(
1986
2309
  method: "GET" | "POST",
1987
2310
  path: string,
1988
2311
  body?: unknown,
1989
- extraHeaders?: Record<string, string>
2312
+ extraHeaders?: Record<string, string>,
2313
+ timeoutMs = this.requestTimeoutMs
1990
2314
  ): Promise<T> {
1991
2315
  // C7: abort the request after requestTimeoutMs so a slow or dead backend
1992
2316
  // can't hang the app. AbortController is guarded for very old runtimes.
1993
2317
  const controller =
1994
2318
  typeof AbortController !== "undefined" ? new AbortController() : undefined;
1995
2319
  const timer = controller
1996
- ? setTimeout(() => controller.abort(), this.requestTimeoutMs)
2320
+ ? setTimeout(() => controller.abort(), timeoutMs)
1997
2321
  : undefined;
1998
2322
 
1999
2323
  // REV-200 (M26): piggyback the background-failure counter on this
@@ -2025,7 +2349,7 @@ export class RevnixClient {
2025
2349
  (err instanceof Error && err.name === "AbortError")
2026
2350
  ) {
2027
2351
  throw new RevnixTimeoutError(
2028
- `Revnix request to ${path} timed out after ${this.requestTimeoutMs}ms`
2352
+ `Revnix request to ${path} timed out after ${timeoutMs}ms`
2029
2353
  );
2030
2354
  }
2031
2355
  throw new RevnixNetworkError(