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