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/dist/client.js CHANGED
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.RevnixClient = void 0;
4
+ exports.paywallImageUrls = paywallImageUrls;
4
5
  const errors_js_1 = require("./errors.js");
5
6
  const storage_js_1 = require("./storage.js");
6
7
  const device_js_1 = require("./device.js");
@@ -18,7 +19,20 @@ const PENDING_PURCHASES_KEY = "revnix.pendingPurchases";
18
19
  const PLACEMENTS_CACHE_KEY = "revnix.placements";
19
20
  const LAST_ATTRIBUTION_KEY = "revnix.lastAttribution";
20
21
  const LAST_PUSH_TOKEN_KEY = "revnix.lastPushToken";
22
+ const LAST_AD_ID_KEY = "revnix.lastAdId";
23
+ const EVENT_QUEUE_KEY = "revnix.eventQueue";
24
+ const PLACEMENTS_INVALIDATED_AT_KEY = "revnix.placementsInvalidatedAt";
21
25
  const DEFAULT_TIMEOUT_MS = 10000;
26
+ const INTEGRITY_TIMEOUT_MS = 10000;
27
+ const EVENT_QUEUE_CAP = 1000;
28
+ const EVENT_BATCH_SIZE = 100;
29
+ const EVENT_FLUSH_THRESHOLD = 20;
30
+ const EVENT_FLUSH_DELAY_MS = 10000;
31
+ const EVENT_RETRY_HOLD_MS = 30000;
32
+ const PLACEMENT_FRESH_MS = 24 * 60 * 60 * 1000;
33
+ const CACHED_ENTITLEMENTS_TIMEOUT_MS = 3000;
34
+ const PURCHASE_QUEUE_CAP = 100;
35
+ const PURCHASE_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
22
36
  // REV-204 (L10): how many customers keep a cached entitlements snapshot.
23
37
  const ENTITLEMENTS_CACHE_MAX = 4;
24
38
  // REV-204 (L10): http:// is tolerated only for development hosts.
@@ -44,6 +58,38 @@ const DEFAULT_OFFLINE_MAX_CACHE_AGE_MS = 14 * 24 * 60 * 60 * 1000;
44
58
  // — a rolled-back clock. Small negative skew is real (NTP corrections), a
45
59
  // large one is not.
46
60
  const CLOCK_ROLLBACK_TOLERANCE_MS = 5 * 60 * 1000;
61
+ function batchResults(response, path) {
62
+ const results = response?.results;
63
+ if (Array.isArray(results) &&
64
+ results.every((r) => typeof r === "object" && r !== null && typeof r.status === "number")) {
65
+ return results;
66
+ }
67
+ throw new errors_js_1.RevnixError(0, `Revnix: server returned an ill-shaped batch response for ${path}`);
68
+ }
69
+ function paywallImageUrls(value) {
70
+ const found = new Set();
71
+ const visit = (node) => {
72
+ if (Array.isArray(node)) {
73
+ node.forEach(visit);
74
+ return;
75
+ }
76
+ if (typeof node !== "object" || node === null)
77
+ return;
78
+ const o = node;
79
+ const background = o.background;
80
+ for (const url of [
81
+ o.heroImageUrl,
82
+ o.type === "image" ? o.url : undefined,
83
+ background?.image?.url,
84
+ ]) {
85
+ if (typeof url === "string" && url.length > 0)
86
+ found.add(url);
87
+ }
88
+ Object.values(o).forEach(visit);
89
+ };
90
+ visit(value);
91
+ return [...found];
92
+ }
47
93
  // One queue entry per store charge — repeated failures collapse onto it, and
48
94
  // the retry pass removes by this key rather than by array position.
49
95
  const pendingKey = (p) => `${p.source}:${p.token}:${p.transactionId}`;
@@ -58,6 +104,19 @@ const jittered = (ms) => ms * (0.8 + Math.random() * 0.4);
58
104
  // from memory. Short enough that a webhook-driven change is picked up within
59
105
  // half a minute; long enough that gating five screens costs one request.
60
106
  const DEFAULT_ENTITLEMENTS_TTL_MS = 30000;
107
+ const fitsWithin = (value, max) => typeof value === "string" && value.length > 0 && value.length <= max;
108
+ function wellFormedIntegrity(evidence) {
109
+ if (typeof evidence !== "object" || evidence === null)
110
+ return undefined;
111
+ const { platform, keyId, attestation, token } = evidence;
112
+ if (platform === "ios" && fitsWithin(keyId, 128) && fitsWithin(attestation, 16384)) {
113
+ return { platform, keyId, attestation };
114
+ }
115
+ if (platform === "android" && fitsWithin(token, 16384)) {
116
+ return { platform, token };
117
+ }
118
+ return undefined;
119
+ }
61
120
  class RevnixClient {
62
121
  constructor(config) {
63
122
  /** True while the current id is a generated anon id living on
@@ -70,6 +129,7 @@ class RevnixClient {
70
129
  // client (dev fast-refresh re-running configure()) does not keep retrying
71
130
  // and prefetching alongside its successor.
72
131
  this.disposed = false;
132
+ this.integrityRequest = null;
73
133
  this.deferredDeepLinkClaimed = false;
74
134
  this.landingUrl = null;
75
135
  this.lastDeepLinkWrite = Promise.resolve();
@@ -99,6 +159,9 @@ class RevnixClient {
99
159
  * /v1/installs uses, and independent of the device header (which an app
100
160
  * can turn off entirely). */
101
161
  this.freshInstall = false;
162
+ this.eventFlush = null;
163
+ this.eventTimer = null;
164
+ this.eventRetryAt = 0;
102
165
  // REV-199 (CTO-R2 M21): the last fresh network read, held in memory so
103
166
  // reads within the soft TTL answer without a round trip — apps gate many
104
167
  // screens on isEntitled(), and every one of those was a full request for an
@@ -108,11 +171,7 @@ class RevnixClient {
108
171
  // REV-199 (M21): concurrent getEntitlements() calls share one request.
109
172
  this.inflightEntitlements = null;
110
173
  // ——— C7: persisted purchase retry queue ———
111
- // Serializes every queue read-modify-write. Storage adapters may be async,
112
- // so two interleaved mutations (an enqueue during the retry pass, retry
113
- // passes from constructor + a manual call) would otherwise clobber each
114
- // other's writes and lose purchases.
115
- this.queueLock = Promise.resolve();
174
+ this.storageLock = Promise.resolve();
116
175
  if (!config.apiKey)
117
176
  throw new Error("Revnix: apiKey is required");
118
177
  if (!config.baseUrl)
@@ -147,12 +206,15 @@ class RevnixClient {
147
206
  this.onDiagnostic = config.onDiagnostic;
148
207
  this.onDeferredDeepLink = config.onDeferredDeepLink;
149
208
  this.onAttribution = config.onAttribution;
209
+ this.deviceIntegrity = config.deviceIntegrity;
150
210
  this.readYourWritesDelaysMs =
151
211
  config.readYourWritesDelaysMs ?? [250, 500, 1000, 2000];
152
212
  this.customerIdPromise = this.loadCustomerId(config.customerId);
153
213
  // C7: retry any purchases that failed to register on a previous launch, so
154
214
  // a paying user never stays locked out because a POST didn't land.
155
215
  void this.retryPendingPurchases().catch((err) => this.diag("retryPendingPurchases", err));
216
+ if (config.advertisingId)
217
+ this.reportAdvertisingId(config.advertisingId);
156
218
  const store = (0, store_js_1.getStoreAdapter)();
157
219
  if (store) {
158
220
  this.stopStoreObserver = store.observe((tx) => void this.registerStoreTransaction(store, tx).catch((err) => this.diag("storeObserver", err)));
@@ -171,8 +233,13 @@ class RevnixClient {
171
233
  config.implicitPlacements ?? config.onImplicitPaywall !== undefined;
172
234
  this.sessionTimeoutMs =
173
235
  config.sessionTimeoutMs ?? implicit_js_1.DEFAULT_SESSION_TIMEOUT_MS;
236
+ this.subscribeLifecycle(config.lifecycle);
174
237
  if (this.implicitEnabled)
175
- this.startImplicit(config.lifecycle);
238
+ this.startImplicit();
239
+ void this.readEvents().then((events) => {
240
+ if (events.length > 0)
241
+ this.startEventFlush();
242
+ });
176
243
  const { document, location } = globalThis;
177
244
  if (document &&
178
245
  location &&
@@ -258,6 +325,7 @@ class RevnixClient {
258
325
  const customerId = await this.customerIdPromise;
259
326
  try {
260
327
  const result = await this.postPurchase(customerId, input);
328
+ await this.invalidatePlacements();
261
329
  // Identity resolution may have merged this device's id away (restore
262
330
  // after login), so the server's id is the one to keep using.
263
331
  //
@@ -367,25 +435,30 @@ class RevnixClient {
367
435
  }
368
436
  /** C7: retry purchases that failed to register and were persisted locally.
369
437
  * Runs automatically at construction; call again when connectivity returns.
370
- * Idempotent and safe to call repeatedly. */
438
+ * Idempotent and safe to call repeatedly. Purchases queued more than 30
439
+ * days ago are dropped with a diagnostic. */
371
440
  async retryPendingPurchases() {
441
+ const now = Date.now();
442
+ await this.mutateQueue((queue) => queue.some((q) => q.queuedAt === undefined)
443
+ ? queue.map((q) => ({ ...q, queuedAt: q.queuedAt ?? now }))
444
+ : queue);
372
445
  const queue = await this.readPurchaseQueue();
373
446
  for (const item of queue) {
374
447
  if (this.disposed)
375
448
  return;
376
- try {
377
- await this.postPurchase(item.customerId, item.input);
378
- // Delivered — remove it.
449
+ if (now - (item.queuedAt ?? now) > PURCHASE_MAX_AGE_MS) {
450
+ this.diag("retryPendingPurchases", new Error(`expired ${pendingKey(item.input)}`));
379
451
  }
380
- catch (err) {
381
- if (worthQueueing(err)) {
382
- // REV-200 (M26): a queued purchase that STILL cannot land is the
383
- // most important background failure there is — report it.
452
+ else {
453
+ try {
454
+ await this.postPurchase(item.customerId, item.input);
455
+ await this.invalidatePlacements();
456
+ }
457
+ catch (err) {
384
458
  this.diag("retryPendingPurchases", err);
385
- continue; // transient — keep for next time
459
+ if (worthQueueing(err))
460
+ continue;
386
461
  }
387
- // Permanent rejection — remove it.
388
- this.diag("retryPendingPurchases", err);
389
462
  }
390
463
  // Remove THIS item from the queue as it stands now, not from the
391
464
  // snapshot iterated above: each postPurchase can spend seconds in a
@@ -427,16 +500,19 @@ class RevnixClient {
427
500
  }
428
501
  /** The authoritative network read: no TTL, no cache fallback — throws so
429
502
  * the caller sees the real failure (waitForEntitlements needs 429s). */
430
- async fetchFreshEntitlements(customerId) {
431
- const fresh = await this.request("GET", `/v1/customers/${encodeURIComponent(customerId)}/entitlements`);
503
+ async fetchFreshEntitlements(customerId, timeoutMs) {
504
+ const fresh = await this.request("GET", `/v1/customers/${encodeURIComponent(customerId)}/entitlements`, undefined, undefined, timeoutMs);
432
505
  await this.writeEntitlementsCache(customerId, fresh);
433
506
  const result = { ...fresh, stale: false, fetchedAt: Date.now() };
434
507
  this.freshEntitlements = { customerId, data: result, at: Date.now() };
435
508
  return result;
436
509
  }
437
510
  async fetchEntitlementsWithFallback(customerId) {
511
+ const hasCache = (await this.readEntitlementsCache(customerId)) !== null;
438
512
  try {
439
- return await this.fetchFreshEntitlements(customerId);
513
+ return await this.fetchFreshEntitlements(customerId, hasCache
514
+ ? Math.min(CACHED_ENTITLEMENTS_TIMEOUT_MS, this.requestTimeoutMs)
515
+ : undefined);
440
516
  }
441
517
  catch (err) {
442
518
  // REV-198 (CTO-R2 M22): the cache exists to ride out failures that
@@ -547,35 +623,28 @@ class RevnixClient {
547
623
  return await this.request("GET", `/v1/customers/${encodeURIComponent(customerId)}/identity`);
548
624
  }
549
625
  /** Resolve a named in-app location to the currently published offering —
550
- * the one-line paywall call. REV-157: reads the network first and caches
551
- * the result; if that read FAILS (offline / incident) it falls back to the
552
- * last cached resolution with `stale: true`, so the paywall renders the
553
- * last published version rather than not at all. Throws only when there
554
- * is nothing cached. */
626
+ * the one-line paywall call. Served instantly from a cache under 24 h old
627
+ * that was fetched for this customer since the last `setAttributes` or
628
+ * purchase, refreshed in the background. Otherwise reads the network and
629
+ * caches the result; if that read FAILS (offline / incident) it falls back
630
+ * to the last cached resolution with `stale: true`, so the paywall renders
631
+ * the last published version rather than not at all. Throws only when
632
+ * there is nothing cached. A deliberate rejection seen by the background
633
+ * refresh drops the cache, so the next resolve throws it. */
555
634
  async resolvePlacement(placementKey) {
556
635
  const customerId = await this.customerIdPromise;
636
+ const cached = await this.readPlacementCache(placementKey, customerId);
637
+ const age = Date.now() - (cached?.fetchedAt ?? 0);
638
+ if (cached &&
639
+ cached.customerId === customerId &&
640
+ cached.fetchedAt > (await this.placementsInvalidatedAt()) &&
641
+ age >= 0 &&
642
+ age <= PLACEMENT_FRESH_MS) {
643
+ void this.refreshPlacement(placementKey);
644
+ return { ...cached.data, stale: false, fetchedAt: cached.fetchedAt };
645
+ }
557
646
  try {
558
- // REV-219: identify the caller so a running experiment on this
559
- // placement can assign a (sticky, deterministic) variant. Servers
560
- // without experiments simply ignore the parameter. An EPHEMERAL anon
561
- // id (memory-only storage — new id every launch) is deliberately NOT
562
- // sent: each launch would enroll a phantom customer and flip the same
563
- // user between variants; the server serves the placement default
564
- // when no customer is identified (same guard class as M5 installs).
565
- const path = `/v1/placements/${encodeURIComponent(placementKey)}/offering`;
566
- // REV-268: the device facts ride along so the server can evaluate
567
- // targeting rules against THIS device on THIS request, and store them
568
- // as device.* attributes. Not sent for an ephemeral anon id: the server
569
- // would have no customer to keep them on, and it serves such callers
570
- // the untargeted default anyway.
571
- const deviceHeader = this.ephemeralAnonId
572
- ? null
573
- : await this.deviceHeaderPromise;
574
- const fresh = await this.request("GET", this.ephemeralAnonId
575
- ? path
576
- : `${path}?customer=${encodeURIComponent(customerId)}`, undefined, deviceHeader ? { [device_js_1.DEVICE_HEADER]: deviceHeader } : undefined);
577
- await this.writePlacementCache(placementKey, fresh, customerId);
578
- return { ...fresh, stale: false, fetchedAt: Date.now() };
647
+ return await this.fetchPlacement(placementKey);
579
648
  }
580
649
  catch (err) {
581
650
  // REV-198 (M22): same rule as getEntitlements — an unpublished catalog
@@ -584,13 +653,44 @@ class RevnixClient {
584
653
  // fleet-wide with no way to pull it.
585
654
  if (err instanceof errors_js_1.RevnixError && !err.isRetryable)
586
655
  throw err;
587
- const cached = await this.readPlacementCache(placementKey, customerId);
588
- if (cached) {
589
- return { ...cached.data, stale: true, fetchedAt: cached.fetchedAt };
656
+ const fallback = await this.readPlacementCache(placementKey, customerId);
657
+ if (fallback) {
658
+ return { ...fallback.data, stale: true, fetchedAt: fallback.fetchedAt };
590
659
  }
591
660
  throw err;
592
661
  }
593
662
  }
663
+ /** Fetch these placements now so the next `resolvePlacement` answers from
664
+ * the cache. Never throws; failures report to `onDiagnostic`. Paywall
665
+ * images are warmed by `prefetchPaywallImages` from
666
+ * `react-native-revnix/ui`. */
667
+ async preload(placementKeys) {
668
+ await Promise.all(placementKeys.map((key) => this.refreshPlacement(key)));
669
+ }
670
+ async refreshPlacement(placementKey) {
671
+ try {
672
+ await this.fetchPlacement(placementKey);
673
+ }
674
+ catch (err) {
675
+ if (err instanceof errors_js_1.RevnixError && !err.isRetryable) {
676
+ await this.deletePlacementCache(placementKey);
677
+ }
678
+ this.diag("resolvePlacement", err);
679
+ }
680
+ }
681
+ async fetchPlacement(placementKey) {
682
+ const customerId = await this.customerIdPromise;
683
+ const fetchedAt = Date.now();
684
+ const path = `/v1/placements/${encodeURIComponent(placementKey)}/offering`;
685
+ const deviceHeader = this.ephemeralAnonId
686
+ ? null
687
+ : await this.deviceHeaderPromise;
688
+ const fresh = await this.request("GET", this.ephemeralAnonId
689
+ ? path
690
+ : `${path}?customer=${encodeURIComponent(customerId)}`, undefined, deviceHeader ? { [device_js_1.DEVICE_HEADER]: deviceHeader } : undefined);
691
+ await this.writePlacementCache(placementKey, fresh, customerId, fetchedAt);
692
+ return { ...fresh, stale: false, fetchedAt: Date.now() };
693
+ }
594
694
  /** REV-157: the last resolution cached for `placementKey`, with NO network
595
695
  * call — for an instant, offline-safe paywall at launch. Always
596
696
  * `stale: true`; follow with resolvePlacement() to revalidate. Null if
@@ -684,7 +784,8 @@ class RevnixClient {
684
784
  * - `restore` — restore purchases was tapped
685
785
  * - `error` — the paywall itself failed (config, products, render)
686
786
  *
687
- * Fire-and-forget like the other beacons: never blocks, never throws.
787
+ * Queued and sent in batches (see `flushEvents`): resolves once queued,
788
+ * never throws.
688
789
  *
689
790
  * `viewId` is the id `logPaywallShown` returned for THIS display. Passing
690
791
  * it is what threads the whole life of one impression together and puts
@@ -701,31 +802,29 @@ class RevnixClient {
701
802
  * which caps the report at one per display per event type. Pass one per
702
803
  * occurrence (and reuse it across your own retries) to record every
703
804
  * occurrence — e.g. a customer who fails, retries and fails again. */
704
- logPaywallEvent(event, viewId, options) {
805
+ async logPaywallEvent(event, viewId, options) {
705
806
  if (options?.placementKey === implicit_js_1.PREVIEW_PLACEMENT_KEY)
706
807
  return;
707
- void this.customerIdPromise
708
- .then((customerId) => this.request("POST", "/v1/paywalls/events", {
709
- customerId,
710
- viewId,
711
- event,
712
- occurredAt: Date.now(),
713
- sdkVersion: version_js_1.SDK_VERSION,
714
- ...(options?.eventId ? { eventId: options.eventId } : {}),
715
- ...(options?.placementKey ? { placementKey: options.placementKey } : {}),
716
- ...(options?.paywallId ? { paywallId: options.paywallId } : {}),
717
- ...(options?.productId ? { productId: options.productId } : {}),
718
- ...(options?.code ? { code: options.code } : {}),
719
- ...(options?.message
720
- ? { message: options.message.slice(0, 1024) }
721
- : {}),
722
- }))
723
- .catch((err) => this.diag("logPaywallEvent", err));
724
- // REV-272: backing out of the store sheet is the `transaction_abandon`
725
- // moment. Reuses the paywall.purchase_abandoned event just reported.
726
808
  if (event === "purchase_abandoned") {
727
809
  this.fireImplicitFromPaywall("transaction_abandon", viewId, options?.paywallId);
728
810
  }
811
+ const customerId = await this.customerIdPromise;
812
+ await this.enqueueEvent({
813
+ path: "/v1/paywalls/events",
814
+ body: {
815
+ customerId,
816
+ viewId,
817
+ event,
818
+ eventId: options?.eventId ?? viewId,
819
+ occurredAt: Date.now(),
820
+ sdkVersion: version_js_1.SDK_VERSION,
821
+ ...(options?.placementKey ? { placementKey: options.placementKey } : {}),
822
+ ...(options?.paywallId ? { paywallId: options.paywallId } : {}),
823
+ ...(options?.productId ? { productId: options.productId } : {}),
824
+ ...(options?.code ? { code: options.code } : {}),
825
+ ...(options?.message ? { message: options.message.slice(0, 1024) } : {}),
826
+ },
827
+ });
729
828
  }
730
829
  /** Reports one ad impression's payout (POST /v1/ad-revenue).
731
830
  * Fire-and-forget like the other beacons: never blocks, never throws. */
@@ -750,22 +849,124 @@ class RevnixClient {
750
849
  .catch((err) => this.diag("logAdRevenue", err));
751
850
  }
752
851
  /** Reports one custom in-app event (POST /v1/events), not for purchases.
753
- * Fire-and-forget like the other beacons: never blocks, never throws. */
754
- track(event, properties, options) {
852
+ * Queued and sent in batches (see `flushEvents`): resolves once queued,
853
+ * never throws. */
854
+ async track(event, properties, options) {
755
855
  if (!/^[a-z0-9_]{1,64}$/.test(event)) {
756
856
  this.diag("track", new Error(`invalid event name: ${event}`));
757
857
  return;
758
858
  }
759
859
  const eventId = options?.eventId ?? (0, storage_js_1.generateEventId)();
760
- void this.customerIdPromise
761
- .then((customerId) => this.request("POST", "/v1/events", {
762
- customerId,
763
- event,
764
- eventId,
765
- occurredAt: Date.now(),
766
- ...(properties ? { properties } : {}),
767
- }))
768
- .catch((err) => this.diag("track", err));
860
+ const customerId = await this.customerIdPromise;
861
+ await this.enqueueEvent({
862
+ path: "/v1/events",
863
+ body: {
864
+ customerId,
865
+ event,
866
+ eventId,
867
+ occurredAt: Date.now(),
868
+ ...(properties ? { properties } : {}),
869
+ },
870
+ });
871
+ }
872
+ /** Send every queued `track` and paywall interaction event now. Events are
873
+ * otherwise sent in batches: at 20 queued, 10 s after the first, when the
874
+ * app goes to the background, and on the next launch. Never throws; a
875
+ * transient failure keeps the events and holds further sends for 30 s. */
876
+ async flushEvents() {
877
+ await this.eventFlush;
878
+ this.startEventFlush();
879
+ await this.eventFlush;
880
+ }
881
+ async readEvents() {
882
+ try {
883
+ const parsed = JSON.parse((await this.storage.getItem(EVENT_QUEUE_KEY)) ?? "[]");
884
+ return Array.isArray(parsed) ? parsed : [];
885
+ }
886
+ catch {
887
+ return [];
888
+ }
889
+ }
890
+ mutateEvents(mutate) {
891
+ return this.withStorageLock(async () => {
892
+ const next = mutate(await this.readEvents());
893
+ try {
894
+ await this.storage.setItem(EVENT_QUEUE_KEY, JSON.stringify(next));
895
+ }
896
+ catch (err) {
897
+ this.diag("storageWrite", err);
898
+ }
899
+ return next.length;
900
+ });
901
+ }
902
+ // ponytail: the whole queue is re-serialized per event; keep it in memory if 1000-item offline queues show up in profiles.
903
+ async enqueueEvent(event) {
904
+ const count = await this.mutateEvents((events) => {
905
+ const kept = events.slice(Math.max(0, events.length - EVENT_QUEUE_CAP + 1));
906
+ if (kept.length < events.length) {
907
+ this.diag("eventQueue", new Error("queue full, dropped the oldest event"));
908
+ }
909
+ return [...kept, event];
910
+ });
911
+ if (count >= EVENT_FLUSH_THRESHOLD)
912
+ this.startEventFlush();
913
+ else
914
+ this.armEventTimer(EVENT_FLUSH_DELAY_MS);
915
+ }
916
+ armEventTimer(ms) {
917
+ if (this.eventTimer !== null || this.disposed)
918
+ return;
919
+ this.eventTimer = setTimeout(() => {
920
+ this.eventTimer = null;
921
+ this.startEventFlush();
922
+ }, ms);
923
+ }
924
+ startEventFlush() {
925
+ if (this.eventFlush !== null || this.disposed)
926
+ return;
927
+ const hold = this.eventRetryAt - Date.now();
928
+ if (hold > 0) {
929
+ this.armEventTimer(hold);
930
+ return;
931
+ }
932
+ this.eventFlush = this.runEventFlush().finally(() => {
933
+ this.eventFlush = null;
934
+ });
935
+ }
936
+ async runEventFlush() {
937
+ const byPath = new Map();
938
+ for (const event of await this.withStorageLock(() => this.readEvents())) {
939
+ byPath.set(event.path, [...(byPath.get(event.path) ?? []), event]);
940
+ }
941
+ for (const [path, events] of byPath) {
942
+ for (let start = 0; start < events.length; start += EVENT_BATCH_SIZE) {
943
+ const chunk = events.slice(start, start + EVENT_BATCH_SIZE);
944
+ try {
945
+ const response = await this.request("POST", path, {
946
+ events: chunk.map((e) => e.body),
947
+ });
948
+ batchResults(response, path).forEach((result, i) => {
949
+ if (result.status < 400)
950
+ return;
951
+ this.diag("eventQueue", new errors_js_1.RevnixError(result.status, `dropped ${path} event ${String(chunk[i]?.body.eventId)}: ${result.status} ${result.error ?? ""}`));
952
+ });
953
+ await this.removeEvents(chunk);
954
+ }
955
+ catch (err) {
956
+ if (worthQueueing(err)) {
957
+ this.eventRetryAt = Date.now() + EVENT_RETRY_HOLD_MS;
958
+ this.diag("eventQueue", err);
959
+ return;
960
+ }
961
+ await this.removeEvents(chunk);
962
+ this.diag("eventQueue", err);
963
+ }
964
+ }
965
+ }
966
+ }
967
+ async removeEvents(sent) {
968
+ const sentJson = new Set(sent.map((e) => JSON.stringify(e)));
969
+ await this.mutateEvents((events) => events.filter((e) => !sentJson.has(JSON.stringify(e))));
769
970
  }
770
971
  /** PT11: forward an MMP's attribution callback (Adjust, AppsFlyer, …) so
771
972
  * Revnix credits revenue to the right network/campaign. */
@@ -789,6 +990,7 @@ class RevnixClient {
789
990
  ...(attribution.campaign ? { campaign: attribution.campaign } : {}),
790
991
  ...(attribution.adGroup ? { adGroup: attribution.adGroup } : {}),
791
992
  ...(attribution.creative ? { creative: attribution.creative } : {}),
993
+ ...(await this.integrityFor(customerId)),
792
994
  });
793
995
  await this.storage.setItem(LAST_ATTRIBUTION_KEY, payload);
794
996
  })
@@ -822,6 +1024,21 @@ class RevnixClient {
822
1024
  })
823
1025
  .catch((err) => this.diag("setPushToken", err));
824
1026
  }
1027
+ reportAdvertisingId(source) {
1028
+ void Promise.resolve()
1029
+ .then(source)
1030
+ .then(async (value) => {
1031
+ const id = value || null;
1032
+ const customerId = await this.customerIdPromise;
1033
+ const payload = JSON.stringify({ customerId, id });
1034
+ const last = await this.storage.getItem(LAST_AD_ID_KEY);
1035
+ if (last === payload || (id === null && last === null))
1036
+ return;
1037
+ await this.setAttributes({ gps_adid: id });
1038
+ await this.storage.setItem(LAST_AD_ID_KEY, payload);
1039
+ })
1040
+ .catch((err) => this.diag("advertisingId", err));
1041
+ }
825
1042
  /** REV-272: the two moments that happen ON a paywall, with the loop guard
826
1043
  * applied. `fromPaywallId` travels so the server can refuse to hand back
827
1044
  * the very paywall being dismissed — the backstop for a host that reports
@@ -858,6 +1075,7 @@ class RevnixClient {
858
1075
  async setAttributes(attributes) {
859
1076
  const customerId = await this.customerIdPromise;
860
1077
  await this.request("POST", `/v1/customers/${encodeURIComponent(customerId)}/attributes`, { attributes });
1078
+ await this.invalidatePlacements();
861
1079
  }
862
1080
  /** REV-272: hand the SDK the URL that opened your app, from wherever you
863
1081
  * already receive it (Linking.getInitialURL / onNewIntent / your router).
@@ -1043,12 +1261,13 @@ class RevnixClient {
1043
1261
  if (this.ephemeralAnonId)
1044
1262
  return;
1045
1263
  void this.customerIdPromise
1046
- .then((customerId) => this.request("POST", "/v1/installs", {
1264
+ .then(async (customerId) => this.request("POST", "/v1/installs", {
1047
1265
  customerId,
1048
1266
  occurredAt: Date.now(),
1049
1267
  sdkVersion: version_js_1.SDK_VERSION,
1050
1268
  installReferrer: referrer.slice(0, 1024),
1051
1269
  ...this.installMetadata,
1270
+ ...(await this.integrityFor(customerId)),
1052
1271
  }))
1053
1272
  .then((body) => {
1054
1273
  this.refreshAttribution();
@@ -1083,12 +1302,13 @@ class RevnixClient {
1083
1302
  if (this.ephemeralAnonId)
1084
1303
  return Promise.resolve(undefined);
1085
1304
  return this.customerIdPromise
1086
- .then((customerId) => this.request("POST", "/v1/installs", {
1305
+ .then(async (customerId) => this.request("POST", "/v1/installs", {
1087
1306
  customerId,
1088
1307
  occurredAt: Date.now(),
1089
1308
  sdkVersion: version_js_1.SDK_VERSION,
1090
1309
  attributionToken: token.slice(0, 2048),
1091
1310
  ...this.installMetadata,
1311
+ ...(await this.integrityFor(customerId)),
1092
1312
  }))
1093
1313
  .then((body) => {
1094
1314
  this.refreshAttribution();
@@ -1205,6 +1425,19 @@ class RevnixClient {
1205
1425
  await this.storage.setItem(SESSION_STARTED_AT_KEY, String(Date.now()));
1206
1426
  return extra;
1207
1427
  }
1428
+ subscribeLifecycle(configured) {
1429
+ if (configured === false)
1430
+ return;
1431
+ const lifecycle = configured ?? (0, implicit_js_1.browserLifecycle)();
1432
+ if (!lifecycle)
1433
+ return;
1434
+ try {
1435
+ this.lifecycleUnsubscribe = lifecycle.onStateChange((state) => this.onAppState(state));
1436
+ }
1437
+ catch (err) {
1438
+ this.diag("lifecycleSubscribe", err);
1439
+ }
1440
+ }
1208
1441
  /** REV-272: the launch-time moments, plus the foreground listener that
1209
1442
  * `session_start` is built on.
1210
1443
  *
@@ -1213,7 +1446,7 @@ class RevnixClient {
1213
1446
  * install too when this launch generated the anonymous id. All three are
1214
1447
  * independent placements, so an app may have any subset configured and the
1215
1448
  * others cost nothing. */
1216
- startImplicit(configured) {
1449
+ startImplicit() {
1217
1450
  // freshInstall is set inside loadCustomerId, which the constructor kicked
1218
1451
  // off but did not await — so read it after that promise settles rather
1219
1452
  // than now, when it is still false on a genuine first launch.
@@ -1223,22 +1456,6 @@ class RevnixClient {
1223
1456
  // are still reported — a launch is a launch whether or not a paywall
1224
1457
  // showed — but an app that configured all three must not have three
1225
1458
  // paywalls pushed onto its first frame.
1226
- // Subscribed BEFORE the batch, which can take a full network timeout per
1227
- // moment when offline: a customer who backgrounds the app during that
1228
- // window and comes back an hour later is a session, and missing the
1229
- // background transition would lose it.
1230
- if (configured !== false) {
1231
- const lifecycle = configured ?? (0, implicit_js_1.browserLifecycle)();
1232
- if (lifecycle) {
1233
- try {
1234
- this.lifecycleUnsubscribe = lifecycle.onStateChange((state) => this.onAppState(state));
1235
- }
1236
- catch (err) {
1237
- // A broken adapter costs session detection, not the client.
1238
- this.diag("lifecycleSubscribe", err);
1239
- }
1240
- }
1241
- }
1242
1459
  const sessionExtra = this.consumePreviousSessionMs().catch((err) => {
1243
1460
  this.diag("session", err);
1244
1461
  return undefined;
@@ -1273,6 +1490,10 @@ class RevnixClient {
1273
1490
  onAppState(state) {
1274
1491
  if (this.disposed)
1275
1492
  return;
1493
+ if (state === "background")
1494
+ this.startEventFlush();
1495
+ if (!this.implicitEnabled)
1496
+ return;
1276
1497
  if (state === "background") {
1277
1498
  // First report wins: a platform that repeats "background" (a paused
1278
1499
  // Activity re-reporting) must not keep resetting the clock forward.
@@ -1323,6 +1544,9 @@ class RevnixClient {
1323
1544
  this.lifecycleUnsubscribe = null;
1324
1545
  this.stopStoreObserver?.();
1325
1546
  this.stopStoreObserver = null;
1547
+ if (this.eventTimer !== null)
1548
+ clearTimeout(this.eventTimer);
1549
+ this.eventTimer = null;
1326
1550
  }
1327
1551
  /** A block paywall reporting a paint string it could not read. Routed to the
1328
1552
  * same sink as every other swallowed failure, so a host that already wired
@@ -1417,12 +1641,15 @@ class RevnixClient {
1417
1641
  /** Fire-and-forget install report (REV-093). Never blocks startup and
1418
1642
  * never throws — the backend absorbs retries idempotently per id. */
1419
1643
  reportInstall(customerId) {
1420
- void this.request("POST", "/v1/installs", {
1644
+ const occurredAt = Date.now();
1645
+ void this.integrityFor(customerId)
1646
+ .then((integrity) => this.request("POST", "/v1/installs", {
1421
1647
  customerId,
1422
- occurredAt: Date.now(),
1648
+ occurredAt,
1423
1649
  sdkVersion: version_js_1.SDK_VERSION,
1424
1650
  ...this.installMetadata,
1425
- })
1651
+ ...integrity,
1652
+ }))
1426
1653
  .then((body) => {
1427
1654
  if (!this.ephemeralAnonId)
1428
1655
  this.refreshAttribution();
@@ -1430,6 +1657,34 @@ class RevnixClient {
1430
1657
  })
1431
1658
  .catch((err) => this.diag("reportInstall", err));
1432
1659
  }
1660
+ integrityFor(customerId) {
1661
+ const source = this.deviceIntegrity;
1662
+ if (!source)
1663
+ return Promise.resolve({});
1664
+ if (this.integrityRequest?.customerId !== customerId) {
1665
+ let timer;
1666
+ const timeout = new Promise((_, reject) => {
1667
+ timer = setTimeout(() => reject(new Error("device integrity timed out")), INTEGRITY_TIMEOUT_MS);
1668
+ });
1669
+ const body = Promise.race([
1670
+ Promise.resolve().then(() => source(customerId)),
1671
+ timeout,
1672
+ ])
1673
+ .then((evidence) => {
1674
+ const integrity = wellFormedIntegrity(evidence);
1675
+ if (!integrity)
1676
+ throw new Error("device integrity unavailable");
1677
+ return { integrity };
1678
+ })
1679
+ .catch((err) => {
1680
+ this.diag("deviceIntegrity", err);
1681
+ return {};
1682
+ })
1683
+ .finally(() => clearTimeout(timer));
1684
+ this.integrityRequest = { customerId, body };
1685
+ }
1686
+ return this.integrityRequest.body;
1687
+ }
1433
1688
  /** REV-268: assemble and encode the device facts once. Runtime-detected
1434
1689
  * facts first, the app's `device` over them, then the three the SDK owns.
1435
1690
  * `installedAt` is the first launch this storage ever saw — written on
@@ -1590,27 +1845,56 @@ class RevnixClient {
1590
1845
  }
1591
1846
  return entry;
1592
1847
  }
1593
- async writePlacementCache(placementKey, data, customerId) {
1848
+ writePlacementCache(placementKey, data, customerId, fetchedAt) {
1849
+ return this.mutatePlacements((map) => {
1850
+ map[placementKey] = { data, fetchedAt, customerId };
1851
+ });
1852
+ }
1853
+ deletePlacementCache(placementKey) {
1854
+ return this.mutatePlacements((map) => {
1855
+ delete map[placementKey];
1856
+ });
1857
+ }
1858
+ mutatePlacements(mutate) {
1859
+ return this.withStorageLock(async () => {
1860
+ try {
1861
+ const map = await this.readPlacementMap();
1862
+ mutate(map);
1863
+ await this.storage.setItem(PLACEMENTS_CACHE_KEY, JSON.stringify(map));
1864
+ }
1865
+ catch (err) {
1866
+ this.diag("storageWrite", err);
1867
+ }
1868
+ });
1869
+ }
1870
+ async placementsInvalidatedAt() {
1871
+ try {
1872
+ return Number(await this.storage.getItem(PLACEMENTS_INVALIDATED_AT_KEY)) || 0;
1873
+ }
1874
+ catch {
1875
+ return 0;
1876
+ }
1877
+ }
1878
+ async invalidatePlacements() {
1594
1879
  try {
1595
- const map = await this.readPlacementMap();
1596
- map[placementKey] = { data, fetchedAt: Date.now(), customerId };
1597
- await this.storage.setItem(PLACEMENTS_CACHE_KEY, JSON.stringify(map));
1880
+ await this.storage.setItem(PLACEMENTS_INVALIDATED_AT_KEY, String(Date.now()));
1598
1881
  }
1599
1882
  catch (err) {
1600
- // Best-effort, but reported (REV-200 M26).
1601
1883
  this.diag("storageWrite", err);
1602
1884
  }
1603
1885
  }
1886
+ withStorageLock(task) {
1887
+ const run = this.storageLock.then(task);
1888
+ this.storageLock = run.then(() => { }, () => { });
1889
+ return run;
1890
+ }
1604
1891
  mutateQueue(mutate) {
1605
- const run = this.queueLock.then(async () => {
1892
+ return this.withStorageLock(async () => {
1606
1893
  const queue = await this.readPurchaseQueue();
1607
1894
  const next = mutate(queue);
1608
1895
  if (next !== queue)
1609
1896
  await this.writePurchaseQueue(next);
1610
1897
  });
1611
- // The chain must survive a failed mutation or every later one deadlocks.
1612
- this.queueLock = run.catch(() => { });
1613
- return run;
1614
1898
  }
1615
1899
  async readPurchaseQueue() {
1616
1900
  try {
@@ -1635,17 +1919,22 @@ class RevnixClient {
1635
1919
  }
1636
1920
  }
1637
1921
  enqueuePurchase(customerId, input) {
1638
- // Repeated failures of the same purchase collapse to one queued entry.
1639
- return this.mutateQueue((queue) => queue.some((q) => pendingKey(q.input) === pendingKey(input))
1640
- ? queue
1641
- : [...queue, { customerId, input }]);
1922
+ return this.mutateQueue((queue) => {
1923
+ if (queue.some((q) => pendingKey(q.input) === pendingKey(input)))
1924
+ return queue;
1925
+ const kept = queue.slice(Math.max(0, queue.length - PURCHASE_QUEUE_CAP + 1));
1926
+ for (const dropped of queue.slice(0, queue.length - kept.length)) {
1927
+ this.diag("registerPurchase", new Error(`queue full, dropped ${pendingKey(dropped.input)}`));
1928
+ }
1929
+ return [...kept, { customerId, input, queuedAt: Date.now() }];
1930
+ });
1642
1931
  }
1643
- async request(method, path, body, extraHeaders) {
1932
+ async request(method, path, body, extraHeaders, timeoutMs = this.requestTimeoutMs) {
1644
1933
  // C7: abort the request after requestTimeoutMs so a slow or dead backend
1645
1934
  // can't hang the app. AbortController is guarded for very old runtimes.
1646
1935
  const controller = typeof AbortController !== "undefined" ? new AbortController() : undefined;
1647
1936
  const timer = controller
1648
- ? setTimeout(() => controller.abort(), this.requestTimeoutMs)
1937
+ ? setTimeout(() => controller.abort(), timeoutMs)
1649
1938
  : undefined;
1650
1939
  // REV-200 (M26): piggyback the background-failure counter on this
1651
1940
  // request so field pain reaches the backend's request logs. Cleared only
@@ -1674,7 +1963,7 @@ class RevnixClient {
1674
1963
  catch (err) {
1675
1964
  if (controller?.signal.aborted ||
1676
1965
  (err instanceof Error && err.name === "AbortError")) {
1677
- throw new errors_js_1.RevnixTimeoutError(`Revnix request to ${path} timed out after ${this.requestTimeoutMs}ms`);
1966
+ throw new errors_js_1.RevnixTimeoutError(`Revnix request to ${path} timed out after ${timeoutMs}ms`);
1678
1967
  }
1679
1968
  throw new errors_js_1.RevnixNetworkError(err instanceof Error ? err.message : "network request failed");
1680
1969
  }