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/src/client.ts
CHANGED
|
@@ -45,6 +45,7 @@ import type {
|
|
|
45
45
|
IdentifyResult,
|
|
46
46
|
IdentifyStrategy,
|
|
47
47
|
IdentityInfo,
|
|
48
|
+
IntegrityEvidence,
|
|
48
49
|
LastDeepLink,
|
|
49
50
|
MmpAttribution,
|
|
50
51
|
PaywallInteraction,
|
|
@@ -67,7 +68,20 @@ const PENDING_PURCHASES_KEY = "revnix.pendingPurchases";
|
|
|
67
68
|
const PLACEMENTS_CACHE_KEY = "revnix.placements";
|
|
68
69
|
const LAST_ATTRIBUTION_KEY = "revnix.lastAttribution";
|
|
69
70
|
const LAST_PUSH_TOKEN_KEY = "revnix.lastPushToken";
|
|
71
|
+
const LAST_AD_ID_KEY = "revnix.lastAdId";
|
|
72
|
+
const EVENT_QUEUE_KEY = "revnix.eventQueue";
|
|
73
|
+
const PLACEMENTS_INVALIDATED_AT_KEY = "revnix.placementsInvalidatedAt";
|
|
70
74
|
const DEFAULT_TIMEOUT_MS = 10_000;
|
|
75
|
+
const INTEGRITY_TIMEOUT_MS = 10_000;
|
|
76
|
+
const EVENT_QUEUE_CAP = 1000;
|
|
77
|
+
const EVENT_BATCH_SIZE = 100;
|
|
78
|
+
const EVENT_FLUSH_THRESHOLD = 20;
|
|
79
|
+
const EVENT_FLUSH_DELAY_MS = 10_000;
|
|
80
|
+
const EVENT_RETRY_HOLD_MS = 30_000;
|
|
81
|
+
const PLACEMENT_FRESH_MS = 24 * 60 * 60 * 1000;
|
|
82
|
+
const CACHED_ENTITLEMENTS_TIMEOUT_MS = 3_000;
|
|
83
|
+
const PURCHASE_QUEUE_CAP = 100;
|
|
84
|
+
const PURCHASE_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
|
|
71
85
|
|
|
72
86
|
// REV-204 (L10): how many customers keep a cached entitlements snapshot.
|
|
73
87
|
const ENTITLEMENTS_CACHE_MAX = 4;
|
|
@@ -105,6 +119,57 @@ const CLOCK_ROLLBACK_TOLERANCE_MS = 5 * 60 * 1000;
|
|
|
105
119
|
interface PendingPurchase {
|
|
106
120
|
customerId: string;
|
|
107
121
|
input: RegisterPurchaseInput;
|
|
122
|
+
queuedAt?: number;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
interface PlacementCacheEntry {
|
|
126
|
+
data: PlacementResolution;
|
|
127
|
+
fetchedAt: number;
|
|
128
|
+
customerId?: string;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
interface QueuedEvent {
|
|
132
|
+
path: string;
|
|
133
|
+
body: Record<string, unknown>;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function batchResults(
|
|
137
|
+
response: unknown,
|
|
138
|
+
path: string
|
|
139
|
+
): Array<{ status: number; error?: string }> {
|
|
140
|
+
const results = (response as { results?: unknown } | null)?.results;
|
|
141
|
+
if (
|
|
142
|
+
Array.isArray(results) &&
|
|
143
|
+
results.every(
|
|
144
|
+
(r) => typeof r === "object" && r !== null && typeof r.status === "number"
|
|
145
|
+
)
|
|
146
|
+
) {
|
|
147
|
+
return results;
|
|
148
|
+
}
|
|
149
|
+
throw new RevnixError(0, `Revnix: server returned an ill-shaped batch response for ${path}`);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export function paywallImageUrls(value: unknown): string[] {
|
|
153
|
+
const found = new Set<string>();
|
|
154
|
+
const visit = (node: unknown): void => {
|
|
155
|
+
if (Array.isArray(node)) {
|
|
156
|
+
node.forEach(visit);
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
if (typeof node !== "object" || node === null) return;
|
|
160
|
+
const o = node as Record<string, unknown>;
|
|
161
|
+
const background = o.background as { image?: { url?: unknown } } | null | undefined;
|
|
162
|
+
for (const url of [
|
|
163
|
+
o.heroImageUrl,
|
|
164
|
+
o.type === "image" ? o.url : undefined,
|
|
165
|
+
background?.image?.url,
|
|
166
|
+
]) {
|
|
167
|
+
if (typeof url === "string" && url.length > 0) found.add(url);
|
|
168
|
+
}
|
|
169
|
+
Object.values(o).forEach(visit);
|
|
170
|
+
};
|
|
171
|
+
visit(value);
|
|
172
|
+
return [...found];
|
|
108
173
|
}
|
|
109
174
|
|
|
110
175
|
interface InstallResponse {
|
|
@@ -138,6 +203,21 @@ const jittered = (ms: number) => ms * (0.8 + Math.random() * 0.4);
|
|
|
138
203
|
// half a minute; long enough that gating five screens costs one request.
|
|
139
204
|
const DEFAULT_ENTITLEMENTS_TTL_MS = 30_000;
|
|
140
205
|
|
|
206
|
+
const fitsWithin = (value: unknown, max: number): value is string =>
|
|
207
|
+
typeof value === "string" && value.length > 0 && value.length <= max;
|
|
208
|
+
|
|
209
|
+
function wellFormedIntegrity(evidence: unknown): IntegrityEvidence | undefined {
|
|
210
|
+
if (typeof evidence !== "object" || evidence === null) return undefined;
|
|
211
|
+
const { platform, keyId, attestation, token } = evidence as Record<string, unknown>;
|
|
212
|
+
if (platform === "ios" && fitsWithin(keyId, 128) && fitsWithin(attestation, 16_384)) {
|
|
213
|
+
return { platform, keyId, attestation };
|
|
214
|
+
}
|
|
215
|
+
if (platform === "android" && fitsWithin(token, 16_384)) {
|
|
216
|
+
return { platform, token };
|
|
217
|
+
}
|
|
218
|
+
return undefined;
|
|
219
|
+
}
|
|
220
|
+
|
|
141
221
|
export class RevnixClient {
|
|
142
222
|
private readonly apiKey: string;
|
|
143
223
|
private readonly baseUrl: string;
|
|
@@ -182,6 +262,11 @@ export class RevnixClient {
|
|
|
182
262
|
private readonly onImplicitPaywall?: RevnixConfig["onImplicitPaywall"];
|
|
183
263
|
private readonly onDeferredDeepLink?: RevnixConfig["onDeferredDeepLink"];
|
|
184
264
|
private readonly onAttribution?: RevnixConfig["onAttribution"];
|
|
265
|
+
private readonly deviceIntegrity?: RevnixConfig["deviceIntegrity"];
|
|
266
|
+
private integrityRequest: {
|
|
267
|
+
customerId: string;
|
|
268
|
+
body: Promise<{ integrity?: IntegrityEvidence }>;
|
|
269
|
+
} | null = null;
|
|
185
270
|
private deferredDeepLinkClaimed = false;
|
|
186
271
|
private landingUrl: string | null = null;
|
|
187
272
|
private lastDeepLinkWrite: Promise<void> = Promise.resolve();
|
|
@@ -213,6 +298,9 @@ export class RevnixClient {
|
|
|
213
298
|
* /v1/installs uses, and independent of the device header (which an app
|
|
214
299
|
* can turn off entirely). */
|
|
215
300
|
private freshInstall = false;
|
|
301
|
+
private eventFlush: Promise<void> | null = null;
|
|
302
|
+
private eventTimer: ReturnType<typeof setTimeout> | null = null;
|
|
303
|
+
private eventRetryAt = 0;
|
|
216
304
|
|
|
217
305
|
constructor(config: RevnixConfig) {
|
|
218
306
|
if (!config.apiKey) throw new Error("Revnix: apiKey is required");
|
|
@@ -251,6 +339,7 @@ export class RevnixClient {
|
|
|
251
339
|
this.onDiagnostic = config.onDiagnostic;
|
|
252
340
|
this.onDeferredDeepLink = config.onDeferredDeepLink;
|
|
253
341
|
this.onAttribution = config.onAttribution;
|
|
342
|
+
this.deviceIntegrity = config.deviceIntegrity;
|
|
254
343
|
this.readYourWritesDelaysMs =
|
|
255
344
|
config.readYourWritesDelaysMs ?? [250, 500, 1000, 2000];
|
|
256
345
|
this.customerIdPromise = this.loadCustomerId(config.customerId);
|
|
@@ -259,6 +348,7 @@ export class RevnixClient {
|
|
|
259
348
|
void this.retryPendingPurchases().catch((err) =>
|
|
260
349
|
this.diag("retryPendingPurchases", err)
|
|
261
350
|
);
|
|
351
|
+
if (config.advertisingId) this.reportAdvertisingId(config.advertisingId);
|
|
262
352
|
const store = getStoreAdapter();
|
|
263
353
|
if (store) {
|
|
264
354
|
this.stopStoreObserver = store.observe((tx) =>
|
|
@@ -283,7 +373,11 @@ export class RevnixClient {
|
|
|
283
373
|
config.implicitPlacements ?? config.onImplicitPaywall !== undefined;
|
|
284
374
|
this.sessionTimeoutMs =
|
|
285
375
|
config.sessionTimeoutMs ?? DEFAULT_SESSION_TIMEOUT_MS;
|
|
286
|
-
|
|
376
|
+
this.subscribeLifecycle(config.lifecycle);
|
|
377
|
+
if (this.implicitEnabled) this.startImplicit();
|
|
378
|
+
void this.readEvents().then((events) => {
|
|
379
|
+
if (events.length > 0) this.startEventFlush();
|
|
380
|
+
});
|
|
287
381
|
const { document, location } = globalThis as {
|
|
288
382
|
document?: unknown;
|
|
289
383
|
location?: { href: string; search: string };
|
|
@@ -396,6 +490,7 @@ export class RevnixClient {
|
|
|
396
490
|
const customerId = await this.customerIdPromise;
|
|
397
491
|
try {
|
|
398
492
|
const result = await this.postPurchase(customerId, input);
|
|
493
|
+
await this.invalidatePlacements();
|
|
399
494
|
// Identity resolution may have merged this device's id away (restore
|
|
400
495
|
// after login), so the server's id is the one to keep using.
|
|
401
496
|
//
|
|
@@ -522,23 +617,28 @@ export class RevnixClient {
|
|
|
522
617
|
|
|
523
618
|
/** C7: retry purchases that failed to register and were persisted locally.
|
|
524
619
|
* Runs automatically at construction; call again when connectivity returns.
|
|
525
|
-
* Idempotent and safe to call repeatedly.
|
|
620
|
+
* Idempotent and safe to call repeatedly. Purchases queued more than 30
|
|
621
|
+
* days ago are dropped with a diagnostic. */
|
|
526
622
|
async retryPendingPurchases(): Promise<void> {
|
|
623
|
+
const now = Date.now();
|
|
624
|
+
await this.mutateQueue((queue) =>
|
|
625
|
+
queue.some((q) => q.queuedAt === undefined)
|
|
626
|
+
? queue.map((q) => ({ ...q, queuedAt: q.queuedAt ?? now }))
|
|
627
|
+
: queue
|
|
628
|
+
);
|
|
527
629
|
const queue = await this.readPurchaseQueue();
|
|
528
630
|
for (const item of queue) {
|
|
529
631
|
if (this.disposed) return;
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
632
|
+
if (now - (item.queuedAt ?? now) > PURCHASE_MAX_AGE_MS) {
|
|
633
|
+
this.diag("retryPendingPurchases", new Error(`expired ${pendingKey(item.input)}`));
|
|
634
|
+
} else {
|
|
635
|
+
try {
|
|
636
|
+
await this.postPurchase(item.customerId, item.input);
|
|
637
|
+
await this.invalidatePlacements();
|
|
638
|
+
} catch (err) {
|
|
537
639
|
this.diag("retryPendingPurchases", err);
|
|
538
|
-
continue;
|
|
640
|
+
if (worthQueueing(err)) continue;
|
|
539
641
|
}
|
|
540
|
-
// Permanent rejection — remove it.
|
|
541
|
-
this.diag("retryPendingPurchases", err);
|
|
542
642
|
}
|
|
543
643
|
// Remove THIS item from the queue as it stands now, not from the
|
|
544
644
|
// snapshot iterated above: each postPurchase can spend seconds in a
|
|
@@ -600,11 +700,15 @@ export class RevnixClient {
|
|
|
600
700
|
/** The authoritative network read: no TTL, no cache fallback — throws so
|
|
601
701
|
* the caller sees the real failure (waitForEntitlements needs 429s). */
|
|
602
702
|
private async fetchFreshEntitlements(
|
|
603
|
-
customerId: string
|
|
703
|
+
customerId: string,
|
|
704
|
+
timeoutMs?: number
|
|
604
705
|
): Promise<CustomerEntitlements> {
|
|
605
706
|
const fresh = await this.request<CustomerEntitlements>(
|
|
606
707
|
"GET",
|
|
607
|
-
`/v1/customers/${encodeURIComponent(customerId)}/entitlements
|
|
708
|
+
`/v1/customers/${encodeURIComponent(customerId)}/entitlements`,
|
|
709
|
+
undefined,
|
|
710
|
+
undefined,
|
|
711
|
+
timeoutMs
|
|
608
712
|
);
|
|
609
713
|
await this.writeEntitlementsCache(customerId, fresh);
|
|
610
714
|
const result = { ...fresh, stale: false, fetchedAt: Date.now() };
|
|
@@ -615,8 +719,14 @@ export class RevnixClient {
|
|
|
615
719
|
private async fetchEntitlementsWithFallback(
|
|
616
720
|
customerId: string
|
|
617
721
|
): Promise<CustomerEntitlements> {
|
|
722
|
+
const hasCache = (await this.readEntitlementsCache(customerId)) !== null;
|
|
618
723
|
try {
|
|
619
|
-
return await this.fetchFreshEntitlements(
|
|
724
|
+
return await this.fetchFreshEntitlements(
|
|
725
|
+
customerId,
|
|
726
|
+
hasCache
|
|
727
|
+
? Math.min(CACHED_ENTITLEMENTS_TIMEOUT_MS, this.requestTimeoutMs)
|
|
728
|
+
: undefined
|
|
729
|
+
);
|
|
620
730
|
} catch (err) {
|
|
621
731
|
// REV-198 (CTO-R2 M22): the cache exists to ride out failures that
|
|
622
732
|
// might succeed next time — network, timeout, 429, 5xx. A deliberate
|
|
@@ -740,54 +850,82 @@ export class RevnixClient {
|
|
|
740
850
|
}
|
|
741
851
|
|
|
742
852
|
/** Resolve a named in-app location to the currently published offering —
|
|
743
|
-
* the one-line paywall call.
|
|
744
|
-
*
|
|
745
|
-
*
|
|
746
|
-
*
|
|
747
|
-
*
|
|
853
|
+
* the one-line paywall call. Served instantly from a cache under 24 h old
|
|
854
|
+
* that was fetched for this customer since the last `setAttributes` or
|
|
855
|
+
* purchase, refreshed in the background. Otherwise reads the network and
|
|
856
|
+
* caches the result; if that read FAILS (offline / incident) it falls back
|
|
857
|
+
* to the last cached resolution with `stale: true`, so the paywall renders
|
|
858
|
+
* the last published version rather than not at all. Throws only when
|
|
859
|
+
* there is nothing cached. A deliberate rejection seen by the background
|
|
860
|
+
* refresh drops the cache, so the next resolve throws it. */
|
|
748
861
|
async resolvePlacement(placementKey: string): Promise<PlacementResolution> {
|
|
749
862
|
const customerId = await this.customerIdPromise;
|
|
863
|
+
const cached = await this.readPlacementCache(placementKey, customerId);
|
|
864
|
+
const age = Date.now() - (cached?.fetchedAt ?? 0);
|
|
865
|
+
if (
|
|
866
|
+
cached &&
|
|
867
|
+
cached.customerId === customerId &&
|
|
868
|
+
cached.fetchedAt > (await this.placementsInvalidatedAt()) &&
|
|
869
|
+
age >= 0 &&
|
|
870
|
+
age <= PLACEMENT_FRESH_MS
|
|
871
|
+
) {
|
|
872
|
+
void this.refreshPlacement(placementKey);
|
|
873
|
+
return { ...cached.data, stale: false, fetchedAt: cached.fetchedAt };
|
|
874
|
+
}
|
|
750
875
|
try {
|
|
751
|
-
|
|
752
|
-
// placement can assign a (sticky, deterministic) variant. Servers
|
|
753
|
-
// without experiments simply ignore the parameter. An EPHEMERAL anon
|
|
754
|
-
// id (memory-only storage — new id every launch) is deliberately NOT
|
|
755
|
-
// sent: each launch would enroll a phantom customer and flip the same
|
|
756
|
-
// user between variants; the server serves the placement default
|
|
757
|
-
// when no customer is identified (same guard class as M5 installs).
|
|
758
|
-
const path = `/v1/placements/${encodeURIComponent(placementKey)}/offering`;
|
|
759
|
-
// REV-268: the device facts ride along so the server can evaluate
|
|
760
|
-
// targeting rules against THIS device on THIS request, and store them
|
|
761
|
-
// as device.* attributes. Not sent for an ephemeral anon id: the server
|
|
762
|
-
// would have no customer to keep them on, and it serves such callers
|
|
763
|
-
// the untargeted default anyway.
|
|
764
|
-
const deviceHeader = this.ephemeralAnonId
|
|
765
|
-
? null
|
|
766
|
-
: await this.deviceHeaderPromise;
|
|
767
|
-
const fresh = await this.request<PlacementResolution>(
|
|
768
|
-
"GET",
|
|
769
|
-
this.ephemeralAnonId
|
|
770
|
-
? path
|
|
771
|
-
: `${path}?customer=${encodeURIComponent(customerId)}`,
|
|
772
|
-
undefined,
|
|
773
|
-
deviceHeader ? { [DEVICE_HEADER]: deviceHeader } : undefined
|
|
774
|
-
);
|
|
775
|
-
await this.writePlacementCache(placementKey, fresh, customerId);
|
|
776
|
-
return { ...fresh, stale: false, fetchedAt: Date.now() };
|
|
876
|
+
return await this.fetchPlacement(placementKey);
|
|
777
877
|
} catch (err) {
|
|
778
878
|
// REV-198 (M22): same rule as getEntitlements — an unpublished catalog
|
|
779
879
|
// (409) or deleted placement (404) is the server's deliberate answer,
|
|
780
880
|
// and caching over it meant an unpublished paywall kept rendering
|
|
781
881
|
// fleet-wide with no way to pull it.
|
|
782
882
|
if (err instanceof RevnixError && !err.isRetryable) throw err;
|
|
783
|
-
const
|
|
784
|
-
if (
|
|
785
|
-
return { ...
|
|
883
|
+
const fallback = await this.readPlacementCache(placementKey, customerId);
|
|
884
|
+
if (fallback) {
|
|
885
|
+
return { ...fallback.data, stale: true, fetchedAt: fallback.fetchedAt };
|
|
786
886
|
}
|
|
787
887
|
throw err;
|
|
788
888
|
}
|
|
789
889
|
}
|
|
790
890
|
|
|
891
|
+
/** Fetch these placements now so the next `resolvePlacement` answers from
|
|
892
|
+
* the cache. Never throws; failures report to `onDiagnostic`. Paywall
|
|
893
|
+
* images are warmed by `prefetchPaywallImages` from
|
|
894
|
+
* `react-native-revnix/ui`. */
|
|
895
|
+
async preload(placementKeys: string[]): Promise<void> {
|
|
896
|
+
await Promise.all(placementKeys.map((key) => this.refreshPlacement(key)));
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
private async refreshPlacement(placementKey: string): Promise<void> {
|
|
900
|
+
try {
|
|
901
|
+
await this.fetchPlacement(placementKey);
|
|
902
|
+
} catch (err) {
|
|
903
|
+
if (err instanceof RevnixError && !err.isRetryable) {
|
|
904
|
+
await this.deletePlacementCache(placementKey);
|
|
905
|
+
}
|
|
906
|
+
this.diag("resolvePlacement", err);
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
private async fetchPlacement(placementKey: string): Promise<PlacementResolution> {
|
|
911
|
+
const customerId = await this.customerIdPromise;
|
|
912
|
+
const fetchedAt = Date.now();
|
|
913
|
+
const path = `/v1/placements/${encodeURIComponent(placementKey)}/offering`;
|
|
914
|
+
const deviceHeader = this.ephemeralAnonId
|
|
915
|
+
? null
|
|
916
|
+
: await this.deviceHeaderPromise;
|
|
917
|
+
const fresh = await this.request<PlacementResolution>(
|
|
918
|
+
"GET",
|
|
919
|
+
this.ephemeralAnonId
|
|
920
|
+
? path
|
|
921
|
+
: `${path}?customer=${encodeURIComponent(customerId)}`,
|
|
922
|
+
undefined,
|
|
923
|
+
deviceHeader ? { [DEVICE_HEADER]: deviceHeader } : undefined
|
|
924
|
+
);
|
|
925
|
+
await this.writePlacementCache(placementKey, fresh, customerId, fetchedAt);
|
|
926
|
+
return { ...fresh, stale: false, fetchedAt: Date.now() };
|
|
927
|
+
}
|
|
928
|
+
|
|
791
929
|
/** REV-157: the last resolution cached for `placementKey`, with NO network
|
|
792
930
|
* call — for an instant, offline-safe paywall at launch. Always
|
|
793
931
|
* `stale: true`; follow with resolvePlacement() to revalidate. Null if
|
|
@@ -896,7 +1034,8 @@ export class RevnixClient {
|
|
|
896
1034
|
* - `restore` — restore purchases was tapped
|
|
897
1035
|
* - `error` — the paywall itself failed (config, products, render)
|
|
898
1036
|
*
|
|
899
|
-
*
|
|
1037
|
+
* Queued and sent in batches (see `flushEvents`): resolves once queued,
|
|
1038
|
+
* never throws.
|
|
900
1039
|
*
|
|
901
1040
|
* `viewId` is the id `logPaywallShown` returned for THIS display. Passing
|
|
902
1041
|
* it is what threads the whole life of one impression together and puts
|
|
@@ -913,7 +1052,7 @@ export class RevnixClient {
|
|
|
913
1052
|
* which caps the report at one per display per event type. Pass one per
|
|
914
1053
|
* occurrence (and reuse it across your own retries) to record every
|
|
915
1054
|
* occurrence — e.g. a customer who fails, retries and fails again. */
|
|
916
|
-
logPaywallEvent(
|
|
1055
|
+
async logPaywallEvent(
|
|
917
1056
|
event: PaywallInteraction,
|
|
918
1057
|
viewId: string,
|
|
919
1058
|
options?: {
|
|
@@ -926,29 +1065,8 @@ export class RevnixClient {
|
|
|
926
1065
|
/** Idempotency key for this occurrence; defaults to `viewId`. */
|
|
927
1066
|
eventId?: string;
|
|
928
1067
|
}
|
|
929
|
-
): void {
|
|
1068
|
+
): Promise<void> {
|
|
930
1069
|
if (options?.placementKey === PREVIEW_PLACEMENT_KEY) return;
|
|
931
|
-
void this.customerIdPromise
|
|
932
|
-
.then((customerId) =>
|
|
933
|
-
this.request("POST", "/v1/paywalls/events", {
|
|
934
|
-
customerId,
|
|
935
|
-
viewId,
|
|
936
|
-
event,
|
|
937
|
-
occurredAt: Date.now(),
|
|
938
|
-
sdkVersion: SDK_VERSION,
|
|
939
|
-
...(options?.eventId ? { eventId: options.eventId } : {}),
|
|
940
|
-
...(options?.placementKey ? { placementKey: options.placementKey } : {}),
|
|
941
|
-
...(options?.paywallId ? { paywallId: options.paywallId } : {}),
|
|
942
|
-
...(options?.productId ? { productId: options.productId } : {}),
|
|
943
|
-
...(options?.code ? { code: options.code } : {}),
|
|
944
|
-
...(options?.message
|
|
945
|
-
? { message: options.message.slice(0, 1024) }
|
|
946
|
-
: {}),
|
|
947
|
-
})
|
|
948
|
-
)
|
|
949
|
-
.catch((err) => this.diag("logPaywallEvent", err));
|
|
950
|
-
// REV-272: backing out of the store sheet is the `transaction_abandon`
|
|
951
|
-
// moment. Reuses the paywall.purchase_abandoned event just reported.
|
|
952
1070
|
if (event === "purchase_abandoned") {
|
|
953
1071
|
this.fireImplicitFromPaywall(
|
|
954
1072
|
"transaction_abandon",
|
|
@@ -956,6 +1074,23 @@ export class RevnixClient {
|
|
|
956
1074
|
options?.paywallId
|
|
957
1075
|
);
|
|
958
1076
|
}
|
|
1077
|
+
const customerId = await this.customerIdPromise;
|
|
1078
|
+
await this.enqueueEvent({
|
|
1079
|
+
path: "/v1/paywalls/events",
|
|
1080
|
+
body: {
|
|
1081
|
+
customerId,
|
|
1082
|
+
viewId,
|
|
1083
|
+
event,
|
|
1084
|
+
eventId: options?.eventId ?? viewId,
|
|
1085
|
+
occurredAt: Date.now(),
|
|
1086
|
+
sdkVersion: SDK_VERSION,
|
|
1087
|
+
...(options?.placementKey ? { placementKey: options.placementKey } : {}),
|
|
1088
|
+
...(options?.paywallId ? { paywallId: options.paywallId } : {}),
|
|
1089
|
+
...(options?.productId ? { productId: options.productId } : {}),
|
|
1090
|
+
...(options?.code ? { code: options.code } : {}),
|
|
1091
|
+
...(options?.message ? { message: options.message.slice(0, 1024) } : {}),
|
|
1092
|
+
},
|
|
1093
|
+
});
|
|
959
1094
|
}
|
|
960
1095
|
|
|
961
1096
|
/** Reports one ad impression's payout (POST /v1/ad-revenue).
|
|
@@ -984,28 +1119,136 @@ export class RevnixClient {
|
|
|
984
1119
|
}
|
|
985
1120
|
|
|
986
1121
|
/** Reports one custom in-app event (POST /v1/events), not for purchases.
|
|
987
|
-
*
|
|
988
|
-
|
|
1122
|
+
* Queued and sent in batches (see `flushEvents`): resolves once queued,
|
|
1123
|
+
* never throws. */
|
|
1124
|
+
async track(
|
|
989
1125
|
event: string,
|
|
990
1126
|
properties?: Record<string, string | number | boolean>,
|
|
991
1127
|
options?: { eventId?: string }
|
|
992
|
-
): void {
|
|
1128
|
+
): Promise<void> {
|
|
993
1129
|
if (!/^[a-z0-9_]{1,64}$/.test(event)) {
|
|
994
1130
|
this.diag("track", new Error(`invalid event name: ${event}`));
|
|
995
1131
|
return;
|
|
996
1132
|
}
|
|
997
1133
|
const eventId = options?.eventId ?? generateEventId();
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
})
|
|
1007
|
-
|
|
1008
|
-
|
|
1134
|
+
const customerId = await this.customerIdPromise;
|
|
1135
|
+
await this.enqueueEvent({
|
|
1136
|
+
path: "/v1/events",
|
|
1137
|
+
body: {
|
|
1138
|
+
customerId,
|
|
1139
|
+
event,
|
|
1140
|
+
eventId,
|
|
1141
|
+
occurredAt: Date.now(),
|
|
1142
|
+
...(properties ? { properties } : {}),
|
|
1143
|
+
},
|
|
1144
|
+
});
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
/** Send every queued `track` and paywall interaction event now. Events are
|
|
1148
|
+
* otherwise sent in batches: at 20 queued, 10 s after the first, when the
|
|
1149
|
+
* app goes to the background, and on the next launch. Never throws; a
|
|
1150
|
+
* transient failure keeps the events and holds further sends for 30 s. */
|
|
1151
|
+
async flushEvents(): Promise<void> {
|
|
1152
|
+
await this.eventFlush;
|
|
1153
|
+
this.startEventFlush();
|
|
1154
|
+
await this.eventFlush;
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
private async readEvents(): Promise<QueuedEvent[]> {
|
|
1158
|
+
try {
|
|
1159
|
+
const parsed = JSON.parse((await this.storage.getItem(EVENT_QUEUE_KEY)) ?? "[]");
|
|
1160
|
+
return Array.isArray(parsed) ? (parsed as QueuedEvent[]) : [];
|
|
1161
|
+
} catch {
|
|
1162
|
+
return [];
|
|
1163
|
+
}
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
private mutateEvents(mutate: (events: QueuedEvent[]) => QueuedEvent[]): Promise<number> {
|
|
1167
|
+
return this.withStorageLock(async () => {
|
|
1168
|
+
const next = mutate(await this.readEvents());
|
|
1169
|
+
try {
|
|
1170
|
+
await this.storage.setItem(EVENT_QUEUE_KEY, JSON.stringify(next));
|
|
1171
|
+
} catch (err) {
|
|
1172
|
+
this.diag("storageWrite", err);
|
|
1173
|
+
}
|
|
1174
|
+
return next.length;
|
|
1175
|
+
});
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
// ponytail: the whole queue is re-serialized per event; keep it in memory if 1000-item offline queues show up in profiles.
|
|
1179
|
+
private async enqueueEvent(event: QueuedEvent): Promise<void> {
|
|
1180
|
+
const count = await this.mutateEvents((events) => {
|
|
1181
|
+
const kept = events.slice(Math.max(0, events.length - EVENT_QUEUE_CAP + 1));
|
|
1182
|
+
if (kept.length < events.length) {
|
|
1183
|
+
this.diag("eventQueue", new Error("queue full, dropped the oldest event"));
|
|
1184
|
+
}
|
|
1185
|
+
return [...kept, event];
|
|
1186
|
+
});
|
|
1187
|
+
if (count >= EVENT_FLUSH_THRESHOLD) this.startEventFlush();
|
|
1188
|
+
else this.armEventTimer(EVENT_FLUSH_DELAY_MS);
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
private armEventTimer(ms: number): void {
|
|
1192
|
+
if (this.eventTimer !== null || this.disposed) return;
|
|
1193
|
+
this.eventTimer = setTimeout(() => {
|
|
1194
|
+
this.eventTimer = null;
|
|
1195
|
+
this.startEventFlush();
|
|
1196
|
+
}, ms);
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
private startEventFlush(): void {
|
|
1200
|
+
if (this.eventFlush !== null || this.disposed) return;
|
|
1201
|
+
const hold = this.eventRetryAt - Date.now();
|
|
1202
|
+
if (hold > 0) {
|
|
1203
|
+
this.armEventTimer(hold);
|
|
1204
|
+
return;
|
|
1205
|
+
}
|
|
1206
|
+
this.eventFlush = this.runEventFlush().finally(() => {
|
|
1207
|
+
this.eventFlush = null;
|
|
1208
|
+
});
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1211
|
+
private async runEventFlush(): Promise<void> {
|
|
1212
|
+
const byPath = new Map<string, QueuedEvent[]>();
|
|
1213
|
+
for (const event of await this.withStorageLock(() => this.readEvents())) {
|
|
1214
|
+
byPath.set(event.path, [...(byPath.get(event.path) ?? []), event]);
|
|
1215
|
+
}
|
|
1216
|
+
for (const [path, events] of byPath) {
|
|
1217
|
+
for (let start = 0; start < events.length; start += EVENT_BATCH_SIZE) {
|
|
1218
|
+
const chunk = events.slice(start, start + EVENT_BATCH_SIZE);
|
|
1219
|
+
try {
|
|
1220
|
+
const response = await this.request<unknown>("POST", path, {
|
|
1221
|
+
events: chunk.map((e) => e.body),
|
|
1222
|
+
});
|
|
1223
|
+
batchResults(response, path).forEach((result, i) => {
|
|
1224
|
+
if (result.status < 400) return;
|
|
1225
|
+
this.diag(
|
|
1226
|
+
"eventQueue",
|
|
1227
|
+
new RevnixError(
|
|
1228
|
+
result.status,
|
|
1229
|
+
`dropped ${path} event ${String(chunk[i]?.body.eventId)}: ${result.status} ${result.error ?? ""}`
|
|
1230
|
+
)
|
|
1231
|
+
);
|
|
1232
|
+
});
|
|
1233
|
+
await this.removeEvents(chunk);
|
|
1234
|
+
} catch (err) {
|
|
1235
|
+
if (worthQueueing(err)) {
|
|
1236
|
+
this.eventRetryAt = Date.now() + EVENT_RETRY_HOLD_MS;
|
|
1237
|
+
this.diag("eventQueue", err);
|
|
1238
|
+
return;
|
|
1239
|
+
}
|
|
1240
|
+
await this.removeEvents(chunk);
|
|
1241
|
+
this.diag("eventQueue", err);
|
|
1242
|
+
}
|
|
1243
|
+
}
|
|
1244
|
+
}
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
private async removeEvents(sent: QueuedEvent[]): Promise<void> {
|
|
1248
|
+
const sentJson = new Set(sent.map((e) => JSON.stringify(e)));
|
|
1249
|
+
await this.mutateEvents((events) =>
|
|
1250
|
+
events.filter((e) => !sentJson.has(JSON.stringify(e)))
|
|
1251
|
+
);
|
|
1009
1252
|
}
|
|
1010
1253
|
|
|
1011
1254
|
/** PT11: forward an MMP's attribution callback (Adjust, AppsFlyer, …) so
|
|
@@ -1029,6 +1272,7 @@ export class RevnixClient {
|
|
|
1029
1272
|
...(attribution.campaign ? { campaign: attribution.campaign } : {}),
|
|
1030
1273
|
...(attribution.adGroup ? { adGroup: attribution.adGroup } : {}),
|
|
1031
1274
|
...(attribution.creative ? { creative: attribution.creative } : {}),
|
|
1275
|
+
...(await this.integrityFor(customerId)),
|
|
1032
1276
|
});
|
|
1033
1277
|
await this.storage.setItem(LAST_ATTRIBUTION_KEY, payload);
|
|
1034
1278
|
})
|
|
@@ -1065,6 +1309,23 @@ export class RevnixClient {
|
|
|
1065
1309
|
.catch((err) => this.diag("setPushToken", err));
|
|
1066
1310
|
}
|
|
1067
1311
|
|
|
1312
|
+
private reportAdvertisingId(
|
|
1313
|
+
source: NonNullable<RevnixConfig["advertisingId"]>
|
|
1314
|
+
): void {
|
|
1315
|
+
void Promise.resolve()
|
|
1316
|
+
.then(source)
|
|
1317
|
+
.then(async (value) => {
|
|
1318
|
+
const id = value || null;
|
|
1319
|
+
const customerId = await this.customerIdPromise;
|
|
1320
|
+
const payload = JSON.stringify({ customerId, id });
|
|
1321
|
+
const last = await this.storage.getItem(LAST_AD_ID_KEY);
|
|
1322
|
+
if (last === payload || (id === null && last === null)) return;
|
|
1323
|
+
await this.setAttributes({ gps_adid: id });
|
|
1324
|
+
await this.storage.setItem(LAST_AD_ID_KEY, payload);
|
|
1325
|
+
})
|
|
1326
|
+
.catch((err) => this.diag("advertisingId", err));
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1068
1329
|
/** REV-272: the two moments that happen ON a paywall, with the loop guard
|
|
1069
1330
|
* applied. `fromPaywallId` travels so the server can refuse to hand back
|
|
1070
1331
|
* the very paywall being dismissed — the backstop for a host that reports
|
|
@@ -1112,6 +1373,7 @@ export class RevnixClient {
|
|
|
1112
1373
|
`/v1/customers/${encodeURIComponent(customerId)}/attributes`,
|
|
1113
1374
|
{ attributes }
|
|
1114
1375
|
);
|
|
1376
|
+
await this.invalidatePlacements();
|
|
1115
1377
|
}
|
|
1116
1378
|
|
|
1117
1379
|
/** REV-272: hand the SDK the URL that opened your app, from wherever you
|
|
@@ -1300,13 +1562,14 @@ export class RevnixClient {
|
|
|
1300
1562
|
// one-launch phantoms. Same guard, same reason, as triggerImplicit.
|
|
1301
1563
|
if (this.ephemeralAnonId) return;
|
|
1302
1564
|
void this.customerIdPromise
|
|
1303
|
-
.then((customerId) =>
|
|
1565
|
+
.then(async (customerId) =>
|
|
1304
1566
|
this.request<InstallResponse>("POST", "/v1/installs", {
|
|
1305
1567
|
customerId,
|
|
1306
1568
|
occurredAt: Date.now(),
|
|
1307
1569
|
sdkVersion: SDK_VERSION,
|
|
1308
1570
|
installReferrer: referrer.slice(0, 1024),
|
|
1309
1571
|
...this.installMetadata,
|
|
1572
|
+
...(await this.integrityFor(customerId)),
|
|
1310
1573
|
})
|
|
1311
1574
|
)
|
|
1312
1575
|
.then((body) => {
|
|
@@ -1341,13 +1604,14 @@ export class RevnixClient {
|
|
|
1341
1604
|
if (this.disposed || token.trim().length === 0) return Promise.resolve(undefined);
|
|
1342
1605
|
if (this.ephemeralAnonId) return Promise.resolve(undefined);
|
|
1343
1606
|
return this.customerIdPromise
|
|
1344
|
-
.then((customerId) =>
|
|
1607
|
+
.then(async (customerId) =>
|
|
1345
1608
|
this.request<InstallResponse>("POST", "/v1/installs", {
|
|
1346
1609
|
customerId,
|
|
1347
1610
|
occurredAt: Date.now(),
|
|
1348
1611
|
sdkVersion: SDK_VERSION,
|
|
1349
1612
|
attributionToken: token.slice(0, 2048),
|
|
1350
1613
|
...this.installMetadata,
|
|
1614
|
+
...(await this.integrityFor(customerId)),
|
|
1351
1615
|
})
|
|
1352
1616
|
)
|
|
1353
1617
|
.then((body) => {
|
|
@@ -1492,6 +1756,19 @@ export class RevnixClient {
|
|
|
1492
1756
|
return extra;
|
|
1493
1757
|
}
|
|
1494
1758
|
|
|
1759
|
+
private subscribeLifecycle(configured: RevnixConfig["lifecycle"]): void {
|
|
1760
|
+
if (configured === false) return;
|
|
1761
|
+
const lifecycle: RevnixLifecycle | null = configured ?? browserLifecycle();
|
|
1762
|
+
if (!lifecycle) return;
|
|
1763
|
+
try {
|
|
1764
|
+
this.lifecycleUnsubscribe = lifecycle.onStateChange((state) =>
|
|
1765
|
+
this.onAppState(state)
|
|
1766
|
+
);
|
|
1767
|
+
} catch (err) {
|
|
1768
|
+
this.diag("lifecycleSubscribe", err);
|
|
1769
|
+
}
|
|
1770
|
+
}
|
|
1771
|
+
|
|
1495
1772
|
/** REV-272: the launch-time moments, plus the foreground listener that
|
|
1496
1773
|
* `session_start` is built on.
|
|
1497
1774
|
*
|
|
@@ -1500,7 +1777,7 @@ export class RevnixClient {
|
|
|
1500
1777
|
* install too when this launch generated the anonymous id. All three are
|
|
1501
1778
|
* independent placements, so an app may have any subset configured and the
|
|
1502
1779
|
* others cost nothing. */
|
|
1503
|
-
private startImplicit(
|
|
1780
|
+
private startImplicit(): void {
|
|
1504
1781
|
// freshInstall is set inside loadCustomerId, which the constructor kicked
|
|
1505
1782
|
// off but did not await — so read it after that promise settles rather
|
|
1506
1783
|
// than now, when it is still false on a genuine first launch.
|
|
@@ -1510,25 +1787,6 @@ export class RevnixClient {
|
|
|
1510
1787
|
// are still reported — a launch is a launch whether or not a paywall
|
|
1511
1788
|
// showed — but an app that configured all three must not have three
|
|
1512
1789
|
// paywalls pushed onto its first frame.
|
|
1513
|
-
// Subscribed BEFORE the batch, which can take a full network timeout per
|
|
1514
|
-
// moment when offline: a customer who backgrounds the app during that
|
|
1515
|
-
// window and comes back an hour later is a session, and missing the
|
|
1516
|
-
// background transition would lose it.
|
|
1517
|
-
if (configured !== false) {
|
|
1518
|
-
const lifecycle: RevnixLifecycle | null =
|
|
1519
|
-
configured ?? browserLifecycle();
|
|
1520
|
-
if (lifecycle) {
|
|
1521
|
-
try {
|
|
1522
|
-
this.lifecycleUnsubscribe = lifecycle.onStateChange((state) =>
|
|
1523
|
-
this.onAppState(state)
|
|
1524
|
-
);
|
|
1525
|
-
} catch (err) {
|
|
1526
|
-
// A broken adapter costs session detection, not the client.
|
|
1527
|
-
this.diag("lifecycleSubscribe", err);
|
|
1528
|
-
}
|
|
1529
|
-
}
|
|
1530
|
-
}
|
|
1531
|
-
|
|
1532
1790
|
const sessionExtra = this.consumePreviousSessionMs().catch(
|
|
1533
1791
|
(err: unknown) => {
|
|
1534
1792
|
this.diag("session", err);
|
|
@@ -1568,6 +1826,8 @@ export class RevnixClient {
|
|
|
1568
1826
|
|
|
1569
1827
|
private onAppState(state: RevnixAppState): void {
|
|
1570
1828
|
if (this.disposed) return;
|
|
1829
|
+
if (state === "background") this.startEventFlush();
|
|
1830
|
+
if (!this.implicitEnabled) return;
|
|
1571
1831
|
if (state === "background") {
|
|
1572
1832
|
// First report wins: a platform that repeats "background" (a paused
|
|
1573
1833
|
// Activity re-reporting) must not keep resetting the clock forward.
|
|
@@ -1619,6 +1879,8 @@ export class RevnixClient {
|
|
|
1619
1879
|
this.lifecycleUnsubscribe = null;
|
|
1620
1880
|
this.stopStoreObserver?.();
|
|
1621
1881
|
this.stopStoreObserver = null;
|
|
1882
|
+
if (this.eventTimer !== null) clearTimeout(this.eventTimer);
|
|
1883
|
+
this.eventTimer = null;
|
|
1622
1884
|
}
|
|
1623
1885
|
|
|
1624
1886
|
/** A block paywall reporting a paint string it could not read. Routed to the
|
|
@@ -1704,12 +1966,17 @@ export class RevnixClient {
|
|
|
1704
1966
|
/** Fire-and-forget install report (REV-093). Never blocks startup and
|
|
1705
1967
|
* never throws — the backend absorbs retries idempotently per id. */
|
|
1706
1968
|
private reportInstall(customerId: string): void {
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1969
|
+
const occurredAt = Date.now();
|
|
1970
|
+
void this.integrityFor(customerId)
|
|
1971
|
+
.then((integrity) =>
|
|
1972
|
+
this.request<InstallResponse>("POST", "/v1/installs", {
|
|
1973
|
+
customerId,
|
|
1974
|
+
occurredAt,
|
|
1975
|
+
sdkVersion: SDK_VERSION,
|
|
1976
|
+
...this.installMetadata,
|
|
1977
|
+
...integrity,
|
|
1978
|
+
})
|
|
1979
|
+
)
|
|
1713
1980
|
.then((body) => {
|
|
1714
1981
|
if (!this.ephemeralAnonId) this.refreshAttribution();
|
|
1715
1982
|
return this.deliverDeferredDeepLink(body.deferredDeepLink);
|
|
@@ -1717,6 +1984,38 @@ export class RevnixClient {
|
|
|
1717
1984
|
.catch((err) => this.diag("reportInstall", err));
|
|
1718
1985
|
}
|
|
1719
1986
|
|
|
1987
|
+
private integrityFor(
|
|
1988
|
+
customerId: string
|
|
1989
|
+
): Promise<{ integrity?: IntegrityEvidence }> {
|
|
1990
|
+
const source = this.deviceIntegrity;
|
|
1991
|
+
if (!source) return Promise.resolve({});
|
|
1992
|
+
if (this.integrityRequest?.customerId !== customerId) {
|
|
1993
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
1994
|
+
const timeout = new Promise<never>((_, reject) => {
|
|
1995
|
+
timer = setTimeout(
|
|
1996
|
+
() => reject(new Error("device integrity timed out")),
|
|
1997
|
+
INTEGRITY_TIMEOUT_MS
|
|
1998
|
+
);
|
|
1999
|
+
});
|
|
2000
|
+
const body = Promise.race([
|
|
2001
|
+
Promise.resolve().then(() => source(customerId)),
|
|
2002
|
+
timeout,
|
|
2003
|
+
])
|
|
2004
|
+
.then((evidence) => {
|
|
2005
|
+
const integrity = wellFormedIntegrity(evidence);
|
|
2006
|
+
if (!integrity) throw new Error("device integrity unavailable");
|
|
2007
|
+
return { integrity };
|
|
2008
|
+
})
|
|
2009
|
+
.catch((err: unknown) => {
|
|
2010
|
+
this.diag("deviceIntegrity", err);
|
|
2011
|
+
return {};
|
|
2012
|
+
})
|
|
2013
|
+
.finally(() => clearTimeout(timer));
|
|
2014
|
+
this.integrityRequest = { customerId, body };
|
|
2015
|
+
}
|
|
2016
|
+
return this.integrityRequest.body;
|
|
2017
|
+
}
|
|
2018
|
+
|
|
1720
2019
|
/** REV-268: assemble and encode the device facts once. Runtime-detected
|
|
1721
2020
|
* facts first, the app's `device` over them, then the three the SDK owns.
|
|
1722
2021
|
* `installedAt` is the first launch this storage ever saw — written on
|
|
@@ -1867,25 +2166,13 @@ export class RevnixClient {
|
|
|
1867
2166
|
// common no-experiment offline paywall keeps working across identity
|
|
1868
2167
|
// changes.
|
|
1869
2168
|
|
|
1870
|
-
private async readPlacementMap(): Promise<
|
|
1871
|
-
Record<
|
|
1872
|
-
string,
|
|
1873
|
-
{ data: PlacementResolution; fetchedAt: number; customerId?: string }
|
|
1874
|
-
>
|
|
1875
|
-
> {
|
|
2169
|
+
private async readPlacementMap(): Promise<Record<string, PlacementCacheEntry>> {
|
|
1876
2170
|
try {
|
|
1877
2171
|
const raw = await this.storage.getItem(PLACEMENTS_CACHE_KEY);
|
|
1878
2172
|
if (!raw) return {};
|
|
1879
2173
|
const parsed = JSON.parse(raw);
|
|
1880
2174
|
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
|
|
1881
|
-
? (parsed as Record<
|
|
1882
|
-
string,
|
|
1883
|
-
{
|
|
1884
|
-
data: PlacementResolution;
|
|
1885
|
-
fetchedAt: number;
|
|
1886
|
-
customerId?: string;
|
|
1887
|
-
}
|
|
1888
|
-
>)
|
|
2175
|
+
? (parsed as Record<string, PlacementCacheEntry>)
|
|
1889
2176
|
: {};
|
|
1890
2177
|
} catch {
|
|
1891
2178
|
return {};
|
|
@@ -1895,7 +2182,7 @@ export class RevnixClient {
|
|
|
1895
2182
|
private async readPlacementCache(
|
|
1896
2183
|
placementKey: string,
|
|
1897
2184
|
customerId: string
|
|
1898
|
-
): Promise<
|
|
2185
|
+
): Promise<PlacementCacheEntry | null> {
|
|
1899
2186
|
const map = await this.readPlacementMap();
|
|
1900
2187
|
const entry = map[placementKey];
|
|
1901
2188
|
if (!entry) return null;
|
|
@@ -1913,40 +2200,74 @@ export class RevnixClient {
|
|
|
1913
2200
|
return entry;
|
|
1914
2201
|
}
|
|
1915
2202
|
|
|
1916
|
-
private
|
|
2203
|
+
private writePlacementCache(
|
|
1917
2204
|
placementKey: string,
|
|
1918
2205
|
data: PlacementResolution,
|
|
1919
|
-
customerId: string
|
|
2206
|
+
customerId: string,
|
|
2207
|
+
fetchedAt: number
|
|
2208
|
+
): Promise<void> {
|
|
2209
|
+
return this.mutatePlacements((map) => {
|
|
2210
|
+
map[placementKey] = { data, fetchedAt, customerId };
|
|
2211
|
+
});
|
|
2212
|
+
}
|
|
2213
|
+
|
|
2214
|
+
private deletePlacementCache(placementKey: string): Promise<void> {
|
|
2215
|
+
return this.mutatePlacements((map) => {
|
|
2216
|
+
delete map[placementKey];
|
|
2217
|
+
});
|
|
2218
|
+
}
|
|
2219
|
+
|
|
2220
|
+
private mutatePlacements(
|
|
2221
|
+
mutate: (map: Record<string, PlacementCacheEntry>) => void
|
|
1920
2222
|
): Promise<void> {
|
|
2223
|
+
return this.withStorageLock(async () => {
|
|
2224
|
+
try {
|
|
2225
|
+
const map = await this.readPlacementMap();
|
|
2226
|
+
mutate(map);
|
|
2227
|
+
await this.storage.setItem(PLACEMENTS_CACHE_KEY, JSON.stringify(map));
|
|
2228
|
+
} catch (err) {
|
|
2229
|
+
this.diag("storageWrite", err);
|
|
2230
|
+
}
|
|
2231
|
+
});
|
|
2232
|
+
}
|
|
2233
|
+
|
|
2234
|
+
private async placementsInvalidatedAt(): Promise<number> {
|
|
1921
2235
|
try {
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
|
|
2236
|
+
return Number(await this.storage.getItem(PLACEMENTS_INVALIDATED_AT_KEY)) || 0;
|
|
2237
|
+
} catch {
|
|
2238
|
+
return 0;
|
|
2239
|
+
}
|
|
2240
|
+
}
|
|
2241
|
+
|
|
2242
|
+
private async invalidatePlacements(): Promise<void> {
|
|
2243
|
+
try {
|
|
2244
|
+
await this.storage.setItem(PLACEMENTS_INVALIDATED_AT_KEY, String(Date.now()));
|
|
1925
2245
|
} catch (err) {
|
|
1926
|
-
// Best-effort, but reported (REV-200 M26).
|
|
1927
2246
|
this.diag("storageWrite", err);
|
|
1928
2247
|
}
|
|
1929
2248
|
}
|
|
1930
2249
|
|
|
1931
2250
|
// ——— C7: persisted purchase retry queue ———
|
|
1932
2251
|
|
|
1933
|
-
|
|
1934
|
-
|
|
1935
|
-
|
|
1936
|
-
|
|
1937
|
-
|
|
2252
|
+
private storageLock: Promise<void> = Promise.resolve();
|
|
2253
|
+
|
|
2254
|
+
private withStorageLock<T>(task: () => Promise<T>): Promise<T> {
|
|
2255
|
+
const run = this.storageLock.then(task);
|
|
2256
|
+
this.storageLock = run.then(
|
|
2257
|
+
() => {},
|
|
2258
|
+
() => {}
|
|
2259
|
+
);
|
|
2260
|
+
return run;
|
|
2261
|
+
}
|
|
1938
2262
|
|
|
1939
2263
|
private mutateQueue(
|
|
1940
2264
|
mutate: (queue: PendingPurchase[]) => PendingPurchase[]
|
|
1941
2265
|
): Promise<void> {
|
|
1942
|
-
|
|
2266
|
+
return this.withStorageLock(async () => {
|
|
1943
2267
|
const queue = await this.readPurchaseQueue();
|
|
1944
2268
|
const next = mutate(queue);
|
|
1945
2269
|
if (next !== queue) await this.writePurchaseQueue(next);
|
|
1946
2270
|
});
|
|
1947
|
-
// The chain must survive a failed mutation or every later one deadlocks.
|
|
1948
|
-
this.queueLock = run.catch(() => {});
|
|
1949
|
-
return run;
|
|
1950
2271
|
}
|
|
1951
2272
|
|
|
1952
2273
|
private async readPurchaseQueue(): Promise<PendingPurchase[]> {
|
|
@@ -1974,26 +2295,29 @@ export class RevnixClient {
|
|
|
1974
2295
|
customerId: string,
|
|
1975
2296
|
input: RegisterPurchaseInput
|
|
1976
2297
|
): Promise<void> {
|
|
1977
|
-
|
|
1978
|
-
|
|
1979
|
-
queue.
|
|
1980
|
-
|
|
1981
|
-
|
|
1982
|
-
|
|
2298
|
+
return this.mutateQueue((queue) => {
|
|
2299
|
+
if (queue.some((q) => pendingKey(q.input) === pendingKey(input))) return queue;
|
|
2300
|
+
const kept = queue.slice(Math.max(0, queue.length - PURCHASE_QUEUE_CAP + 1));
|
|
2301
|
+
for (const dropped of queue.slice(0, queue.length - kept.length)) {
|
|
2302
|
+
this.diag("registerPurchase", new Error(`queue full, dropped ${pendingKey(dropped.input)}`));
|
|
2303
|
+
}
|
|
2304
|
+
return [...kept, { customerId, input, queuedAt: Date.now() }];
|
|
2305
|
+
});
|
|
1983
2306
|
}
|
|
1984
2307
|
|
|
1985
2308
|
private async request<T>(
|
|
1986
2309
|
method: "GET" | "POST",
|
|
1987
2310
|
path: string,
|
|
1988
2311
|
body?: unknown,
|
|
1989
|
-
extraHeaders?: Record<string, string
|
|
2312
|
+
extraHeaders?: Record<string, string>,
|
|
2313
|
+
timeoutMs = this.requestTimeoutMs
|
|
1990
2314
|
): Promise<T> {
|
|
1991
2315
|
// C7: abort the request after requestTimeoutMs so a slow or dead backend
|
|
1992
2316
|
// can't hang the app. AbortController is guarded for very old runtimes.
|
|
1993
2317
|
const controller =
|
|
1994
2318
|
typeof AbortController !== "undefined" ? new AbortController() : undefined;
|
|
1995
2319
|
const timer = controller
|
|
1996
|
-
? setTimeout(() => controller.abort(),
|
|
2320
|
+
? setTimeout(() => controller.abort(), timeoutMs)
|
|
1997
2321
|
: undefined;
|
|
1998
2322
|
|
|
1999
2323
|
// REV-200 (M26): piggyback the background-failure counter on this
|
|
@@ -2025,7 +2349,7 @@ export class RevnixClient {
|
|
|
2025
2349
|
(err instanceof Error && err.name === "AbortError")
|
|
2026
2350
|
) {
|
|
2027
2351
|
throw new RevnixTimeoutError(
|
|
2028
|
-
`Revnix request to ${path} timed out after ${
|
|
2352
|
+
`Revnix request to ${path} timed out after ${timeoutMs}ms`
|
|
2029
2353
|
);
|
|
2030
2354
|
}
|
|
2031
2355
|
throw new RevnixNetworkError(
|