react-native-revnix 1.4.2 → 1.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +138 -713
- package/android/build.gradle +34 -0
- package/android/src/main/AndroidManifest.xml +1 -0
- package/android/src/main/kotlin/com/revnix/reactnative/RevnixStoreModule.kt +247 -0
- package/android/src/main/kotlin/com/revnix/reactnative/RevnixStorePackage.kt +14 -0
- package/dist/client.d.ts +58 -11
- package/dist/client.js +403 -114
- package/dist/esm/client.js +402 -114
- package/dist/esm/index.js +1 -0
- package/dist/esm/store/index.js +22 -0
- package/dist/esm/store.js +9 -0
- package/dist/esm/ui/RevnixPaywall.js +42 -8
- package/dist/esm/ui/index.js +1 -1
- package/dist/esm/version.js +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +3 -1
- package/dist/store/index.d.ts +2 -0
- package/dist/store/index.js +25 -0
- package/dist/store.d.ts +30 -0
- package/dist/store.js +13 -0
- package/dist/ui/RevnixPaywall.d.ts +17 -3
- package/dist/ui/RevnixPaywall.js +43 -8
- package/dist/ui/index.d.ts +1 -1
- package/dist/ui/index.js +2 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/ios/RevnixStore.m +18 -0
- package/ios/RevnixStore.swift +106 -0
- package/package.json +20 -2
- package/react-native-revnix.podspec +22 -0
- package/src/client.ts +475 -146
- package/src/index.ts +5 -0
- package/src/store/index.ts +44 -0
- package/src/store.ts +41 -0
- package/src/ui/RevnixPaywall.tsx +68 -17
- package/src/ui/index.ts +1 -0
- package/src/version.ts +1 -1
- package/store.d.ts +1 -0
- package/store.js +1 -0
package/dist/esm/client.js
CHANGED
|
@@ -2,6 +2,7 @@ import { PurchaseBlockedError, RevnixAuthError, RevnixError, RevnixNetworkError,
|
|
|
2
2
|
import { generateAnonymousId, generateEventId, generateViewId, resolveStorage, } from "./storage.js";
|
|
3
3
|
import { DEVICE_HEADER, INSTALLED_AT_KEY, compactPayload, detectDeviceFacts, encodeDeviceHeader, } from "./device.js";
|
|
4
4
|
import { SDK_VERSION } from "./version.js";
|
|
5
|
+
import { getStoreAdapter, } from "./store.js";
|
|
5
6
|
import { browserLifecycle, DEFAULT_SESSION_TIMEOUT_MS, IMPLICIT_CONFIG_RETRY_MS, IMPLICIT_PLACEMENTS, PREVIEW_PLACEMENT_KEY, } from "./implicit.js";
|
|
6
7
|
const CUSTOMER_ID_KEY = "revnix.customerId";
|
|
7
8
|
const SESSION_STARTED_AT_KEY = "revnix.sessionStartedAt";
|
|
@@ -14,7 +15,18 @@ const PENDING_PURCHASES_KEY = "revnix.pendingPurchases";
|
|
|
14
15
|
const PLACEMENTS_CACHE_KEY = "revnix.placements";
|
|
15
16
|
const LAST_ATTRIBUTION_KEY = "revnix.lastAttribution";
|
|
16
17
|
const LAST_PUSH_TOKEN_KEY = "revnix.lastPushToken";
|
|
18
|
+
const EVENT_QUEUE_KEY = "revnix.eventQueue";
|
|
19
|
+
const PLACEMENTS_INVALIDATED_AT_KEY = "revnix.placementsInvalidatedAt";
|
|
17
20
|
const DEFAULT_TIMEOUT_MS = 10000;
|
|
21
|
+
const EVENT_QUEUE_CAP = 1000;
|
|
22
|
+
const EVENT_BATCH_SIZE = 100;
|
|
23
|
+
const EVENT_FLUSH_THRESHOLD = 20;
|
|
24
|
+
const EVENT_FLUSH_DELAY_MS = 10000;
|
|
25
|
+
const EVENT_RETRY_HOLD_MS = 30000;
|
|
26
|
+
const PLACEMENT_FRESH_MS = 24 * 60 * 60 * 1000;
|
|
27
|
+
const CACHED_ENTITLEMENTS_TIMEOUT_MS = 3000;
|
|
28
|
+
const PURCHASE_QUEUE_CAP = 100;
|
|
29
|
+
const PURCHASE_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
|
|
18
30
|
// REV-204 (L10): how many customers keep a cached entitlements snapshot.
|
|
19
31
|
const ENTITLEMENTS_CACHE_MAX = 4;
|
|
20
32
|
// REV-204 (L10): http:// is tolerated only for development hosts.
|
|
@@ -40,6 +52,38 @@ const DEFAULT_OFFLINE_MAX_CACHE_AGE_MS = 14 * 24 * 60 * 60 * 1000;
|
|
|
40
52
|
// — a rolled-back clock. Small negative skew is real (NTP corrections), a
|
|
41
53
|
// large one is not.
|
|
42
54
|
const CLOCK_ROLLBACK_TOLERANCE_MS = 5 * 60 * 1000;
|
|
55
|
+
function batchResults(response, path) {
|
|
56
|
+
const results = response?.results;
|
|
57
|
+
if (Array.isArray(results) &&
|
|
58
|
+
results.every((r) => typeof r === "object" && r !== null && typeof r.status === "number")) {
|
|
59
|
+
return results;
|
|
60
|
+
}
|
|
61
|
+
throw new RevnixError(0, `Revnix: server returned an ill-shaped batch response for ${path}`);
|
|
62
|
+
}
|
|
63
|
+
export function paywallImageUrls(value) {
|
|
64
|
+
const found = new Set();
|
|
65
|
+
const visit = (node) => {
|
|
66
|
+
if (Array.isArray(node)) {
|
|
67
|
+
node.forEach(visit);
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
if (typeof node !== "object" || node === null)
|
|
71
|
+
return;
|
|
72
|
+
const o = node;
|
|
73
|
+
const background = o.background;
|
|
74
|
+
for (const url of [
|
|
75
|
+
o.heroImageUrl,
|
|
76
|
+
o.type === "image" ? o.url : undefined,
|
|
77
|
+
background?.image?.url,
|
|
78
|
+
]) {
|
|
79
|
+
if (typeof url === "string" && url.length > 0)
|
|
80
|
+
found.add(url);
|
|
81
|
+
}
|
|
82
|
+
Object.values(o).forEach(visit);
|
|
83
|
+
};
|
|
84
|
+
visit(value);
|
|
85
|
+
return [...found];
|
|
86
|
+
}
|
|
43
87
|
// One queue entry per store charge — repeated failures collapse onto it, and
|
|
44
88
|
// the retry pass removes by this key rather than by array position.
|
|
45
89
|
const pendingKey = (p) => `${p.source}:${p.token}:${p.transactionId}`;
|
|
@@ -72,6 +116,7 @@ export class RevnixClient {
|
|
|
72
116
|
this.implicitConfigPromise = null;
|
|
73
117
|
this.implicitConfigRetryAt = 0;
|
|
74
118
|
this.lifecycleUnsubscribe = null;
|
|
119
|
+
this.stopStoreObserver = null;
|
|
75
120
|
/** The cold-start batch while it runs, resolving to whether it presented a
|
|
76
121
|
* paywall. A deep link delivered on the first frame — SwiftUI's onOpenURL,
|
|
77
122
|
* RN's getInitialURL — must wait for it, or the customer gets the launch
|
|
@@ -94,6 +139,9 @@ export class RevnixClient {
|
|
|
94
139
|
* /v1/installs uses, and independent of the device header (which an app
|
|
95
140
|
* can turn off entirely). */
|
|
96
141
|
this.freshInstall = false;
|
|
142
|
+
this.eventFlush = null;
|
|
143
|
+
this.eventTimer = null;
|
|
144
|
+
this.eventRetryAt = 0;
|
|
97
145
|
// REV-199 (CTO-R2 M21): the last fresh network read, held in memory so
|
|
98
146
|
// reads within the soft TTL answer without a round trip — apps gate many
|
|
99
147
|
// screens on isEntitled(), and every one of those was a full request for an
|
|
@@ -103,11 +151,7 @@ export class RevnixClient {
|
|
|
103
151
|
// REV-199 (M21): concurrent getEntitlements() calls share one request.
|
|
104
152
|
this.inflightEntitlements = null;
|
|
105
153
|
// ——— C7: persisted purchase retry queue ———
|
|
106
|
-
|
|
107
|
-
// so two interleaved mutations (an enqueue during the retry pass, retry
|
|
108
|
-
// passes from constructor + a manual call) would otherwise clobber each
|
|
109
|
-
// other's writes and lose purchases.
|
|
110
|
-
this.queueLock = Promise.resolve();
|
|
154
|
+
this.storageLock = Promise.resolve();
|
|
111
155
|
if (!config.apiKey)
|
|
112
156
|
throw new Error("Revnix: apiKey is required");
|
|
113
157
|
if (!config.baseUrl)
|
|
@@ -148,6 +192,10 @@ export class RevnixClient {
|
|
|
148
192
|
// C7: retry any purchases that failed to register on a previous launch, so
|
|
149
193
|
// a paying user never stays locked out because a POST didn't land.
|
|
150
194
|
void this.retryPendingPurchases().catch((err) => this.diag("retryPendingPurchases", err));
|
|
195
|
+
const store = getStoreAdapter();
|
|
196
|
+
if (store) {
|
|
197
|
+
this.stopStoreObserver = store.observe((tx) => void this.registerStoreTransaction(store, tx).catch((err) => this.diag("storeObserver", err)));
|
|
198
|
+
}
|
|
151
199
|
// REV-157: warm the placement cache at launch, so the first paywall of the
|
|
152
200
|
// session can render offline. Fire-and-forget; a failed key just stays on
|
|
153
201
|
// whatever an earlier launch cached.
|
|
@@ -162,8 +210,13 @@ export class RevnixClient {
|
|
|
162
210
|
config.implicitPlacements ?? config.onImplicitPaywall !== undefined;
|
|
163
211
|
this.sessionTimeoutMs =
|
|
164
212
|
config.sessionTimeoutMs ?? DEFAULT_SESSION_TIMEOUT_MS;
|
|
213
|
+
this.subscribeLifecycle(config.lifecycle);
|
|
165
214
|
if (this.implicitEnabled)
|
|
166
|
-
this.startImplicit(
|
|
215
|
+
this.startImplicit();
|
|
216
|
+
void this.readEvents().then((events) => {
|
|
217
|
+
if (events.length > 0)
|
|
218
|
+
this.startEventFlush();
|
|
219
|
+
});
|
|
167
220
|
const { document, location } = globalThis;
|
|
168
221
|
if (document &&
|
|
169
222
|
location &&
|
|
@@ -249,6 +302,7 @@ export class RevnixClient {
|
|
|
249
302
|
const customerId = await this.customerIdPromise;
|
|
250
303
|
try {
|
|
251
304
|
const result = await this.postPurchase(customerId, input);
|
|
305
|
+
await this.invalidatePlacements();
|
|
252
306
|
// Identity resolution may have merged this device's id away (restore
|
|
253
307
|
// after login), so the server's id is the one to keep using.
|
|
254
308
|
//
|
|
@@ -290,6 +344,66 @@ export class RevnixClient {
|
|
|
290
344
|
throw err;
|
|
291
345
|
}
|
|
292
346
|
}
|
|
347
|
+
/** Buys `productId` through the native store (StoreKit 2 / Play Billing),
|
|
348
|
+
* registers it with Revnix, then finishes the store transaction. Resolves
|
|
349
|
+
* null when the customer cancelled or the purchase is pending approval.
|
|
350
|
+
* Needs `import "react-native-revnix/store"` before configure(). A refused
|
|
351
|
+
* claim (PurchaseBlockedError) is never finished, so Google refunds it. */
|
|
352
|
+
async purchase(productId, opts) {
|
|
353
|
+
const store = this.requireStore();
|
|
354
|
+
const customerId = await this.customerIdPromise;
|
|
355
|
+
const tx = await store.purchase(productId, { customerId });
|
|
356
|
+
if (!tx)
|
|
357
|
+
return null;
|
|
358
|
+
return this.registerStoreTransaction(store, tx, opts);
|
|
359
|
+
}
|
|
360
|
+
/** Re-registers every purchase the store still holds for this device and
|
|
361
|
+
* resolves how many registered. Per-purchase failures go to onDiagnostic. */
|
|
362
|
+
async restore() {
|
|
363
|
+
const store = this.requireStore();
|
|
364
|
+
let restored = 0;
|
|
365
|
+
for (const tx of await store.restore()) {
|
|
366
|
+
try {
|
|
367
|
+
await this.registerStoreTransaction(store, tx, undefined, tx.store === "google");
|
|
368
|
+
restored += 1;
|
|
369
|
+
}
|
|
370
|
+
catch (err) {
|
|
371
|
+
this.diag("storeRestore", err);
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
return restored;
|
|
375
|
+
}
|
|
376
|
+
requireStore() {
|
|
377
|
+
const store = getStoreAdapter();
|
|
378
|
+
if (!store) {
|
|
379
|
+
throw new Error('Revnix: native purchases need `import "react-native-revnix/store"` before Revnix.configure(...) and a rebuilt app (pod install / Expo development build)');
|
|
380
|
+
}
|
|
381
|
+
return store;
|
|
382
|
+
}
|
|
383
|
+
async registerStoreTransaction(store, tx, opts, finish = true) {
|
|
384
|
+
const finishTx = () => finish
|
|
385
|
+
? store.finish(tx.transactionId).catch((err) => this.diag("storeFinish", err))
|
|
386
|
+
: undefined;
|
|
387
|
+
try {
|
|
388
|
+
const result = await this.registerPurchase({
|
|
389
|
+
source: tx.store,
|
|
390
|
+
token: tx.token,
|
|
391
|
+
productId: tx.productId,
|
|
392
|
+
transactionId: tx.transactionId,
|
|
393
|
+
occurredAt: tx.occurredAt,
|
|
394
|
+
expiresAt: tx.expiresAt,
|
|
395
|
+
signedTransactionInfo: tx.signedTransactionInfo,
|
|
396
|
+
...opts,
|
|
397
|
+
});
|
|
398
|
+
await finishTx();
|
|
399
|
+
return result;
|
|
400
|
+
}
|
|
401
|
+
catch (err) {
|
|
402
|
+
if (!(err instanceof PurchaseBlockedError))
|
|
403
|
+
await finishTx();
|
|
404
|
+
throw err;
|
|
405
|
+
}
|
|
406
|
+
}
|
|
293
407
|
postPurchase(customerId, input) {
|
|
294
408
|
return this.request("POST", "/v1/purchases", {
|
|
295
409
|
customerId,
|
|
@@ -298,25 +412,30 @@ export class RevnixClient {
|
|
|
298
412
|
}
|
|
299
413
|
/** C7: retry purchases that failed to register and were persisted locally.
|
|
300
414
|
* Runs automatically at construction; call again when connectivity returns.
|
|
301
|
-
* Idempotent and safe to call repeatedly.
|
|
415
|
+
* Idempotent and safe to call repeatedly. Purchases queued more than 30
|
|
416
|
+
* days ago are dropped with a diagnostic. */
|
|
302
417
|
async retryPendingPurchases() {
|
|
418
|
+
const now = Date.now();
|
|
419
|
+
await this.mutateQueue((queue) => queue.some((q) => q.queuedAt === undefined)
|
|
420
|
+
? queue.map((q) => ({ ...q, queuedAt: q.queuedAt ?? now }))
|
|
421
|
+
: queue);
|
|
303
422
|
const queue = await this.readPurchaseQueue();
|
|
304
423
|
for (const item of queue) {
|
|
305
424
|
if (this.disposed)
|
|
306
425
|
return;
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
// Delivered — remove it.
|
|
426
|
+
if (now - (item.queuedAt ?? now) > PURCHASE_MAX_AGE_MS) {
|
|
427
|
+
this.diag("retryPendingPurchases", new Error(`expired ${pendingKey(item.input)}`));
|
|
310
428
|
}
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
429
|
+
else {
|
|
430
|
+
try {
|
|
431
|
+
await this.postPurchase(item.customerId, item.input);
|
|
432
|
+
await this.invalidatePlacements();
|
|
433
|
+
}
|
|
434
|
+
catch (err) {
|
|
315
435
|
this.diag("retryPendingPurchases", err);
|
|
316
|
-
|
|
436
|
+
if (worthQueueing(err))
|
|
437
|
+
continue;
|
|
317
438
|
}
|
|
318
|
-
// Permanent rejection — remove it.
|
|
319
|
-
this.diag("retryPendingPurchases", err);
|
|
320
439
|
}
|
|
321
440
|
// Remove THIS item from the queue as it stands now, not from the
|
|
322
441
|
// snapshot iterated above: each postPurchase can spend seconds in a
|
|
@@ -358,16 +477,19 @@ export class RevnixClient {
|
|
|
358
477
|
}
|
|
359
478
|
/** The authoritative network read: no TTL, no cache fallback — throws so
|
|
360
479
|
* the caller sees the real failure (waitForEntitlements needs 429s). */
|
|
361
|
-
async fetchFreshEntitlements(customerId) {
|
|
362
|
-
const fresh = await this.request("GET", `/v1/customers/${encodeURIComponent(customerId)}/entitlements
|
|
480
|
+
async fetchFreshEntitlements(customerId, timeoutMs) {
|
|
481
|
+
const fresh = await this.request("GET", `/v1/customers/${encodeURIComponent(customerId)}/entitlements`, undefined, undefined, timeoutMs);
|
|
363
482
|
await this.writeEntitlementsCache(customerId, fresh);
|
|
364
483
|
const result = { ...fresh, stale: false, fetchedAt: Date.now() };
|
|
365
484
|
this.freshEntitlements = { customerId, data: result, at: Date.now() };
|
|
366
485
|
return result;
|
|
367
486
|
}
|
|
368
487
|
async fetchEntitlementsWithFallback(customerId) {
|
|
488
|
+
const hasCache = (await this.readEntitlementsCache(customerId)) !== null;
|
|
369
489
|
try {
|
|
370
|
-
return await this.fetchFreshEntitlements(customerId
|
|
490
|
+
return await this.fetchFreshEntitlements(customerId, hasCache
|
|
491
|
+
? Math.min(CACHED_ENTITLEMENTS_TIMEOUT_MS, this.requestTimeoutMs)
|
|
492
|
+
: undefined);
|
|
371
493
|
}
|
|
372
494
|
catch (err) {
|
|
373
495
|
// REV-198 (CTO-R2 M22): the cache exists to ride out failures that
|
|
@@ -478,35 +600,28 @@ export class RevnixClient {
|
|
|
478
600
|
return await this.request("GET", `/v1/customers/${encodeURIComponent(customerId)}/identity`);
|
|
479
601
|
}
|
|
480
602
|
/** Resolve a named in-app location to the currently published offering —
|
|
481
|
-
* the one-line paywall call.
|
|
482
|
-
*
|
|
483
|
-
*
|
|
484
|
-
*
|
|
485
|
-
*
|
|
603
|
+
* the one-line paywall call. Served instantly from a cache under 24 h old
|
|
604
|
+
* that was fetched for this customer since the last `setAttributes` or
|
|
605
|
+
* purchase, refreshed in the background. Otherwise reads the network and
|
|
606
|
+
* caches the result; if that read FAILS (offline / incident) it falls back
|
|
607
|
+
* to the last cached resolution with `stale: true`, so the paywall renders
|
|
608
|
+
* the last published version rather than not at all. Throws only when
|
|
609
|
+
* there is nothing cached. A deliberate rejection seen by the background
|
|
610
|
+
* refresh drops the cache, so the next resolve throws it. */
|
|
486
611
|
async resolvePlacement(placementKey) {
|
|
487
612
|
const customerId = await this.customerIdPromise;
|
|
613
|
+
const cached = await this.readPlacementCache(placementKey, customerId);
|
|
614
|
+
const age = Date.now() - (cached?.fetchedAt ?? 0);
|
|
615
|
+
if (cached &&
|
|
616
|
+
cached.customerId === customerId &&
|
|
617
|
+
cached.fetchedAt > (await this.placementsInvalidatedAt()) &&
|
|
618
|
+
age >= 0 &&
|
|
619
|
+
age <= PLACEMENT_FRESH_MS) {
|
|
620
|
+
void this.refreshPlacement(placementKey);
|
|
621
|
+
return { ...cached.data, stale: false, fetchedAt: cached.fetchedAt };
|
|
622
|
+
}
|
|
488
623
|
try {
|
|
489
|
-
|
|
490
|
-
// placement can assign a (sticky, deterministic) variant. Servers
|
|
491
|
-
// without experiments simply ignore the parameter. An EPHEMERAL anon
|
|
492
|
-
// id (memory-only storage — new id every launch) is deliberately NOT
|
|
493
|
-
// sent: each launch would enroll a phantom customer and flip the same
|
|
494
|
-
// user between variants; the server serves the placement default
|
|
495
|
-
// when no customer is identified (same guard class as M5 installs).
|
|
496
|
-
const path = `/v1/placements/${encodeURIComponent(placementKey)}/offering`;
|
|
497
|
-
// REV-268: the device facts ride along so the server can evaluate
|
|
498
|
-
// targeting rules against THIS device on THIS request, and store them
|
|
499
|
-
// as device.* attributes. Not sent for an ephemeral anon id: the server
|
|
500
|
-
// would have no customer to keep them on, and it serves such callers
|
|
501
|
-
// the untargeted default anyway.
|
|
502
|
-
const deviceHeader = this.ephemeralAnonId
|
|
503
|
-
? null
|
|
504
|
-
: await this.deviceHeaderPromise;
|
|
505
|
-
const fresh = await this.request("GET", this.ephemeralAnonId
|
|
506
|
-
? path
|
|
507
|
-
: `${path}?customer=${encodeURIComponent(customerId)}`, undefined, deviceHeader ? { [DEVICE_HEADER]: deviceHeader } : undefined);
|
|
508
|
-
await this.writePlacementCache(placementKey, fresh, customerId);
|
|
509
|
-
return { ...fresh, stale: false, fetchedAt: Date.now() };
|
|
624
|
+
return await this.fetchPlacement(placementKey);
|
|
510
625
|
}
|
|
511
626
|
catch (err) {
|
|
512
627
|
// REV-198 (M22): same rule as getEntitlements — an unpublished catalog
|
|
@@ -515,13 +630,44 @@ export class RevnixClient {
|
|
|
515
630
|
// fleet-wide with no way to pull it.
|
|
516
631
|
if (err instanceof RevnixError && !err.isRetryable)
|
|
517
632
|
throw err;
|
|
518
|
-
const
|
|
519
|
-
if (
|
|
520
|
-
return { ...
|
|
633
|
+
const fallback = await this.readPlacementCache(placementKey, customerId);
|
|
634
|
+
if (fallback) {
|
|
635
|
+
return { ...fallback.data, stale: true, fetchedAt: fallback.fetchedAt };
|
|
521
636
|
}
|
|
522
637
|
throw err;
|
|
523
638
|
}
|
|
524
639
|
}
|
|
640
|
+
/** Fetch these placements now so the next `resolvePlacement` answers from
|
|
641
|
+
* the cache. Never throws; failures report to `onDiagnostic`. Paywall
|
|
642
|
+
* images are warmed by `prefetchPaywallImages` from
|
|
643
|
+
* `react-native-revnix/ui`. */
|
|
644
|
+
async preload(placementKeys) {
|
|
645
|
+
await Promise.all(placementKeys.map((key) => this.refreshPlacement(key)));
|
|
646
|
+
}
|
|
647
|
+
async refreshPlacement(placementKey) {
|
|
648
|
+
try {
|
|
649
|
+
await this.fetchPlacement(placementKey);
|
|
650
|
+
}
|
|
651
|
+
catch (err) {
|
|
652
|
+
if (err instanceof RevnixError && !err.isRetryable) {
|
|
653
|
+
await this.deletePlacementCache(placementKey);
|
|
654
|
+
}
|
|
655
|
+
this.diag("resolvePlacement", err);
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
async fetchPlacement(placementKey) {
|
|
659
|
+
const customerId = await this.customerIdPromise;
|
|
660
|
+
const fetchedAt = Date.now();
|
|
661
|
+
const path = `/v1/placements/${encodeURIComponent(placementKey)}/offering`;
|
|
662
|
+
const deviceHeader = this.ephemeralAnonId
|
|
663
|
+
? null
|
|
664
|
+
: await this.deviceHeaderPromise;
|
|
665
|
+
const fresh = await this.request("GET", this.ephemeralAnonId
|
|
666
|
+
? path
|
|
667
|
+
: `${path}?customer=${encodeURIComponent(customerId)}`, undefined, deviceHeader ? { [DEVICE_HEADER]: deviceHeader } : undefined);
|
|
668
|
+
await this.writePlacementCache(placementKey, fresh, customerId, fetchedAt);
|
|
669
|
+
return { ...fresh, stale: false, fetchedAt: Date.now() };
|
|
670
|
+
}
|
|
525
671
|
/** REV-157: the last resolution cached for `placementKey`, with NO network
|
|
526
672
|
* call — for an instant, offline-safe paywall at launch. Always
|
|
527
673
|
* `stale: true`; follow with resolvePlacement() to revalidate. Null if
|
|
@@ -615,7 +761,8 @@ export class RevnixClient {
|
|
|
615
761
|
* - `restore` — restore purchases was tapped
|
|
616
762
|
* - `error` — the paywall itself failed (config, products, render)
|
|
617
763
|
*
|
|
618
|
-
*
|
|
764
|
+
* Queued and sent in batches (see `flushEvents`): resolves once queued,
|
|
765
|
+
* never throws.
|
|
619
766
|
*
|
|
620
767
|
* `viewId` is the id `logPaywallShown` returned for THIS display. Passing
|
|
621
768
|
* it is what threads the whole life of one impression together and puts
|
|
@@ -632,31 +779,29 @@ export class RevnixClient {
|
|
|
632
779
|
* which caps the report at one per display per event type. Pass one per
|
|
633
780
|
* occurrence (and reuse it across your own retries) to record every
|
|
634
781
|
* occurrence — e.g. a customer who fails, retries and fails again. */
|
|
635
|
-
logPaywallEvent(event, viewId, options) {
|
|
782
|
+
async logPaywallEvent(event, viewId, options) {
|
|
636
783
|
if (options?.placementKey === PREVIEW_PLACEMENT_KEY)
|
|
637
784
|
return;
|
|
638
|
-
void this.customerIdPromise
|
|
639
|
-
.then((customerId) => this.request("POST", "/v1/paywalls/events", {
|
|
640
|
-
customerId,
|
|
641
|
-
viewId,
|
|
642
|
-
event,
|
|
643
|
-
occurredAt: Date.now(),
|
|
644
|
-
sdkVersion: SDK_VERSION,
|
|
645
|
-
...(options?.eventId ? { eventId: options.eventId } : {}),
|
|
646
|
-
...(options?.placementKey ? { placementKey: options.placementKey } : {}),
|
|
647
|
-
...(options?.paywallId ? { paywallId: options.paywallId } : {}),
|
|
648
|
-
...(options?.productId ? { productId: options.productId } : {}),
|
|
649
|
-
...(options?.code ? { code: options.code } : {}),
|
|
650
|
-
...(options?.message
|
|
651
|
-
? { message: options.message.slice(0, 1024) }
|
|
652
|
-
: {}),
|
|
653
|
-
}))
|
|
654
|
-
.catch((err) => this.diag("logPaywallEvent", err));
|
|
655
|
-
// REV-272: backing out of the store sheet is the `transaction_abandon`
|
|
656
|
-
// moment. Reuses the paywall.purchase_abandoned event just reported.
|
|
657
785
|
if (event === "purchase_abandoned") {
|
|
658
786
|
this.fireImplicitFromPaywall("transaction_abandon", viewId, options?.paywallId);
|
|
659
787
|
}
|
|
788
|
+
const customerId = await this.customerIdPromise;
|
|
789
|
+
await this.enqueueEvent({
|
|
790
|
+
path: "/v1/paywalls/events",
|
|
791
|
+
body: {
|
|
792
|
+
customerId,
|
|
793
|
+
viewId,
|
|
794
|
+
event,
|
|
795
|
+
eventId: options?.eventId ?? viewId,
|
|
796
|
+
occurredAt: Date.now(),
|
|
797
|
+
sdkVersion: SDK_VERSION,
|
|
798
|
+
...(options?.placementKey ? { placementKey: options.placementKey } : {}),
|
|
799
|
+
...(options?.paywallId ? { paywallId: options.paywallId } : {}),
|
|
800
|
+
...(options?.productId ? { productId: options.productId } : {}),
|
|
801
|
+
...(options?.code ? { code: options.code } : {}),
|
|
802
|
+
...(options?.message ? { message: options.message.slice(0, 1024) } : {}),
|
|
803
|
+
},
|
|
804
|
+
});
|
|
660
805
|
}
|
|
661
806
|
/** Reports one ad impression's payout (POST /v1/ad-revenue).
|
|
662
807
|
* Fire-and-forget like the other beacons: never blocks, never throws. */
|
|
@@ -681,22 +826,124 @@ export class RevnixClient {
|
|
|
681
826
|
.catch((err) => this.diag("logAdRevenue", err));
|
|
682
827
|
}
|
|
683
828
|
/** Reports one custom in-app event (POST /v1/events), not for purchases.
|
|
684
|
-
*
|
|
685
|
-
|
|
829
|
+
* Queued and sent in batches (see `flushEvents`): resolves once queued,
|
|
830
|
+
* never throws. */
|
|
831
|
+
async track(event, properties, options) {
|
|
686
832
|
if (!/^[a-z0-9_]{1,64}$/.test(event)) {
|
|
687
833
|
this.diag("track", new Error(`invalid event name: ${event}`));
|
|
688
834
|
return;
|
|
689
835
|
}
|
|
690
836
|
const eventId = options?.eventId ?? generateEventId();
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
837
|
+
const customerId = await this.customerIdPromise;
|
|
838
|
+
await this.enqueueEvent({
|
|
839
|
+
path: "/v1/events",
|
|
840
|
+
body: {
|
|
841
|
+
customerId,
|
|
842
|
+
event,
|
|
843
|
+
eventId,
|
|
844
|
+
occurredAt: Date.now(),
|
|
845
|
+
...(properties ? { properties } : {}),
|
|
846
|
+
},
|
|
847
|
+
});
|
|
848
|
+
}
|
|
849
|
+
/** Send every queued `track` and paywall interaction event now. Events are
|
|
850
|
+
* otherwise sent in batches: at 20 queued, 10 s after the first, when the
|
|
851
|
+
* app goes to the background, and on the next launch. Never throws; a
|
|
852
|
+
* transient failure keeps the events and holds further sends for 30 s. */
|
|
853
|
+
async flushEvents() {
|
|
854
|
+
await this.eventFlush;
|
|
855
|
+
this.startEventFlush();
|
|
856
|
+
await this.eventFlush;
|
|
857
|
+
}
|
|
858
|
+
async readEvents() {
|
|
859
|
+
try {
|
|
860
|
+
const parsed = JSON.parse((await this.storage.getItem(EVENT_QUEUE_KEY)) ?? "[]");
|
|
861
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
862
|
+
}
|
|
863
|
+
catch {
|
|
864
|
+
return [];
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
mutateEvents(mutate) {
|
|
868
|
+
return this.withStorageLock(async () => {
|
|
869
|
+
const next = mutate(await this.readEvents());
|
|
870
|
+
try {
|
|
871
|
+
await this.storage.setItem(EVENT_QUEUE_KEY, JSON.stringify(next));
|
|
872
|
+
}
|
|
873
|
+
catch (err) {
|
|
874
|
+
this.diag("storageWrite", err);
|
|
875
|
+
}
|
|
876
|
+
return next.length;
|
|
877
|
+
});
|
|
878
|
+
}
|
|
879
|
+
// ponytail: the whole queue is re-serialized per event; keep it in memory if 1000-item offline queues show up in profiles.
|
|
880
|
+
async enqueueEvent(event) {
|
|
881
|
+
const count = await this.mutateEvents((events) => {
|
|
882
|
+
const kept = events.slice(Math.max(0, events.length - EVENT_QUEUE_CAP + 1));
|
|
883
|
+
if (kept.length < events.length) {
|
|
884
|
+
this.diag("eventQueue", new Error("queue full, dropped the oldest event"));
|
|
885
|
+
}
|
|
886
|
+
return [...kept, event];
|
|
887
|
+
});
|
|
888
|
+
if (count >= EVENT_FLUSH_THRESHOLD)
|
|
889
|
+
this.startEventFlush();
|
|
890
|
+
else
|
|
891
|
+
this.armEventTimer(EVENT_FLUSH_DELAY_MS);
|
|
892
|
+
}
|
|
893
|
+
armEventTimer(ms) {
|
|
894
|
+
if (this.eventTimer !== null || this.disposed)
|
|
895
|
+
return;
|
|
896
|
+
this.eventTimer = setTimeout(() => {
|
|
897
|
+
this.eventTimer = null;
|
|
898
|
+
this.startEventFlush();
|
|
899
|
+
}, ms);
|
|
900
|
+
}
|
|
901
|
+
startEventFlush() {
|
|
902
|
+
if (this.eventFlush !== null || this.disposed)
|
|
903
|
+
return;
|
|
904
|
+
const hold = this.eventRetryAt - Date.now();
|
|
905
|
+
if (hold > 0) {
|
|
906
|
+
this.armEventTimer(hold);
|
|
907
|
+
return;
|
|
908
|
+
}
|
|
909
|
+
this.eventFlush = this.runEventFlush().finally(() => {
|
|
910
|
+
this.eventFlush = null;
|
|
911
|
+
});
|
|
912
|
+
}
|
|
913
|
+
async runEventFlush() {
|
|
914
|
+
const byPath = new Map();
|
|
915
|
+
for (const event of await this.withStorageLock(() => this.readEvents())) {
|
|
916
|
+
byPath.set(event.path, [...(byPath.get(event.path) ?? []), event]);
|
|
917
|
+
}
|
|
918
|
+
for (const [path, events] of byPath) {
|
|
919
|
+
for (let start = 0; start < events.length; start += EVENT_BATCH_SIZE) {
|
|
920
|
+
const chunk = events.slice(start, start + EVENT_BATCH_SIZE);
|
|
921
|
+
try {
|
|
922
|
+
const response = await this.request("POST", path, {
|
|
923
|
+
events: chunk.map((e) => e.body),
|
|
924
|
+
});
|
|
925
|
+
batchResults(response, path).forEach((result, i) => {
|
|
926
|
+
if (result.status < 400)
|
|
927
|
+
return;
|
|
928
|
+
this.diag("eventQueue", new RevnixError(result.status, `dropped ${path} event ${String(chunk[i]?.body.eventId)}: ${result.status} ${result.error ?? ""}`));
|
|
929
|
+
});
|
|
930
|
+
await this.removeEvents(chunk);
|
|
931
|
+
}
|
|
932
|
+
catch (err) {
|
|
933
|
+
if (worthQueueing(err)) {
|
|
934
|
+
this.eventRetryAt = Date.now() + EVENT_RETRY_HOLD_MS;
|
|
935
|
+
this.diag("eventQueue", err);
|
|
936
|
+
return;
|
|
937
|
+
}
|
|
938
|
+
await this.removeEvents(chunk);
|
|
939
|
+
this.diag("eventQueue", err);
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
async removeEvents(sent) {
|
|
945
|
+
const sentJson = new Set(sent.map((e) => JSON.stringify(e)));
|
|
946
|
+
await this.mutateEvents((events) => events.filter((e) => !sentJson.has(JSON.stringify(e))));
|
|
700
947
|
}
|
|
701
948
|
/** PT11: forward an MMP's attribution callback (Adjust, AppsFlyer, …) so
|
|
702
949
|
* Revnix credits revenue to the right network/campaign. */
|
|
@@ -789,6 +1036,7 @@ export class RevnixClient {
|
|
|
789
1036
|
async setAttributes(attributes) {
|
|
790
1037
|
const customerId = await this.customerIdPromise;
|
|
791
1038
|
await this.request("POST", `/v1/customers/${encodeURIComponent(customerId)}/attributes`, { attributes });
|
|
1039
|
+
await this.invalidatePlacements();
|
|
792
1040
|
}
|
|
793
1041
|
/** REV-272: hand the SDK the URL that opened your app, from wherever you
|
|
794
1042
|
* already receive it (Linking.getInitialURL / onNewIntent / your router).
|
|
@@ -1136,6 +1384,19 @@ export class RevnixClient {
|
|
|
1136
1384
|
await this.storage.setItem(SESSION_STARTED_AT_KEY, String(Date.now()));
|
|
1137
1385
|
return extra;
|
|
1138
1386
|
}
|
|
1387
|
+
subscribeLifecycle(configured) {
|
|
1388
|
+
if (configured === false)
|
|
1389
|
+
return;
|
|
1390
|
+
const lifecycle = configured ?? browserLifecycle();
|
|
1391
|
+
if (!lifecycle)
|
|
1392
|
+
return;
|
|
1393
|
+
try {
|
|
1394
|
+
this.lifecycleUnsubscribe = lifecycle.onStateChange((state) => this.onAppState(state));
|
|
1395
|
+
}
|
|
1396
|
+
catch (err) {
|
|
1397
|
+
this.diag("lifecycleSubscribe", err);
|
|
1398
|
+
}
|
|
1399
|
+
}
|
|
1139
1400
|
/** REV-272: the launch-time moments, plus the foreground listener that
|
|
1140
1401
|
* `session_start` is built on.
|
|
1141
1402
|
*
|
|
@@ -1144,7 +1405,7 @@ export class RevnixClient {
|
|
|
1144
1405
|
* install too when this launch generated the anonymous id. All three are
|
|
1145
1406
|
* independent placements, so an app may have any subset configured and the
|
|
1146
1407
|
* others cost nothing. */
|
|
1147
|
-
startImplicit(
|
|
1408
|
+
startImplicit() {
|
|
1148
1409
|
// freshInstall is set inside loadCustomerId, which the constructor kicked
|
|
1149
1410
|
// off but did not await — so read it after that promise settles rather
|
|
1150
1411
|
// than now, when it is still false on a genuine first launch.
|
|
@@ -1154,22 +1415,6 @@ export class RevnixClient {
|
|
|
1154
1415
|
// are still reported — a launch is a launch whether or not a paywall
|
|
1155
1416
|
// showed — but an app that configured all three must not have three
|
|
1156
1417
|
// paywalls pushed onto its first frame.
|
|
1157
|
-
// Subscribed BEFORE the batch, which can take a full network timeout per
|
|
1158
|
-
// moment when offline: a customer who backgrounds the app during that
|
|
1159
|
-
// window and comes back an hour later is a session, and missing the
|
|
1160
|
-
// background transition would lose it.
|
|
1161
|
-
if (configured !== false) {
|
|
1162
|
-
const lifecycle = configured ?? browserLifecycle();
|
|
1163
|
-
if (lifecycle) {
|
|
1164
|
-
try {
|
|
1165
|
-
this.lifecycleUnsubscribe = lifecycle.onStateChange((state) => this.onAppState(state));
|
|
1166
|
-
}
|
|
1167
|
-
catch (err) {
|
|
1168
|
-
// A broken adapter costs session detection, not the client.
|
|
1169
|
-
this.diag("lifecycleSubscribe", err);
|
|
1170
|
-
}
|
|
1171
|
-
}
|
|
1172
|
-
}
|
|
1173
1418
|
const sessionExtra = this.consumePreviousSessionMs().catch((err) => {
|
|
1174
1419
|
this.diag("session", err);
|
|
1175
1420
|
return undefined;
|
|
@@ -1204,6 +1449,10 @@ export class RevnixClient {
|
|
|
1204
1449
|
onAppState(state) {
|
|
1205
1450
|
if (this.disposed)
|
|
1206
1451
|
return;
|
|
1452
|
+
if (state === "background")
|
|
1453
|
+
this.startEventFlush();
|
|
1454
|
+
if (!this.implicitEnabled)
|
|
1455
|
+
return;
|
|
1207
1456
|
if (state === "background") {
|
|
1208
1457
|
// First report wins: a platform that repeats "background" (a paused
|
|
1209
1458
|
// Activity re-reporting) must not keep resetting the clock forward.
|
|
@@ -1252,6 +1501,11 @@ export class RevnixClient {
|
|
|
1252
1501
|
// every return alongside its successor.
|
|
1253
1502
|
this.lifecycleUnsubscribe?.();
|
|
1254
1503
|
this.lifecycleUnsubscribe = null;
|
|
1504
|
+
this.stopStoreObserver?.();
|
|
1505
|
+
this.stopStoreObserver = null;
|
|
1506
|
+
if (this.eventTimer !== null)
|
|
1507
|
+
clearTimeout(this.eventTimer);
|
|
1508
|
+
this.eventTimer = null;
|
|
1255
1509
|
}
|
|
1256
1510
|
/** A block paywall reporting a paint string it could not read. Routed to the
|
|
1257
1511
|
* same sink as every other swallowed failure, so a host that already wired
|
|
@@ -1519,27 +1773,56 @@ export class RevnixClient {
|
|
|
1519
1773
|
}
|
|
1520
1774
|
return entry;
|
|
1521
1775
|
}
|
|
1522
|
-
|
|
1776
|
+
writePlacementCache(placementKey, data, customerId, fetchedAt) {
|
|
1777
|
+
return this.mutatePlacements((map) => {
|
|
1778
|
+
map[placementKey] = { data, fetchedAt, customerId };
|
|
1779
|
+
});
|
|
1780
|
+
}
|
|
1781
|
+
deletePlacementCache(placementKey) {
|
|
1782
|
+
return this.mutatePlacements((map) => {
|
|
1783
|
+
delete map[placementKey];
|
|
1784
|
+
});
|
|
1785
|
+
}
|
|
1786
|
+
mutatePlacements(mutate) {
|
|
1787
|
+
return this.withStorageLock(async () => {
|
|
1788
|
+
try {
|
|
1789
|
+
const map = await this.readPlacementMap();
|
|
1790
|
+
mutate(map);
|
|
1791
|
+
await this.storage.setItem(PLACEMENTS_CACHE_KEY, JSON.stringify(map));
|
|
1792
|
+
}
|
|
1793
|
+
catch (err) {
|
|
1794
|
+
this.diag("storageWrite", err);
|
|
1795
|
+
}
|
|
1796
|
+
});
|
|
1797
|
+
}
|
|
1798
|
+
async placementsInvalidatedAt() {
|
|
1799
|
+
try {
|
|
1800
|
+
return Number(await this.storage.getItem(PLACEMENTS_INVALIDATED_AT_KEY)) || 0;
|
|
1801
|
+
}
|
|
1802
|
+
catch {
|
|
1803
|
+
return 0;
|
|
1804
|
+
}
|
|
1805
|
+
}
|
|
1806
|
+
async invalidatePlacements() {
|
|
1523
1807
|
try {
|
|
1524
|
-
|
|
1525
|
-
map[placementKey] = { data, fetchedAt: Date.now(), customerId };
|
|
1526
|
-
await this.storage.setItem(PLACEMENTS_CACHE_KEY, JSON.stringify(map));
|
|
1808
|
+
await this.storage.setItem(PLACEMENTS_INVALIDATED_AT_KEY, String(Date.now()));
|
|
1527
1809
|
}
|
|
1528
1810
|
catch (err) {
|
|
1529
|
-
// Best-effort, but reported (REV-200 M26).
|
|
1530
1811
|
this.diag("storageWrite", err);
|
|
1531
1812
|
}
|
|
1532
1813
|
}
|
|
1814
|
+
withStorageLock(task) {
|
|
1815
|
+
const run = this.storageLock.then(task);
|
|
1816
|
+
this.storageLock = run.then(() => { }, () => { });
|
|
1817
|
+
return run;
|
|
1818
|
+
}
|
|
1533
1819
|
mutateQueue(mutate) {
|
|
1534
|
-
|
|
1820
|
+
return this.withStorageLock(async () => {
|
|
1535
1821
|
const queue = await this.readPurchaseQueue();
|
|
1536
1822
|
const next = mutate(queue);
|
|
1537
1823
|
if (next !== queue)
|
|
1538
1824
|
await this.writePurchaseQueue(next);
|
|
1539
1825
|
});
|
|
1540
|
-
// The chain must survive a failed mutation or every later one deadlocks.
|
|
1541
|
-
this.queueLock = run.catch(() => { });
|
|
1542
|
-
return run;
|
|
1543
1826
|
}
|
|
1544
1827
|
async readPurchaseQueue() {
|
|
1545
1828
|
try {
|
|
@@ -1564,17 +1847,22 @@ export class RevnixClient {
|
|
|
1564
1847
|
}
|
|
1565
1848
|
}
|
|
1566
1849
|
enqueuePurchase(customerId, input) {
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1850
|
+
return this.mutateQueue((queue) => {
|
|
1851
|
+
if (queue.some((q) => pendingKey(q.input) === pendingKey(input)))
|
|
1852
|
+
return queue;
|
|
1853
|
+
const kept = queue.slice(Math.max(0, queue.length - PURCHASE_QUEUE_CAP + 1));
|
|
1854
|
+
for (const dropped of queue.slice(0, queue.length - kept.length)) {
|
|
1855
|
+
this.diag("registerPurchase", new Error(`queue full, dropped ${pendingKey(dropped.input)}`));
|
|
1856
|
+
}
|
|
1857
|
+
return [...kept, { customerId, input, queuedAt: Date.now() }];
|
|
1858
|
+
});
|
|
1571
1859
|
}
|
|
1572
|
-
async request(method, path, body, extraHeaders) {
|
|
1860
|
+
async request(method, path, body, extraHeaders, timeoutMs = this.requestTimeoutMs) {
|
|
1573
1861
|
// C7: abort the request after requestTimeoutMs so a slow or dead backend
|
|
1574
1862
|
// can't hang the app. AbortController is guarded for very old runtimes.
|
|
1575
1863
|
const controller = typeof AbortController !== "undefined" ? new AbortController() : undefined;
|
|
1576
1864
|
const timer = controller
|
|
1577
|
-
? setTimeout(() => controller.abort(),
|
|
1865
|
+
? setTimeout(() => controller.abort(), timeoutMs)
|
|
1578
1866
|
: undefined;
|
|
1579
1867
|
// REV-200 (M26): piggyback the background-failure counter on this
|
|
1580
1868
|
// request so field pain reaches the backend's request logs. Cleared only
|
|
@@ -1603,7 +1891,7 @@ export class RevnixClient {
|
|
|
1603
1891
|
catch (err) {
|
|
1604
1892
|
if (controller?.signal.aborted ||
|
|
1605
1893
|
(err instanceof Error && err.name === "AbortError")) {
|
|
1606
|
-
throw new RevnixTimeoutError(`Revnix request to ${path} timed out after ${
|
|
1894
|
+
throw new RevnixTimeoutError(`Revnix request to ${path} timed out after ${timeoutMs}ms`);
|
|
1607
1895
|
}
|
|
1608
1896
|
throw new RevnixNetworkError(err instanceof Error ? err.message : "network request failed");
|
|
1609
1897
|
}
|