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