react-native-revnix 1.4.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/LICENSE +21 -0
- package/README.md +766 -0
- package/dist/blocks/background.d.ts +101 -0
- package/dist/blocks/background.js +165 -0
- package/dist/blocks/color.d.ts +88 -0
- package/dist/blocks/color.js +329 -0
- package/dist/blocks/locale.d.ts +42 -0
- package/dist/blocks/locale.js +263 -0
- package/dist/blocks/selection.d.ts +49 -0
- package/dist/blocks/selection.js +109 -0
- package/dist/blocks/style.d.ts +93 -0
- package/dist/blocks/style.js +365 -0
- package/dist/blocks/tags.d.ts +12 -0
- package/dist/blocks/tags.js +121 -0
- package/dist/blocks/types.d.ts +301 -0
- package/dist/blocks/types.js +44 -0
- package/dist/client.d.ts +402 -0
- package/dist/client.js +1653 -0
- package/dist/device.d.ts +29 -0
- package/dist/device.js +137 -0
- package/dist/errors.d.ts +39 -0
- package/dist/errors.js +68 -0
- package/dist/esm/blocks/background.js +160 -0
- package/dist/esm/blocks/color.js +318 -0
- package/dist/esm/blocks/locale.js +255 -0
- package/dist/esm/blocks/selection.js +100 -0
- package/dist/esm/blocks/style.js +356 -0
- package/dist/esm/blocks/tags.js +117 -0
- package/dist/esm/blocks/types.js +40 -0
- package/dist/esm/client.js +1649 -0
- package/dist/esm/device.js +130 -0
- package/dist/esm/errors.js +59 -0
- package/dist/esm/iap.js +101 -0
- package/dist/esm/implicit.js +63 -0
- package/dist/esm/index.js +29 -0
- package/dist/esm/package.json +1 -0
- package/dist/esm/storage.js +78 -0
- package/dist/esm/types.js +4 -0
- package/dist/esm/ui/RevnixBlockPaywall.js +706 -0
- package/dist/esm/ui/RevnixPaywall.js +890 -0
- package/dist/esm/ui/index.js +6 -0
- package/dist/esm/version.js +4 -0
- package/dist/iap.d.ts +20 -0
- package/dist/iap.js +104 -0
- package/dist/implicit.d.ts +69 -0
- package/dist/implicit.js +67 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.js +50 -0
- package/dist/storage.d.ts +14 -0
- package/dist/storage.js +85 -0
- package/dist/types.d.ts +463 -0
- package/dist/types.js +5 -0
- package/dist/ui/RevnixBlockPaywall.d.ts +96 -0
- package/dist/ui/RevnixBlockPaywall.js +742 -0
- package/dist/ui/RevnixPaywall.d.ts +148 -0
- package/dist/ui/RevnixPaywall.js +927 -0
- package/dist/ui/index.d.ts +7 -0
- package/dist/ui/index.js +25 -0
- package/dist/version.d.ts +1 -0
- package/dist/version.js +7 -0
- package/package.json +79 -0
- package/src/blocks/background.ts +232 -0
- package/src/blocks/color.ts +352 -0
- package/src/blocks/locale.ts +271 -0
- package/src/blocks/paywall-background-wire.json +12 -0
- package/src/blocks/paywall-selection-wire.json +149 -0
- package/src/blocks/selection.ts +161 -0
- package/src/blocks/style.ts +411 -0
- package/src/blocks/tags.ts +125 -0
- package/src/blocks/types.ts +352 -0
- package/src/client.ts +1993 -0
- package/src/device.ts +146 -0
- package/src/errors.ts +69 -0
- package/src/iap.ts +137 -0
- package/src/implicit.ts +129 -0
- package/src/index.ts +82 -0
- package/src/storage.ts +92 -0
- package/src/types.ts +528 -0
- package/src/ui/RevnixBlockPaywall.tsx +1423 -0
- package/src/ui/RevnixPaywall.tsx +1542 -0
- package/src/ui/index.ts +43 -0
- package/src/version.ts +4 -0
- package/ui.d.ts +1 -0
- package/ui.js +5 -0
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// REV-268: the device attribute contract, SDK side. Every SDK sends the same
|
|
2
|
+
// twelve facts about the device on each placement resolve, in the
|
|
3
|
+
// X-Revnix-Device header, and the server stores them as reserved `device.*`
|
|
4
|
+
// customer attributes — the facts a targeting rule ("US only", "app version
|
|
5
|
+
// at least 3", "first open") evaluates against on the very request that
|
|
6
|
+
// serves the paywall.
|
|
7
|
+
//
|
|
8
|
+
// This SDK runs in a browser, in React Native, and under Node, so it cannot
|
|
9
|
+
// read most of the facts itself the way the native SDKs can: there is no
|
|
10
|
+
// StoreKit storefront in a browser and no `navigator` in Node. The split is
|
|
11
|
+
// - what the runtime can tell us safely (detectDeviceFacts): platform,
|
|
12
|
+
// locale, and — in a browser — the OS version from the user agent;
|
|
13
|
+
// - what the app must tell us (RevnixConfig.device): app version, model,
|
|
14
|
+
// storefront, currency, sandbox — a React Native app has
|
|
15
|
+
// react-native-device-info in hand and this SDK should not depend on it;
|
|
16
|
+
// - what the SDK owns outright: sdkVersion, installedAt, firstOpen.
|
|
17
|
+
// Anything unknown is simply omitted; the server treats every field as
|
|
18
|
+
// optional and normalizes what it gets (lib/deviceAttributes.ts in
|
|
19
|
+
// revnix-app), so nothing here needs to be exact — only honest.
|
|
20
|
+
/** The header the server reads. Keep in sync with
|
|
21
|
+
* convex/lib/deviceAttributes.ts DEVICE_HEADER. */
|
|
22
|
+
export const DEVICE_HEADER = "X-Revnix-Device";
|
|
23
|
+
/** Storage key for the first-launch timestamp. */
|
|
24
|
+
export const INSTALLED_AT_KEY = "revnix.installedAt";
|
|
25
|
+
/** The facts this runtime can report about itself without help. Browser
|
|
26
|
+
* only in practice — under Node and React Native `navigator` is absent or
|
|
27
|
+
* says nothing useful, and those callers pass `device` in configure(). */
|
|
28
|
+
export function detectDeviceFacts() {
|
|
29
|
+
const nav = globalThis.navigator;
|
|
30
|
+
const facts = {};
|
|
31
|
+
if (!nav)
|
|
32
|
+
return facts;
|
|
33
|
+
// A React Native runtime also exposes `navigator` (product: "ReactNative")
|
|
34
|
+
// but its language/userAgent are not the device's — leave those to the app.
|
|
35
|
+
if (nav.product === "ReactNative")
|
|
36
|
+
return facts;
|
|
37
|
+
if (typeof nav.userAgent === "string" && nav.userAgent.length > 0) {
|
|
38
|
+
facts.platform = "web";
|
|
39
|
+
const os = osVersionFromUserAgent(nav.userAgent);
|
|
40
|
+
if (os !== undefined)
|
|
41
|
+
facts.osVersion = os;
|
|
42
|
+
}
|
|
43
|
+
if (typeof nav.language === "string" && nav.language.length > 0) {
|
|
44
|
+
facts.locale = nav.language;
|
|
45
|
+
}
|
|
46
|
+
return facts;
|
|
47
|
+
}
|
|
48
|
+
/** Best-effort OS version from a browser UA string; undefined when the UA
|
|
49
|
+
* gives nothing recognizable. Only the major desktop/mobile families —
|
|
50
|
+
* this is a targeting hint, not a fingerprinting library. */
|
|
51
|
+
export function osVersionFromUserAgent(ua) {
|
|
52
|
+
const ios = /(?:iPhone|iPad|CPU) OS (\d+(?:_\d+)*)/.exec(ua)?.[1];
|
|
53
|
+
if (ios !== undefined)
|
|
54
|
+
return ios.replace(/_/g, ".");
|
|
55
|
+
const android = /Android (\d+(?:\.\d+)*)/.exec(ua)?.[1];
|
|
56
|
+
if (android !== undefined)
|
|
57
|
+
return android;
|
|
58
|
+
const mac = /Mac OS X (\d+(?:[._]\d+)*)/.exec(ua)?.[1];
|
|
59
|
+
if (mac !== undefined)
|
|
60
|
+
return mac.replace(/_/g, ".");
|
|
61
|
+
const windows = /Windows NT (\d+(?:\.\d+)*)/.exec(ua)?.[1];
|
|
62
|
+
if (windows !== undefined)
|
|
63
|
+
return windows;
|
|
64
|
+
return undefined;
|
|
65
|
+
}
|
|
66
|
+
/** Drop undefined/empty fields so the encoded header carries only what is
|
|
67
|
+
* known — the server would drop them anyway, but bytes on every resolve
|
|
68
|
+
* are bytes on every resolve. */
|
|
69
|
+
export function compactPayload(payload) {
|
|
70
|
+
const out = {};
|
|
71
|
+
for (const [key, value] of Object.entries(payload)) {
|
|
72
|
+
if (value === undefined || value === null || value === "")
|
|
73
|
+
continue;
|
|
74
|
+
out[key] = value;
|
|
75
|
+
}
|
|
76
|
+
return out;
|
|
77
|
+
}
|
|
78
|
+
/** base64url of the UTF-8 JSON payload. Hand-rolled rather than
|
|
79
|
+
* btoa/TextEncoder: neither is guaranteed on every React Native runtime
|
|
80
|
+
* this SDK supports, and a missing global here would fail every paywall
|
|
81
|
+
* resolve on that platform for the sake of one header. */
|
|
82
|
+
export function encodeDeviceHeader(payload) {
|
|
83
|
+
return base64url(utf8Bytes(JSON.stringify(payload)));
|
|
84
|
+
}
|
|
85
|
+
function utf8Bytes(text) {
|
|
86
|
+
const bytes = [];
|
|
87
|
+
for (let i = 0; i < text.length; i++) {
|
|
88
|
+
let code = text.charCodeAt(i);
|
|
89
|
+
// Surrogate pair → one code point.
|
|
90
|
+
if (code >= 0xd800 && code <= 0xdbff && i + 1 < text.length) {
|
|
91
|
+
const low = text.charCodeAt(i + 1);
|
|
92
|
+
if (low >= 0xdc00 && low <= 0xdfff) {
|
|
93
|
+
code = 0x10000 + ((code - 0xd800) << 10) + (low - 0xdc00);
|
|
94
|
+
i++;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
if (code < 0x80) {
|
|
98
|
+
bytes.push(code);
|
|
99
|
+
}
|
|
100
|
+
else if (code < 0x800) {
|
|
101
|
+
bytes.push(0xc0 | (code >> 6), 0x80 | (code & 0x3f));
|
|
102
|
+
}
|
|
103
|
+
else if (code < 0x10000) {
|
|
104
|
+
bytes.push(0xe0 | (code >> 12), 0x80 | ((code >> 6) & 0x3f), 0x80 | (code & 0x3f));
|
|
105
|
+
}
|
|
106
|
+
else {
|
|
107
|
+
bytes.push(0xf0 | (code >> 18), 0x80 | ((code >> 12) & 0x3f), 0x80 | ((code >> 6) & 0x3f), 0x80 | (code & 0x3f));
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return bytes;
|
|
111
|
+
}
|
|
112
|
+
const B64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
|
|
113
|
+
function base64url(bytes) {
|
|
114
|
+
let out = "";
|
|
115
|
+
for (let i = 0; i < bytes.length; i += 3) {
|
|
116
|
+
const a = bytes[i] ?? 0;
|
|
117
|
+
const b = i + 1 < bytes.length ? bytes[i + 1] : undefined;
|
|
118
|
+
const c = i + 2 < bytes.length ? bytes[i + 2] : undefined;
|
|
119
|
+
out += B64[a >> 2];
|
|
120
|
+
out += B64[((a & 0x03) << 4) | ((b ?? 0) >> 4)];
|
|
121
|
+
if (b === undefined)
|
|
122
|
+
break;
|
|
123
|
+
out += B64[((b & 0x0f) << 2) | ((c ?? 0) >> 6)];
|
|
124
|
+
if (c === undefined)
|
|
125
|
+
break;
|
|
126
|
+
out += B64[c & 0x3f];
|
|
127
|
+
}
|
|
128
|
+
// No padding: base64url as the server decodes it (it re-pads itself).
|
|
129
|
+
return out;
|
|
130
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
export class RevnixError extends Error {
|
|
2
|
+
constructor(status, message) {
|
|
3
|
+
super(message);
|
|
4
|
+
this.name = "RevnixError";
|
|
5
|
+
this.status = status;
|
|
6
|
+
}
|
|
7
|
+
/** Transient failures worth retrying (network, timeout, rate limit, 5xx).
|
|
8
|
+
* 4xx other than 429 are the caller's fault and won't fix themselves. */
|
|
9
|
+
get isRetryable() {
|
|
10
|
+
return this.status === 0 || this.status === 429 || this.status >= 500;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
/** POST /v1/purchases returned 409: the store token belongs to another
|
|
14
|
+
* customer and the app's transfer behavior is "block". Surface this to the
|
|
15
|
+
* user as "this subscription is linked to a different account". */
|
|
16
|
+
export class PurchaseBlockedError extends RevnixError {
|
|
17
|
+
constructor(message) {
|
|
18
|
+
super(409, message);
|
|
19
|
+
this.name = "PurchaseBlockedError";
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
/** C7: the request didn't complete within the configured timeout (default
|
|
23
|
+
* 10s). `fetch` has no default timeout on React Native / browsers, so without
|
|
24
|
+
* this a slow or dead backend would hang the app until the OS TCP timeout. */
|
|
25
|
+
export class RevnixTimeoutError extends RevnixError {
|
|
26
|
+
constructor(message = "Revnix request timed out") {
|
|
27
|
+
super(0, message);
|
|
28
|
+
this.name = "RevnixTimeoutError";
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
/** C7: the request never reached the server (offline, DNS/TLS failure, captive
|
|
32
|
+
* portal). Distinct from an HTTP error so callers can show an offline state
|
|
33
|
+
* instead of treating it as a bug. */
|
|
34
|
+
export class RevnixNetworkError extends RevnixError {
|
|
35
|
+
constructor(message = "Revnix request failed to reach the server") {
|
|
36
|
+
super(0, message);
|
|
37
|
+
this.name = "RevnixNetworkError";
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
/** L4: the API returned 401/403 — the key is missing, revoked, or the wrong
|
|
41
|
+
* kind (e.g. a secret key where a public key is required) — or the SDK
|
|
42
|
+
* refused locally before the network for the same reason (identify()/alias()
|
|
43
|
+
* on a publishable key). Retrying is pointless; the fix is reconfiguration,
|
|
44
|
+
* so surface this to the developer. */
|
|
45
|
+
export class RevnixAuthError extends RevnixError {
|
|
46
|
+
constructor(status, message) {
|
|
47
|
+
super(status, message);
|
|
48
|
+
this.name = "RevnixAuthError";
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
/** C7: the API returned 429 (rate limited). `retryAfterMs` mirrors the
|
|
52
|
+
* server's Retry-After header when present — back off at least that long. */
|
|
53
|
+
export class RevnixRateLimitError extends RevnixError {
|
|
54
|
+
constructor(message, retryAfterMs) {
|
|
55
|
+
super(429, message);
|
|
56
|
+
this.name = "RevnixRateLimitError";
|
|
57
|
+
this.retryAfterMs = retryAfterMs;
|
|
58
|
+
}
|
|
59
|
+
}
|
package/dist/esm/iap.js
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// A JWS is three dot-separated base64url segments. Older react-native-iap
|
|
2
|
+
// versions put other receipt shapes in purchaseToken on iOS — shape-check so
|
|
3
|
+
// only a real JWS is sent as proof (the server rejects a malformed one).
|
|
4
|
+
const JWS_SHAPE = /^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/;
|
|
5
|
+
function jwsFromIap(purchase) {
|
|
6
|
+
for (const candidate of [purchase.jwsRepresentation, purchase.purchaseToken]) {
|
|
7
|
+
if (candidate && JWS_SHAPE.test(candidate))
|
|
8
|
+
return candidate;
|
|
9
|
+
}
|
|
10
|
+
return undefined;
|
|
11
|
+
}
|
|
12
|
+
// REV-200 (CTO-R2 M25): base64url → string without atob (not available on
|
|
13
|
+
// every React Native runtime). The ids read from the payload are ASCII;
|
|
14
|
+
// multi-byte content is never consulted.
|
|
15
|
+
const B64_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
|
16
|
+
function base64UrlDecode(segment) {
|
|
17
|
+
const b64 = segment.replace(/-/g, "+").replace(/_/g, "/");
|
|
18
|
+
let out = "";
|
|
19
|
+
let buffer = 0;
|
|
20
|
+
let bits = 0;
|
|
21
|
+
for (const ch of b64) {
|
|
22
|
+
const idx = B64_ALPHABET.indexOf(ch);
|
|
23
|
+
if (idx === -1)
|
|
24
|
+
continue; // padding / stray characters
|
|
25
|
+
buffer = (buffer << 6) | idx;
|
|
26
|
+
bits += 6;
|
|
27
|
+
if (bits >= 8) {
|
|
28
|
+
bits -= 8;
|
|
29
|
+
out += String.fromCharCode((buffer >> bits) & 0xff);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return out;
|
|
33
|
+
}
|
|
34
|
+
// The JWS payload's own ids — read WITHOUT verifying the signature, which is
|
|
35
|
+
// fine here: the server verifies the chain and only trusts the proof when the
|
|
36
|
+
// verified payload's ids match what we send. Reading them locally is exactly
|
|
37
|
+
// how we make them match.
|
|
38
|
+
function jwsPayload(jws) {
|
|
39
|
+
try {
|
|
40
|
+
const segment = jws.split(".")[1];
|
|
41
|
+
if (!segment)
|
|
42
|
+
return undefined;
|
|
43
|
+
const parsed = JSON.parse(base64UrlDecode(segment));
|
|
44
|
+
return typeof parsed === "object" && parsed !== null
|
|
45
|
+
? parsed
|
|
46
|
+
: undefined;
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return undefined;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
export function purchaseInputFromIap(purchase, source, extras) {
|
|
53
|
+
if (source === "apple") {
|
|
54
|
+
const transactionId = purchase.transactionId;
|
|
55
|
+
if (!transactionId) {
|
|
56
|
+
throw new Error("Revnix: Apple purchase is missing transactionId");
|
|
57
|
+
}
|
|
58
|
+
const jws = jwsFromIap(purchase);
|
|
59
|
+
const proof = jws ? jwsPayload(jws) : undefined;
|
|
60
|
+
return {
|
|
61
|
+
source,
|
|
62
|
+
// REV-200 (M25): token precedence. The old fallback (original id
|
|
63
|
+
// missing → the renewal's own transactionId) minted a purchaseKey the
|
|
64
|
+
// store webhook would never produce — a second purchase.completed and
|
|
65
|
+
// double-counted revenue — AND failed the server's proof check, which
|
|
66
|
+
// requires the verified JWS's originalTransactionId to EQUAL this
|
|
67
|
+
// token, silently downgrading a proof-carrying purchase to
|
|
68
|
+
// provisional. The JWS payload names the true original id, so when a
|
|
69
|
+
// JWS is present it wins; the IAP library's field is next; the bare
|
|
70
|
+
// transactionId remains only for the proof-less first purchase, where
|
|
71
|
+
// original === transaction anyway.
|
|
72
|
+
token: proof?.originalTransactionId ??
|
|
73
|
+
purchase.originalTransactionIdentifierIOS ??
|
|
74
|
+
transactionId,
|
|
75
|
+
productId: purchase.productId,
|
|
76
|
+
transactionId,
|
|
77
|
+
occurredAt: purchase.transactionDate ?? undefined,
|
|
78
|
+
expiresAt: extras?.expiresAt,
|
|
79
|
+
rawPayload: extras?.rawPayload,
|
|
80
|
+
// Without this the purchase lands PROVISIONAL (see
|
|
81
|
+
// RegisterPurchaseInput.signedTransactionInfo).
|
|
82
|
+
signedTransactionInfo: jws,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
const token = purchase.purchaseToken;
|
|
86
|
+
if (!token) {
|
|
87
|
+
throw new Error("Revnix: Google purchase is missing purchaseToken");
|
|
88
|
+
}
|
|
89
|
+
return {
|
|
90
|
+
source,
|
|
91
|
+
token,
|
|
92
|
+
productId: purchase.productId,
|
|
93
|
+
// The Google webhook adapter (convex/lib/google.ts) reports the
|
|
94
|
+
// purchaseToken as the transactionId — send the same, never the Play
|
|
95
|
+
// orderId, or client-register and webhook won't share a purchaseKey.
|
|
96
|
+
transactionId: token,
|
|
97
|
+
occurredAt: purchase.transactionDate ?? undefined,
|
|
98
|
+
expiresAt: extras?.expiresAt,
|
|
99
|
+
rawPayload: extras?.rawPayload,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// REV-272: implicit placements — the six moments the SDK reports on its own.
|
|
2
|
+
//
|
|
3
|
+
// A placement is normally a location the HOST resolves by name, so every new
|
|
4
|
+
// place a paywall could appear costs a code change and a release. Six moments
|
|
5
|
+
// are the same in every app and visible to the SDK without the host saying
|
|
6
|
+
// anything, so an operator can attach a paywall to them from the dashboard
|
|
7
|
+
// alone. This module owns the vocabulary and the platform-lifecycle seam;
|
|
8
|
+
// client.ts owns the firing.
|
|
9
|
+
//
|
|
10
|
+
// Mirrors convex/lib/implicitPlacements.ts in revnix-app — the backend is the
|
|
11
|
+
// source of truth for the key spellings, and a mismatch here is silent (the
|
|
12
|
+
// server 400s a key it does not know, and the moment is simply never
|
|
13
|
+
// configured), so the two lists are checked against each other in the tests.
|
|
14
|
+
/** The six reserved keys. Lowercase throughout: a placement key must match
|
|
15
|
+
* `^[a-z0-9][a-z0-9._-]{0,63}$` server-side, which is why this is
|
|
16
|
+
* `deeplink_open` and not Superwall's `deepLink_open`. */
|
|
17
|
+
export const IMPLICIT_PLACEMENTS = [
|
|
18
|
+
"app_install",
|
|
19
|
+
"app_launch",
|
|
20
|
+
"session_start",
|
|
21
|
+
"deeplink_open",
|
|
22
|
+
"paywall_decline",
|
|
23
|
+
"transaction_abandon",
|
|
24
|
+
];
|
|
25
|
+
/** The one moment the SDK cannot see unaided: the URL that opened the app is
|
|
26
|
+
* delivered to the host's own entry point, and an SDK trying to intercept it
|
|
27
|
+
* would be fighting the host's router. Call `handleDeepLink(url)`. */
|
|
28
|
+
export const HOST_CALL_PLACEMENT = "deeplink_open";
|
|
29
|
+
/** The placementKey `handleDeepLink` reports for a dashboard QR/link preview
|
|
30
|
+
* (`<scheme>://revnix-preview?revnix_preview=<token>`) — not one of the six
|
|
31
|
+
* above, and never sent to /v1/placements/triggered. */
|
|
32
|
+
export const PREVIEW_PLACEMENT_KEY = "revnix_preview";
|
|
33
|
+
/** How long the app must have been backgrounded for the return to count as a
|
|
34
|
+
* new session rather than an app switch. Matches Superwall's own definition
|
|
35
|
+
* so a team moving over gets the same numbers. */
|
|
36
|
+
export const DEFAULT_SESSION_TIMEOUT_MS = 30 * 60000;
|
|
37
|
+
/** How long after a FAILED /v1/config read the SDK waits before asking again.
|
|
38
|
+
* See RevnixClient.implicitConfigRetryAt. */
|
|
39
|
+
export const IMPLICIT_CONFIG_RETRY_MS = 60000;
|
|
40
|
+
/** Foreground detection in a browser tab. `visibilitychange` rather than
|
|
41
|
+
* `focus`: switching windows fires focus without the page ever having been
|
|
42
|
+
* hidden, which would mint sessions for someone alt-tabbing. Returns null
|
|
43
|
+
* where there is no document (React Native, Node, SSR) — the host passes its
|
|
44
|
+
* own adapter there. */
|
|
45
|
+
export function browserLifecycle() {
|
|
46
|
+
const doc = globalThis.document;
|
|
47
|
+
if (!doc ||
|
|
48
|
+
typeof doc.addEventListener !== "function" ||
|
|
49
|
+
typeof doc.removeEventListener !== "function") {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
const add = doc.addEventListener.bind(doc);
|
|
53
|
+
const remove = doc.removeEventListener.bind(doc);
|
|
54
|
+
return {
|
|
55
|
+
onStateChange(handler) {
|
|
56
|
+
const listener = () => {
|
|
57
|
+
handler(doc.visibilityState === "visible" ? "foreground" : "background");
|
|
58
|
+
};
|
|
59
|
+
add("visibilitychange", listener);
|
|
60
|
+
return () => remove("visibilitychange", listener);
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { RevnixClient } from "./client.js";
|
|
2
|
+
import { setLocale } from "./blocks/locale.js";
|
|
3
|
+
export { RevnixClient } from "./client.js";
|
|
4
|
+
export { PurchaseBlockedError, RevnixAuthError, RevnixError, RevnixNetworkError, RevnixRateLimitError, RevnixTimeoutError, } from "./errors.js";
|
|
5
|
+
export { purchaseInputFromIap } from "./iap.js";
|
|
6
|
+
export { memoryStorage, generateAnonymousId } from "./storage.js";
|
|
7
|
+
export { SDK_VERSION } from "./version.js";
|
|
8
|
+
export { DEVICE_HEADER, encodeDeviceHeader } from "./device.js";
|
|
9
|
+
export { browserLifecycle, DEFAULT_SESSION_TIMEOUT_MS, HOST_CALL_PLACEMENT, IMPLICIT_PLACEMENTS, PREVIEW_PLACEMENT_KEY, } from "./implicit.js";
|
|
10
|
+
let instance = null;
|
|
11
|
+
/** Singleton facade — configure once at app start, then import `Revnix`
|
|
12
|
+
* anywhere. Prefer constructing RevnixClient directly in tests. */
|
|
13
|
+
export const Revnix = {
|
|
14
|
+
configure(config) {
|
|
15
|
+
// REV-204 (L10): dev fast-refresh re-runs configure(); without teardown
|
|
16
|
+
// the old client kept its retry/prefetch background work running
|
|
17
|
+
// alongside the new one.
|
|
18
|
+
instance?.dispose();
|
|
19
|
+
instance = new RevnixClient(config);
|
|
20
|
+
return instance;
|
|
21
|
+
},
|
|
22
|
+
setLocale,
|
|
23
|
+
get client() {
|
|
24
|
+
if (!instance) {
|
|
25
|
+
throw new Error("Revnix: call Revnix.configure(...) before use");
|
|
26
|
+
}
|
|
27
|
+
return instance;
|
|
28
|
+
},
|
|
29
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"type":"module"}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/** Default storage: survives only the current JS runtime. Real apps must
|
|
2
|
+
* pass a persistent adapter (MMKV, AsyncStorage, localStorage) or every
|
|
3
|
+
* launch becomes a new anonymous customer. */
|
|
4
|
+
export function memoryStorage() {
|
|
5
|
+
const map = new Map();
|
|
6
|
+
return {
|
|
7
|
+
getItem: (key) => map.get(key) ?? null,
|
|
8
|
+
setItem: (key, value) => {
|
|
9
|
+
map.set(key, value);
|
|
10
|
+
},
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
// CTO-SCALE-REVIEW M5: what actually backs the client when `storage` is
|
|
14
|
+
// omitted. Memory-only used to be the silent default — every launch became a
|
|
15
|
+
// new anonymous customer + a new install report (analytics inflated by the
|
|
16
|
+
// DAU multiplier, purchases orphaned on the previous launch's id). Now:
|
|
17
|
+
// 1. explicit adapter → use it (unchanged);
|
|
18
|
+
// 2. web (localStorage usable) → use localStorage automatically;
|
|
19
|
+
// 3. truly nothing (React Native without an adapter) → memory, but WARN
|
|
20
|
+
// loudly, and the client skips install reporting (see client.ts) so a
|
|
21
|
+
// forgotten config line can't fabricate fleet-wide installs.
|
|
22
|
+
export function resolveStorage(configured) {
|
|
23
|
+
if (configured)
|
|
24
|
+
return { storage: configured, persistent: true };
|
|
25
|
+
const ls = globalThis.localStorage;
|
|
26
|
+
if (ls) {
|
|
27
|
+
try {
|
|
28
|
+
const probe = "revnix.__probe";
|
|
29
|
+
ls.setItem(probe, "1");
|
|
30
|
+
ls.removeItem(probe);
|
|
31
|
+
return {
|
|
32
|
+
storage: {
|
|
33
|
+
getItem: (key) => ls.getItem(key),
|
|
34
|
+
setItem: (key, value) => {
|
|
35
|
+
ls.setItem(key, value);
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
persistent: true,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
// Private mode / quota quirks — fall through to memory.
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
console.warn("[revnix] No `storage` adapter configured and no localStorage available; " +
|
|
46
|
+
"the customer id lives in memory only. Every launch will look like a NEW " +
|
|
47
|
+
"anonymous customer: prior purchases won't be attached and analytics " +
|
|
48
|
+
"would be inflated, so install reporting and experiment enrollment are " +
|
|
49
|
+
"disabled. Pass a persistent adapter (AsyncStorage/MMKV on React " +
|
|
50
|
+
"Native) to fix this.");
|
|
51
|
+
return { storage: memoryStorage(), persistent: false };
|
|
52
|
+
}
|
|
53
|
+
function randomHex(byteLength) {
|
|
54
|
+
const bytes = new Uint8Array(byteLength);
|
|
55
|
+
const cryptoObj = globalThis.crypto;
|
|
56
|
+
// L2: no Math.random fallback — a guessable customer id is readable by
|
|
57
|
+
// anyone holding the public key, so failing loudly beats failing silently.
|
|
58
|
+
// Every supported runtime (browsers, RN ≥0.72 via Hermes, Node ≥19) exposes
|
|
59
|
+
// getRandomValues; older Node needs `globalThis.crypto = require("crypto").webcrypto`.
|
|
60
|
+
if (!cryptoObj?.getRandomValues) {
|
|
61
|
+
throw new Error("[revnix] crypto.getRandomValues is unavailable in this runtime; " +
|
|
62
|
+
"cannot generate a secure customer id. Polyfill globalThis.crypto " +
|
|
63
|
+
"or pass an explicit `customerId` in configure().");
|
|
64
|
+
}
|
|
65
|
+
cryptoObj.getRandomValues(bytes);
|
|
66
|
+
return [...bytes].map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
67
|
+
}
|
|
68
|
+
export function generateAnonymousId() {
|
|
69
|
+
return `rvx_anon_${randomHex(16)}`;
|
|
70
|
+
}
|
|
71
|
+
/** One id per paywall display (REV-094) — the backend dedupes retries on it
|
|
72
|
+
* while genuine repeat views (fresh ids) count separately. */
|
|
73
|
+
export function generateViewId() {
|
|
74
|
+
return `rvx_view_${randomHex(16)}`;
|
|
75
|
+
}
|
|
76
|
+
export function generateEventId() {
|
|
77
|
+
return `rvx_evt_${randomHex(16)}`;
|
|
78
|
+
}
|