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