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.
- package/README.md +16 -0
- package/android/build.gradle +3 -0
- package/android/consumer-rules.pro +1 -0
- package/android/src/main/kotlin/com/revnix/reactnative/RevnixStoreModule.kt +43 -0
- package/dist/blocks/locale.js +7 -0
- package/dist/blocks/selection.d.ts +7 -1
- package/dist/blocks/selection.js +9 -0
- package/dist/blocks/tags.d.ts +3 -0
- package/dist/blocks/tags.js +33 -0
- package/dist/blocks/types.d.ts +45 -3
- package/dist/blocks/types.js +1 -0
- package/dist/client.d.ts +50 -11
- package/dist/client.js +408 -119
- package/dist/esm/blocks/locale.js +7 -0
- package/dist/esm/blocks/selection.js +8 -0
- package/dist/esm/blocks/tags.js +31 -0
- package/dist/esm/blocks/types.js +1 -0
- package/dist/esm/client.js +407 -119
- package/dist/esm/store/index.js +7 -0
- package/dist/esm/ui/RevnixBlockPaywall.js +103 -22
- package/dist/esm/ui/RevnixPaywall.js +7 -0
- package/dist/esm/ui/index.js +1 -1
- package/dist/esm/version.js +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/store/index.d.ts +8 -0
- package/dist/store/index.js +10 -1
- package/dist/types.d.ts +32 -0
- package/dist/ui/RevnixBlockPaywall.js +100 -19
- package/dist/ui/RevnixPaywall.d.ts +6 -2
- package/dist/ui/RevnixPaywall.js +8 -0
- 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 +4 -0
- package/ios/RevnixStore.swift +29 -0
- package/package.json +1 -1
- package/src/blocks/locale.ts +7 -0
- package/src/blocks/selection.ts +13 -0
- package/src/blocks/tags.ts +38 -0
- package/src/blocks/types.ts +51 -2
- package/src/client.ts +478 -154
- package/src/index.ts +1 -0
- package/src/store/index.ts +15 -0
- package/src/types.ts +30 -0
- package/src/ui/RevnixBlockPaywall.tsx +186 -20
- package/src/ui/RevnixPaywall.tsx +15 -1
- package/src/ui/index.ts +1 -0
- 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
|
-
|
|
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(
|
|
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
|
-
|
|
377
|
-
|
|
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
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
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
|
-
|
|
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.
|
|
551
|
-
*
|
|
552
|
-
*
|
|
553
|
-
*
|
|
554
|
-
*
|
|
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
|
-
|
|
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
|
|
588
|
-
if (
|
|
589
|
-
return { ...
|
|
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
|
-
*
|
|
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
|
-
*
|
|
754
|
-
|
|
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
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
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(
|
|
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
|
-
|
|
1644
|
+
const occurredAt = Date.now();
|
|
1645
|
+
void this.integrityFor(customerId)
|
|
1646
|
+
.then((integrity) => this.request("POST", "/v1/installs", {
|
|
1421
1647
|
customerId,
|
|
1422
|
-
occurredAt
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
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(),
|
|
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 ${
|
|
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
|
}
|