react-native-revnix 1.5.0 → 1.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/client.d.ts +46 -11
- package/dist/client.js +335 -114
- package/dist/esm/client.js +334 -114
- 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/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/package.json +1 -1
- package/src/client.ts +388 -146
- 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
|
@@ -67,7 +67,18 @@ const PENDING_PURCHASES_KEY = "revnix.pendingPurchases";
|
|
|
67
67
|
const PLACEMENTS_CACHE_KEY = "revnix.placements";
|
|
68
68
|
const LAST_ATTRIBUTION_KEY = "revnix.lastAttribution";
|
|
69
69
|
const LAST_PUSH_TOKEN_KEY = "revnix.lastPushToken";
|
|
70
|
+
const EVENT_QUEUE_KEY = "revnix.eventQueue";
|
|
71
|
+
const PLACEMENTS_INVALIDATED_AT_KEY = "revnix.placementsInvalidatedAt";
|
|
70
72
|
const DEFAULT_TIMEOUT_MS = 10_000;
|
|
73
|
+
const EVENT_QUEUE_CAP = 1000;
|
|
74
|
+
const EVENT_BATCH_SIZE = 100;
|
|
75
|
+
const EVENT_FLUSH_THRESHOLD = 20;
|
|
76
|
+
const EVENT_FLUSH_DELAY_MS = 10_000;
|
|
77
|
+
const EVENT_RETRY_HOLD_MS = 30_000;
|
|
78
|
+
const PLACEMENT_FRESH_MS = 24 * 60 * 60 * 1000;
|
|
79
|
+
const CACHED_ENTITLEMENTS_TIMEOUT_MS = 3_000;
|
|
80
|
+
const PURCHASE_QUEUE_CAP = 100;
|
|
81
|
+
const PURCHASE_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
|
|
71
82
|
|
|
72
83
|
// REV-204 (L10): how many customers keep a cached entitlements snapshot.
|
|
73
84
|
const ENTITLEMENTS_CACHE_MAX = 4;
|
|
@@ -105,6 +116,57 @@ const CLOCK_ROLLBACK_TOLERANCE_MS = 5 * 60 * 1000;
|
|
|
105
116
|
interface PendingPurchase {
|
|
106
117
|
customerId: string;
|
|
107
118
|
input: RegisterPurchaseInput;
|
|
119
|
+
queuedAt?: number;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
interface PlacementCacheEntry {
|
|
123
|
+
data: PlacementResolution;
|
|
124
|
+
fetchedAt: number;
|
|
125
|
+
customerId?: string;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
interface QueuedEvent {
|
|
129
|
+
path: string;
|
|
130
|
+
body: Record<string, unknown>;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function batchResults(
|
|
134
|
+
response: unknown,
|
|
135
|
+
path: string
|
|
136
|
+
): Array<{ status: number; error?: string }> {
|
|
137
|
+
const results = (response as { results?: unknown } | null)?.results;
|
|
138
|
+
if (
|
|
139
|
+
Array.isArray(results) &&
|
|
140
|
+
results.every(
|
|
141
|
+
(r) => typeof r === "object" && r !== null && typeof r.status === "number"
|
|
142
|
+
)
|
|
143
|
+
) {
|
|
144
|
+
return results;
|
|
145
|
+
}
|
|
146
|
+
throw new RevnixError(0, `Revnix: server returned an ill-shaped batch response for ${path}`);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function paywallImageUrls(value: unknown): string[] {
|
|
150
|
+
const found = new Set<string>();
|
|
151
|
+
const visit = (node: unknown): void => {
|
|
152
|
+
if (Array.isArray(node)) {
|
|
153
|
+
node.forEach(visit);
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
if (typeof node !== "object" || node === null) return;
|
|
157
|
+
const o = node as Record<string, unknown>;
|
|
158
|
+
const background = o.background as { image?: { url?: unknown } } | null | undefined;
|
|
159
|
+
for (const url of [
|
|
160
|
+
o.heroImageUrl,
|
|
161
|
+
o.type === "image" ? o.url : undefined,
|
|
162
|
+
background?.image?.url,
|
|
163
|
+
]) {
|
|
164
|
+
if (typeof url === "string" && url.length > 0) found.add(url);
|
|
165
|
+
}
|
|
166
|
+
Object.values(o).forEach(visit);
|
|
167
|
+
};
|
|
168
|
+
visit(value);
|
|
169
|
+
return [...found];
|
|
108
170
|
}
|
|
109
171
|
|
|
110
172
|
interface InstallResponse {
|
|
@@ -213,6 +275,9 @@ export class RevnixClient {
|
|
|
213
275
|
* /v1/installs uses, and independent of the device header (which an app
|
|
214
276
|
* can turn off entirely). */
|
|
215
277
|
private freshInstall = false;
|
|
278
|
+
private eventFlush: Promise<void> | null = null;
|
|
279
|
+
private eventTimer: ReturnType<typeof setTimeout> | null = null;
|
|
280
|
+
private eventRetryAt = 0;
|
|
216
281
|
|
|
217
282
|
constructor(config: RevnixConfig) {
|
|
218
283
|
if (!config.apiKey) throw new Error("Revnix: apiKey is required");
|
|
@@ -283,7 +348,11 @@ export class RevnixClient {
|
|
|
283
348
|
config.implicitPlacements ?? config.onImplicitPaywall !== undefined;
|
|
284
349
|
this.sessionTimeoutMs =
|
|
285
350
|
config.sessionTimeoutMs ?? DEFAULT_SESSION_TIMEOUT_MS;
|
|
286
|
-
|
|
351
|
+
this.subscribeLifecycle(config.lifecycle);
|
|
352
|
+
if (this.implicitEnabled) this.startImplicit();
|
|
353
|
+
void this.readEvents().then((events) => {
|
|
354
|
+
if (events.length > 0) this.startEventFlush();
|
|
355
|
+
});
|
|
287
356
|
const { document, location } = globalThis as {
|
|
288
357
|
document?: unknown;
|
|
289
358
|
location?: { href: string; search: string };
|
|
@@ -396,6 +465,7 @@ export class RevnixClient {
|
|
|
396
465
|
const customerId = await this.customerIdPromise;
|
|
397
466
|
try {
|
|
398
467
|
const result = await this.postPurchase(customerId, input);
|
|
468
|
+
await this.invalidatePlacements();
|
|
399
469
|
// Identity resolution may have merged this device's id away (restore
|
|
400
470
|
// after login), so the server's id is the one to keep using.
|
|
401
471
|
//
|
|
@@ -522,23 +592,28 @@ export class RevnixClient {
|
|
|
522
592
|
|
|
523
593
|
/** C7: retry purchases that failed to register and were persisted locally.
|
|
524
594
|
* Runs automatically at construction; call again when connectivity returns.
|
|
525
|
-
* Idempotent and safe to call repeatedly.
|
|
595
|
+
* Idempotent and safe to call repeatedly. Purchases queued more than 30
|
|
596
|
+
* days ago are dropped with a diagnostic. */
|
|
526
597
|
async retryPendingPurchases(): Promise<void> {
|
|
598
|
+
const now = Date.now();
|
|
599
|
+
await this.mutateQueue((queue) =>
|
|
600
|
+
queue.some((q) => q.queuedAt === undefined)
|
|
601
|
+
? queue.map((q) => ({ ...q, queuedAt: q.queuedAt ?? now }))
|
|
602
|
+
: queue
|
|
603
|
+
);
|
|
527
604
|
const queue = await this.readPurchaseQueue();
|
|
528
605
|
for (const item of queue) {
|
|
529
606
|
if (this.disposed) return;
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
607
|
+
if (now - (item.queuedAt ?? now) > PURCHASE_MAX_AGE_MS) {
|
|
608
|
+
this.diag("retryPendingPurchases", new Error(`expired ${pendingKey(item.input)}`));
|
|
609
|
+
} else {
|
|
610
|
+
try {
|
|
611
|
+
await this.postPurchase(item.customerId, item.input);
|
|
612
|
+
await this.invalidatePlacements();
|
|
613
|
+
} catch (err) {
|
|
537
614
|
this.diag("retryPendingPurchases", err);
|
|
538
|
-
continue;
|
|
615
|
+
if (worthQueueing(err)) continue;
|
|
539
616
|
}
|
|
540
|
-
// Permanent rejection — remove it.
|
|
541
|
-
this.diag("retryPendingPurchases", err);
|
|
542
617
|
}
|
|
543
618
|
// Remove THIS item from the queue as it stands now, not from the
|
|
544
619
|
// snapshot iterated above: each postPurchase can spend seconds in a
|
|
@@ -600,11 +675,15 @@ export class RevnixClient {
|
|
|
600
675
|
/** The authoritative network read: no TTL, no cache fallback — throws so
|
|
601
676
|
* the caller sees the real failure (waitForEntitlements needs 429s). */
|
|
602
677
|
private async fetchFreshEntitlements(
|
|
603
|
-
customerId: string
|
|
678
|
+
customerId: string,
|
|
679
|
+
timeoutMs?: number
|
|
604
680
|
): Promise<CustomerEntitlements> {
|
|
605
681
|
const fresh = await this.request<CustomerEntitlements>(
|
|
606
682
|
"GET",
|
|
607
|
-
`/v1/customers/${encodeURIComponent(customerId)}/entitlements
|
|
683
|
+
`/v1/customers/${encodeURIComponent(customerId)}/entitlements`,
|
|
684
|
+
undefined,
|
|
685
|
+
undefined,
|
|
686
|
+
timeoutMs
|
|
608
687
|
);
|
|
609
688
|
await this.writeEntitlementsCache(customerId, fresh);
|
|
610
689
|
const result = { ...fresh, stale: false, fetchedAt: Date.now() };
|
|
@@ -615,8 +694,14 @@ export class RevnixClient {
|
|
|
615
694
|
private async fetchEntitlementsWithFallback(
|
|
616
695
|
customerId: string
|
|
617
696
|
): Promise<CustomerEntitlements> {
|
|
697
|
+
const hasCache = (await this.readEntitlementsCache(customerId)) !== null;
|
|
618
698
|
try {
|
|
619
|
-
return await this.fetchFreshEntitlements(
|
|
699
|
+
return await this.fetchFreshEntitlements(
|
|
700
|
+
customerId,
|
|
701
|
+
hasCache
|
|
702
|
+
? Math.min(CACHED_ENTITLEMENTS_TIMEOUT_MS, this.requestTimeoutMs)
|
|
703
|
+
: undefined
|
|
704
|
+
);
|
|
620
705
|
} catch (err) {
|
|
621
706
|
// REV-198 (CTO-R2 M22): the cache exists to ride out failures that
|
|
622
707
|
// might succeed next time — network, timeout, 429, 5xx. A deliberate
|
|
@@ -740,54 +825,82 @@ export class RevnixClient {
|
|
|
740
825
|
}
|
|
741
826
|
|
|
742
827
|
/** Resolve a named in-app location to the currently published offering —
|
|
743
|
-
* the one-line paywall call.
|
|
744
|
-
*
|
|
745
|
-
*
|
|
746
|
-
*
|
|
747
|
-
*
|
|
828
|
+
* the one-line paywall call. Served instantly from a cache under 24 h old
|
|
829
|
+
* that was fetched for this customer since the last `setAttributes` or
|
|
830
|
+
* purchase, refreshed in the background. Otherwise reads the network and
|
|
831
|
+
* caches the result; if that read FAILS (offline / incident) it falls back
|
|
832
|
+
* to the last cached resolution with `stale: true`, so the paywall renders
|
|
833
|
+
* the last published version rather than not at all. Throws only when
|
|
834
|
+
* there is nothing cached. A deliberate rejection seen by the background
|
|
835
|
+
* refresh drops the cache, so the next resolve throws it. */
|
|
748
836
|
async resolvePlacement(placementKey: string): Promise<PlacementResolution> {
|
|
749
837
|
const customerId = await this.customerIdPromise;
|
|
838
|
+
const cached = await this.readPlacementCache(placementKey, customerId);
|
|
839
|
+
const age = Date.now() - (cached?.fetchedAt ?? 0);
|
|
840
|
+
if (
|
|
841
|
+
cached &&
|
|
842
|
+
cached.customerId === customerId &&
|
|
843
|
+
cached.fetchedAt > (await this.placementsInvalidatedAt()) &&
|
|
844
|
+
age >= 0 &&
|
|
845
|
+
age <= PLACEMENT_FRESH_MS
|
|
846
|
+
) {
|
|
847
|
+
void this.refreshPlacement(placementKey);
|
|
848
|
+
return { ...cached.data, stale: false, fetchedAt: cached.fetchedAt };
|
|
849
|
+
}
|
|
750
850
|
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() };
|
|
851
|
+
return await this.fetchPlacement(placementKey);
|
|
777
852
|
} catch (err) {
|
|
778
853
|
// REV-198 (M22): same rule as getEntitlements — an unpublished catalog
|
|
779
854
|
// (409) or deleted placement (404) is the server's deliberate answer,
|
|
780
855
|
// and caching over it meant an unpublished paywall kept rendering
|
|
781
856
|
// fleet-wide with no way to pull it.
|
|
782
857
|
if (err instanceof RevnixError && !err.isRetryable) throw err;
|
|
783
|
-
const
|
|
784
|
-
if (
|
|
785
|
-
return { ...
|
|
858
|
+
const fallback = await this.readPlacementCache(placementKey, customerId);
|
|
859
|
+
if (fallback) {
|
|
860
|
+
return { ...fallback.data, stale: true, fetchedAt: fallback.fetchedAt };
|
|
786
861
|
}
|
|
787
862
|
throw err;
|
|
788
863
|
}
|
|
789
864
|
}
|
|
790
865
|
|
|
866
|
+
/** Fetch these placements now so the next `resolvePlacement` answers from
|
|
867
|
+
* the cache. Never throws; failures report to `onDiagnostic`. Paywall
|
|
868
|
+
* images are warmed by `prefetchPaywallImages` from
|
|
869
|
+
* `react-native-revnix/ui`. */
|
|
870
|
+
async preload(placementKeys: string[]): Promise<void> {
|
|
871
|
+
await Promise.all(placementKeys.map((key) => this.refreshPlacement(key)));
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
private async refreshPlacement(placementKey: string): Promise<void> {
|
|
875
|
+
try {
|
|
876
|
+
await this.fetchPlacement(placementKey);
|
|
877
|
+
} catch (err) {
|
|
878
|
+
if (err instanceof RevnixError && !err.isRetryable) {
|
|
879
|
+
await this.deletePlacementCache(placementKey);
|
|
880
|
+
}
|
|
881
|
+
this.diag("resolvePlacement", err);
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
private async fetchPlacement(placementKey: string): Promise<PlacementResolution> {
|
|
886
|
+
const customerId = await this.customerIdPromise;
|
|
887
|
+
const fetchedAt = Date.now();
|
|
888
|
+
const path = `/v1/placements/${encodeURIComponent(placementKey)}/offering`;
|
|
889
|
+
const deviceHeader = this.ephemeralAnonId
|
|
890
|
+
? null
|
|
891
|
+
: await this.deviceHeaderPromise;
|
|
892
|
+
const fresh = await this.request<PlacementResolution>(
|
|
893
|
+
"GET",
|
|
894
|
+
this.ephemeralAnonId
|
|
895
|
+
? path
|
|
896
|
+
: `${path}?customer=${encodeURIComponent(customerId)}`,
|
|
897
|
+
undefined,
|
|
898
|
+
deviceHeader ? { [DEVICE_HEADER]: deviceHeader } : undefined
|
|
899
|
+
);
|
|
900
|
+
await this.writePlacementCache(placementKey, fresh, customerId, fetchedAt);
|
|
901
|
+
return { ...fresh, stale: false, fetchedAt: Date.now() };
|
|
902
|
+
}
|
|
903
|
+
|
|
791
904
|
/** REV-157: the last resolution cached for `placementKey`, with NO network
|
|
792
905
|
* call — for an instant, offline-safe paywall at launch. Always
|
|
793
906
|
* `stale: true`; follow with resolvePlacement() to revalidate. Null if
|
|
@@ -896,7 +1009,8 @@ export class RevnixClient {
|
|
|
896
1009
|
* - `restore` — restore purchases was tapped
|
|
897
1010
|
* - `error` — the paywall itself failed (config, products, render)
|
|
898
1011
|
*
|
|
899
|
-
*
|
|
1012
|
+
* Queued and sent in batches (see `flushEvents`): resolves once queued,
|
|
1013
|
+
* never throws.
|
|
900
1014
|
*
|
|
901
1015
|
* `viewId` is the id `logPaywallShown` returned for THIS display. Passing
|
|
902
1016
|
* it is what threads the whole life of one impression together and puts
|
|
@@ -913,7 +1027,7 @@ export class RevnixClient {
|
|
|
913
1027
|
* which caps the report at one per display per event type. Pass one per
|
|
914
1028
|
* occurrence (and reuse it across your own retries) to record every
|
|
915
1029
|
* occurrence — e.g. a customer who fails, retries and fails again. */
|
|
916
|
-
logPaywallEvent(
|
|
1030
|
+
async logPaywallEvent(
|
|
917
1031
|
event: PaywallInteraction,
|
|
918
1032
|
viewId: string,
|
|
919
1033
|
options?: {
|
|
@@ -926,29 +1040,8 @@ export class RevnixClient {
|
|
|
926
1040
|
/** Idempotency key for this occurrence; defaults to `viewId`. */
|
|
927
1041
|
eventId?: string;
|
|
928
1042
|
}
|
|
929
|
-
): void {
|
|
1043
|
+
): Promise<void> {
|
|
930
1044
|
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
1045
|
if (event === "purchase_abandoned") {
|
|
953
1046
|
this.fireImplicitFromPaywall(
|
|
954
1047
|
"transaction_abandon",
|
|
@@ -956,6 +1049,23 @@ export class RevnixClient {
|
|
|
956
1049
|
options?.paywallId
|
|
957
1050
|
);
|
|
958
1051
|
}
|
|
1052
|
+
const customerId = await this.customerIdPromise;
|
|
1053
|
+
await this.enqueueEvent({
|
|
1054
|
+
path: "/v1/paywalls/events",
|
|
1055
|
+
body: {
|
|
1056
|
+
customerId,
|
|
1057
|
+
viewId,
|
|
1058
|
+
event,
|
|
1059
|
+
eventId: options?.eventId ?? viewId,
|
|
1060
|
+
occurredAt: Date.now(),
|
|
1061
|
+
sdkVersion: SDK_VERSION,
|
|
1062
|
+
...(options?.placementKey ? { placementKey: options.placementKey } : {}),
|
|
1063
|
+
...(options?.paywallId ? { paywallId: options.paywallId } : {}),
|
|
1064
|
+
...(options?.productId ? { productId: options.productId } : {}),
|
|
1065
|
+
...(options?.code ? { code: options.code } : {}),
|
|
1066
|
+
...(options?.message ? { message: options.message.slice(0, 1024) } : {}),
|
|
1067
|
+
},
|
|
1068
|
+
});
|
|
959
1069
|
}
|
|
960
1070
|
|
|
961
1071
|
/** Reports one ad impression's payout (POST /v1/ad-revenue).
|
|
@@ -984,28 +1094,136 @@ export class RevnixClient {
|
|
|
984
1094
|
}
|
|
985
1095
|
|
|
986
1096
|
/** Reports one custom in-app event (POST /v1/events), not for purchases.
|
|
987
|
-
*
|
|
988
|
-
|
|
1097
|
+
* Queued and sent in batches (see `flushEvents`): resolves once queued,
|
|
1098
|
+
* never throws. */
|
|
1099
|
+
async track(
|
|
989
1100
|
event: string,
|
|
990
1101
|
properties?: Record<string, string | number | boolean>,
|
|
991
1102
|
options?: { eventId?: string }
|
|
992
|
-
): void {
|
|
1103
|
+
): Promise<void> {
|
|
993
1104
|
if (!/^[a-z0-9_]{1,64}$/.test(event)) {
|
|
994
1105
|
this.diag("track", new Error(`invalid event name: ${event}`));
|
|
995
1106
|
return;
|
|
996
1107
|
}
|
|
997
1108
|
const eventId = options?.eventId ?? generateEventId();
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
})
|
|
1007
|
-
|
|
1008
|
-
|
|
1109
|
+
const customerId = await this.customerIdPromise;
|
|
1110
|
+
await this.enqueueEvent({
|
|
1111
|
+
path: "/v1/events",
|
|
1112
|
+
body: {
|
|
1113
|
+
customerId,
|
|
1114
|
+
event,
|
|
1115
|
+
eventId,
|
|
1116
|
+
occurredAt: Date.now(),
|
|
1117
|
+
...(properties ? { properties } : {}),
|
|
1118
|
+
},
|
|
1119
|
+
});
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
/** Send every queued `track` and paywall interaction event now. Events are
|
|
1123
|
+
* otherwise sent in batches: at 20 queued, 10 s after the first, when the
|
|
1124
|
+
* app goes to the background, and on the next launch. Never throws; a
|
|
1125
|
+
* transient failure keeps the events and holds further sends for 30 s. */
|
|
1126
|
+
async flushEvents(): Promise<void> {
|
|
1127
|
+
await this.eventFlush;
|
|
1128
|
+
this.startEventFlush();
|
|
1129
|
+
await this.eventFlush;
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
private async readEvents(): Promise<QueuedEvent[]> {
|
|
1133
|
+
try {
|
|
1134
|
+
const parsed = JSON.parse((await this.storage.getItem(EVENT_QUEUE_KEY)) ?? "[]");
|
|
1135
|
+
return Array.isArray(parsed) ? (parsed as QueuedEvent[]) : [];
|
|
1136
|
+
} catch {
|
|
1137
|
+
return [];
|
|
1138
|
+
}
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
private mutateEvents(mutate: (events: QueuedEvent[]) => QueuedEvent[]): Promise<number> {
|
|
1142
|
+
return this.withStorageLock(async () => {
|
|
1143
|
+
const next = mutate(await this.readEvents());
|
|
1144
|
+
try {
|
|
1145
|
+
await this.storage.setItem(EVENT_QUEUE_KEY, JSON.stringify(next));
|
|
1146
|
+
} catch (err) {
|
|
1147
|
+
this.diag("storageWrite", err);
|
|
1148
|
+
}
|
|
1149
|
+
return next.length;
|
|
1150
|
+
});
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
// ponytail: the whole queue is re-serialized per event; keep it in memory if 1000-item offline queues show up in profiles.
|
|
1154
|
+
private async enqueueEvent(event: QueuedEvent): Promise<void> {
|
|
1155
|
+
const count = await this.mutateEvents((events) => {
|
|
1156
|
+
const kept = events.slice(Math.max(0, events.length - EVENT_QUEUE_CAP + 1));
|
|
1157
|
+
if (kept.length < events.length) {
|
|
1158
|
+
this.diag("eventQueue", new Error("queue full, dropped the oldest event"));
|
|
1159
|
+
}
|
|
1160
|
+
return [...kept, event];
|
|
1161
|
+
});
|
|
1162
|
+
if (count >= EVENT_FLUSH_THRESHOLD) this.startEventFlush();
|
|
1163
|
+
else this.armEventTimer(EVENT_FLUSH_DELAY_MS);
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
private armEventTimer(ms: number): void {
|
|
1167
|
+
if (this.eventTimer !== null || this.disposed) return;
|
|
1168
|
+
this.eventTimer = setTimeout(() => {
|
|
1169
|
+
this.eventTimer = null;
|
|
1170
|
+
this.startEventFlush();
|
|
1171
|
+
}, ms);
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
private startEventFlush(): void {
|
|
1175
|
+
if (this.eventFlush !== null || this.disposed) return;
|
|
1176
|
+
const hold = this.eventRetryAt - Date.now();
|
|
1177
|
+
if (hold > 0) {
|
|
1178
|
+
this.armEventTimer(hold);
|
|
1179
|
+
return;
|
|
1180
|
+
}
|
|
1181
|
+
this.eventFlush = this.runEventFlush().finally(() => {
|
|
1182
|
+
this.eventFlush = null;
|
|
1183
|
+
});
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
private async runEventFlush(): Promise<void> {
|
|
1187
|
+
const byPath = new Map<string, QueuedEvent[]>();
|
|
1188
|
+
for (const event of await this.withStorageLock(() => this.readEvents())) {
|
|
1189
|
+
byPath.set(event.path, [...(byPath.get(event.path) ?? []), event]);
|
|
1190
|
+
}
|
|
1191
|
+
for (const [path, events] of byPath) {
|
|
1192
|
+
for (let start = 0; start < events.length; start += EVENT_BATCH_SIZE) {
|
|
1193
|
+
const chunk = events.slice(start, start + EVENT_BATCH_SIZE);
|
|
1194
|
+
try {
|
|
1195
|
+
const response = await this.request<unknown>("POST", path, {
|
|
1196
|
+
events: chunk.map((e) => e.body),
|
|
1197
|
+
});
|
|
1198
|
+
batchResults(response, path).forEach((result, i) => {
|
|
1199
|
+
if (result.status < 400) return;
|
|
1200
|
+
this.diag(
|
|
1201
|
+
"eventQueue",
|
|
1202
|
+
new RevnixError(
|
|
1203
|
+
result.status,
|
|
1204
|
+
`dropped ${path} event ${String(chunk[i]?.body.eventId)}: ${result.status} ${result.error ?? ""}`
|
|
1205
|
+
)
|
|
1206
|
+
);
|
|
1207
|
+
});
|
|
1208
|
+
await this.removeEvents(chunk);
|
|
1209
|
+
} catch (err) {
|
|
1210
|
+
if (worthQueueing(err)) {
|
|
1211
|
+
this.eventRetryAt = Date.now() + EVENT_RETRY_HOLD_MS;
|
|
1212
|
+
this.diag("eventQueue", err);
|
|
1213
|
+
return;
|
|
1214
|
+
}
|
|
1215
|
+
await this.removeEvents(chunk);
|
|
1216
|
+
this.diag("eventQueue", err);
|
|
1217
|
+
}
|
|
1218
|
+
}
|
|
1219
|
+
}
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
private async removeEvents(sent: QueuedEvent[]): Promise<void> {
|
|
1223
|
+
const sentJson = new Set(sent.map((e) => JSON.stringify(e)));
|
|
1224
|
+
await this.mutateEvents((events) =>
|
|
1225
|
+
events.filter((e) => !sentJson.has(JSON.stringify(e)))
|
|
1226
|
+
);
|
|
1009
1227
|
}
|
|
1010
1228
|
|
|
1011
1229
|
/** PT11: forward an MMP's attribution callback (Adjust, AppsFlyer, …) so
|
|
@@ -1112,6 +1330,7 @@ export class RevnixClient {
|
|
|
1112
1330
|
`/v1/customers/${encodeURIComponent(customerId)}/attributes`,
|
|
1113
1331
|
{ attributes }
|
|
1114
1332
|
);
|
|
1333
|
+
await this.invalidatePlacements();
|
|
1115
1334
|
}
|
|
1116
1335
|
|
|
1117
1336
|
/** REV-272: hand the SDK the URL that opened your app, from wherever you
|
|
@@ -1492,6 +1711,19 @@ export class RevnixClient {
|
|
|
1492
1711
|
return extra;
|
|
1493
1712
|
}
|
|
1494
1713
|
|
|
1714
|
+
private subscribeLifecycle(configured: RevnixConfig["lifecycle"]): void {
|
|
1715
|
+
if (configured === false) return;
|
|
1716
|
+
const lifecycle: RevnixLifecycle | null = configured ?? browserLifecycle();
|
|
1717
|
+
if (!lifecycle) return;
|
|
1718
|
+
try {
|
|
1719
|
+
this.lifecycleUnsubscribe = lifecycle.onStateChange((state) =>
|
|
1720
|
+
this.onAppState(state)
|
|
1721
|
+
);
|
|
1722
|
+
} catch (err) {
|
|
1723
|
+
this.diag("lifecycleSubscribe", err);
|
|
1724
|
+
}
|
|
1725
|
+
}
|
|
1726
|
+
|
|
1495
1727
|
/** REV-272: the launch-time moments, plus the foreground listener that
|
|
1496
1728
|
* `session_start` is built on.
|
|
1497
1729
|
*
|
|
@@ -1500,7 +1732,7 @@ export class RevnixClient {
|
|
|
1500
1732
|
* install too when this launch generated the anonymous id. All three are
|
|
1501
1733
|
* independent placements, so an app may have any subset configured and the
|
|
1502
1734
|
* others cost nothing. */
|
|
1503
|
-
private startImplicit(
|
|
1735
|
+
private startImplicit(): void {
|
|
1504
1736
|
// freshInstall is set inside loadCustomerId, which the constructor kicked
|
|
1505
1737
|
// off but did not await — so read it after that promise settles rather
|
|
1506
1738
|
// than now, when it is still false on a genuine first launch.
|
|
@@ -1510,25 +1742,6 @@ export class RevnixClient {
|
|
|
1510
1742
|
// are still reported — a launch is a launch whether or not a paywall
|
|
1511
1743
|
// showed — but an app that configured all three must not have three
|
|
1512
1744
|
// 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
1745
|
const sessionExtra = this.consumePreviousSessionMs().catch(
|
|
1533
1746
|
(err: unknown) => {
|
|
1534
1747
|
this.diag("session", err);
|
|
@@ -1568,6 +1781,8 @@ export class RevnixClient {
|
|
|
1568
1781
|
|
|
1569
1782
|
private onAppState(state: RevnixAppState): void {
|
|
1570
1783
|
if (this.disposed) return;
|
|
1784
|
+
if (state === "background") this.startEventFlush();
|
|
1785
|
+
if (!this.implicitEnabled) return;
|
|
1571
1786
|
if (state === "background") {
|
|
1572
1787
|
// First report wins: a platform that repeats "background" (a paused
|
|
1573
1788
|
// Activity re-reporting) must not keep resetting the clock forward.
|
|
@@ -1619,6 +1834,8 @@ export class RevnixClient {
|
|
|
1619
1834
|
this.lifecycleUnsubscribe = null;
|
|
1620
1835
|
this.stopStoreObserver?.();
|
|
1621
1836
|
this.stopStoreObserver = null;
|
|
1837
|
+
if (this.eventTimer !== null) clearTimeout(this.eventTimer);
|
|
1838
|
+
this.eventTimer = null;
|
|
1622
1839
|
}
|
|
1623
1840
|
|
|
1624
1841
|
/** A block paywall reporting a paint string it could not read. Routed to the
|
|
@@ -1867,25 +2084,13 @@ export class RevnixClient {
|
|
|
1867
2084
|
// common no-experiment offline paywall keeps working across identity
|
|
1868
2085
|
// changes.
|
|
1869
2086
|
|
|
1870
|
-
private async readPlacementMap(): Promise<
|
|
1871
|
-
Record<
|
|
1872
|
-
string,
|
|
1873
|
-
{ data: PlacementResolution; fetchedAt: number; customerId?: string }
|
|
1874
|
-
>
|
|
1875
|
-
> {
|
|
2087
|
+
private async readPlacementMap(): Promise<Record<string, PlacementCacheEntry>> {
|
|
1876
2088
|
try {
|
|
1877
2089
|
const raw = await this.storage.getItem(PLACEMENTS_CACHE_KEY);
|
|
1878
2090
|
if (!raw) return {};
|
|
1879
2091
|
const parsed = JSON.parse(raw);
|
|
1880
2092
|
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
|
-
>)
|
|
2093
|
+
? (parsed as Record<string, PlacementCacheEntry>)
|
|
1889
2094
|
: {};
|
|
1890
2095
|
} catch {
|
|
1891
2096
|
return {};
|
|
@@ -1895,7 +2100,7 @@ export class RevnixClient {
|
|
|
1895
2100
|
private async readPlacementCache(
|
|
1896
2101
|
placementKey: string,
|
|
1897
2102
|
customerId: string
|
|
1898
|
-
): Promise<
|
|
2103
|
+
): Promise<PlacementCacheEntry | null> {
|
|
1899
2104
|
const map = await this.readPlacementMap();
|
|
1900
2105
|
const entry = map[placementKey];
|
|
1901
2106
|
if (!entry) return null;
|
|
@@ -1913,40 +2118,74 @@ export class RevnixClient {
|
|
|
1913
2118
|
return entry;
|
|
1914
2119
|
}
|
|
1915
2120
|
|
|
1916
|
-
private
|
|
2121
|
+
private writePlacementCache(
|
|
1917
2122
|
placementKey: string,
|
|
1918
2123
|
data: PlacementResolution,
|
|
1919
|
-
customerId: string
|
|
2124
|
+
customerId: string,
|
|
2125
|
+
fetchedAt: number
|
|
2126
|
+
): Promise<void> {
|
|
2127
|
+
return this.mutatePlacements((map) => {
|
|
2128
|
+
map[placementKey] = { data, fetchedAt, customerId };
|
|
2129
|
+
});
|
|
2130
|
+
}
|
|
2131
|
+
|
|
2132
|
+
private deletePlacementCache(placementKey: string): Promise<void> {
|
|
2133
|
+
return this.mutatePlacements((map) => {
|
|
2134
|
+
delete map[placementKey];
|
|
2135
|
+
});
|
|
2136
|
+
}
|
|
2137
|
+
|
|
2138
|
+
private mutatePlacements(
|
|
2139
|
+
mutate: (map: Record<string, PlacementCacheEntry>) => void
|
|
1920
2140
|
): Promise<void> {
|
|
2141
|
+
return this.withStorageLock(async () => {
|
|
2142
|
+
try {
|
|
2143
|
+
const map = await this.readPlacementMap();
|
|
2144
|
+
mutate(map);
|
|
2145
|
+
await this.storage.setItem(PLACEMENTS_CACHE_KEY, JSON.stringify(map));
|
|
2146
|
+
} catch (err) {
|
|
2147
|
+
this.diag("storageWrite", err);
|
|
2148
|
+
}
|
|
2149
|
+
});
|
|
2150
|
+
}
|
|
2151
|
+
|
|
2152
|
+
private async placementsInvalidatedAt(): Promise<number> {
|
|
1921
2153
|
try {
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
|
|
2154
|
+
return Number(await this.storage.getItem(PLACEMENTS_INVALIDATED_AT_KEY)) || 0;
|
|
2155
|
+
} catch {
|
|
2156
|
+
return 0;
|
|
2157
|
+
}
|
|
2158
|
+
}
|
|
2159
|
+
|
|
2160
|
+
private async invalidatePlacements(): Promise<void> {
|
|
2161
|
+
try {
|
|
2162
|
+
await this.storage.setItem(PLACEMENTS_INVALIDATED_AT_KEY, String(Date.now()));
|
|
1925
2163
|
} catch (err) {
|
|
1926
|
-
// Best-effort, but reported (REV-200 M26).
|
|
1927
2164
|
this.diag("storageWrite", err);
|
|
1928
2165
|
}
|
|
1929
2166
|
}
|
|
1930
2167
|
|
|
1931
2168
|
// ——— C7: persisted purchase retry queue ———
|
|
1932
2169
|
|
|
1933
|
-
|
|
1934
|
-
|
|
1935
|
-
|
|
1936
|
-
|
|
1937
|
-
|
|
2170
|
+
private storageLock: Promise<void> = Promise.resolve();
|
|
2171
|
+
|
|
2172
|
+
private withStorageLock<T>(task: () => Promise<T>): Promise<T> {
|
|
2173
|
+
const run = this.storageLock.then(task);
|
|
2174
|
+
this.storageLock = run.then(
|
|
2175
|
+
() => {},
|
|
2176
|
+
() => {}
|
|
2177
|
+
);
|
|
2178
|
+
return run;
|
|
2179
|
+
}
|
|
1938
2180
|
|
|
1939
2181
|
private mutateQueue(
|
|
1940
2182
|
mutate: (queue: PendingPurchase[]) => PendingPurchase[]
|
|
1941
2183
|
): Promise<void> {
|
|
1942
|
-
|
|
2184
|
+
return this.withStorageLock(async () => {
|
|
1943
2185
|
const queue = await this.readPurchaseQueue();
|
|
1944
2186
|
const next = mutate(queue);
|
|
1945
2187
|
if (next !== queue) await this.writePurchaseQueue(next);
|
|
1946
2188
|
});
|
|
1947
|
-
// The chain must survive a failed mutation or every later one deadlocks.
|
|
1948
|
-
this.queueLock = run.catch(() => {});
|
|
1949
|
-
return run;
|
|
1950
2189
|
}
|
|
1951
2190
|
|
|
1952
2191
|
private async readPurchaseQueue(): Promise<PendingPurchase[]> {
|
|
@@ -1974,26 +2213,29 @@ export class RevnixClient {
|
|
|
1974
2213
|
customerId: string,
|
|
1975
2214
|
input: RegisterPurchaseInput
|
|
1976
2215
|
): Promise<void> {
|
|
1977
|
-
|
|
1978
|
-
|
|
1979
|
-
queue.
|
|
1980
|
-
|
|
1981
|
-
|
|
1982
|
-
|
|
2216
|
+
return this.mutateQueue((queue) => {
|
|
2217
|
+
if (queue.some((q) => pendingKey(q.input) === pendingKey(input))) return queue;
|
|
2218
|
+
const kept = queue.slice(Math.max(0, queue.length - PURCHASE_QUEUE_CAP + 1));
|
|
2219
|
+
for (const dropped of queue.slice(0, queue.length - kept.length)) {
|
|
2220
|
+
this.diag("registerPurchase", new Error(`queue full, dropped ${pendingKey(dropped.input)}`));
|
|
2221
|
+
}
|
|
2222
|
+
return [...kept, { customerId, input, queuedAt: Date.now() }];
|
|
2223
|
+
});
|
|
1983
2224
|
}
|
|
1984
2225
|
|
|
1985
2226
|
private async request<T>(
|
|
1986
2227
|
method: "GET" | "POST",
|
|
1987
2228
|
path: string,
|
|
1988
2229
|
body?: unknown,
|
|
1989
|
-
extraHeaders?: Record<string, string
|
|
2230
|
+
extraHeaders?: Record<string, string>,
|
|
2231
|
+
timeoutMs = this.requestTimeoutMs
|
|
1990
2232
|
): Promise<T> {
|
|
1991
2233
|
// C7: abort the request after requestTimeoutMs so a slow or dead backend
|
|
1992
2234
|
// can't hang the app. AbortController is guarded for very old runtimes.
|
|
1993
2235
|
const controller =
|
|
1994
2236
|
typeof AbortController !== "undefined" ? new AbortController() : undefined;
|
|
1995
2237
|
const timer = controller
|
|
1996
|
-
? setTimeout(() => controller.abort(),
|
|
2238
|
+
? setTimeout(() => controller.abort(), timeoutMs)
|
|
1997
2239
|
: undefined;
|
|
1998
2240
|
|
|
1999
2241
|
// REV-200 (M26): piggyback the background-failure counter on this
|
|
@@ -2025,7 +2267,7 @@ export class RevnixClient {
|
|
|
2025
2267
|
(err instanceof Error && err.name === "AbortError")
|
|
2026
2268
|
) {
|
|
2027
2269
|
throw new RevnixTimeoutError(
|
|
2028
|
-
`Revnix request to ${path} timed out after ${
|
|
2270
|
+
`Revnix request to ${path} timed out after ${timeoutMs}ms`
|
|
2029
2271
|
);
|
|
2030
2272
|
}
|
|
2031
2273
|
throw new RevnixNetworkError(
|