@rovenue/web-sdk 0.0.0-stage → 0.16.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/index.js ADDED
@@ -0,0 +1,368 @@
1
+ import { __publicField } from './chunk-V6TY7KAL.js';
2
+ import { createId } from '@paralleldrive/cuid2';
3
+
4
+ // src/client.ts
5
+ var PLATFORM = "web";
6
+ var RovenueApiError = class extends Error {
7
+ constructor(status, code, message) {
8
+ super(message);
9
+ __publicField(this, "status", status);
10
+ __publicField(this, "code", code);
11
+ this.name = "RovenueApiError";
12
+ }
13
+ };
14
+ function createHttpClient(opts) {
15
+ const { apiUrl, publicKey, identity } = opts;
16
+ const doFetch = opts.fetchImpl ?? globalThis.fetch;
17
+ const baseUrl = `${apiUrl.replace(/\/+$/, "")}/v1/web/${publicKey}`;
18
+ function headers(extra) {
19
+ return {
20
+ Authorization: `Bearer ${publicKey}`,
21
+ // ALWAYS the rovenueId. Never the app scope from identify() — that one
22
+ // is client-local, and putting it here routes the request to an
23
+ // orphaned subscriber.
24
+ "x-rovenue-app-user-id": identity.rovenueId(),
25
+ // BOTH headers, as the Rust core sends. /v1/placements, /v1/config and
26
+ // /v1/experiments read the subscriber from `x-rovenue-user-id`, not
27
+ // from the app-user one. Sending only the latter left every web request
28
+ // anonymous: audience-targeted placement rows could never match (only
29
+ // an `audienceId: null` row can with no attributes), and a null
30
+ // subscriber forces the project holdout to 0 — so web traffic was
31
+ // silently excluded from the holdout and polluted the baseline.
32
+ "x-rovenue-user-id": identity.rovenueId(),
33
+ "x-rovenue-platform": PLATFORM,
34
+ "Content-Type": "application/json",
35
+ ...extra
36
+ };
37
+ }
38
+ async function unwrap(res) {
39
+ if (!res.ok) {
40
+ let code = "HTTP_ERROR";
41
+ let message = `Request failed with ${res.status}`;
42
+ try {
43
+ const body2 = await res.json();
44
+ if (body2?.error) {
45
+ code = body2.error.code;
46
+ message = body2.error.message;
47
+ }
48
+ } catch {
49
+ }
50
+ throw new RovenueApiError(res.status, code, message);
51
+ }
52
+ if (res.status === 204 || res.status === 202) return void 0;
53
+ const text = await res.text();
54
+ if (text === "") return void 0;
55
+ const body = JSON.parse(text);
56
+ return body.data;
57
+ }
58
+ return {
59
+ baseUrl,
60
+ async get(path) {
61
+ const res = await doFetch(`${baseUrl}${path}`, {
62
+ method: "GET",
63
+ headers: headers()
64
+ });
65
+ return unwrap(res);
66
+ },
67
+ async post(path, body, init) {
68
+ const res = await doFetch(`${baseUrl}${path}`, {
69
+ method: "POST",
70
+ headers: headers(
71
+ init?.idempotencyKey ? { "Idempotency-Key": init.idempotencyKey } : void 0
72
+ ),
73
+ body: JSON.stringify(body),
74
+ // Survives the page being unloaded, which is the whole reason the
75
+ // event queue can flush on pagehide at all. sendBeacon cannot be
76
+ // used here: it sets no headers, and this API authenticates from
77
+ // Authorization.
78
+ ...init?.keepalive ? { keepalive: true } : {}
79
+ });
80
+ return unwrap(res);
81
+ }
82
+ };
83
+ }
84
+ var ROVENUE_ID_KEY = "rovenue.rovenueId";
85
+ var EMAIL_SHAPED = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
86
+ function createIdentity(storage, warn = (m) => console.warn(m)) {
87
+ let cached = storage.get(ROVENUE_ID_KEY);
88
+ if (!cached) {
89
+ cached = createId();
90
+ storage.set(ROVENUE_ID_KEY, cached);
91
+ }
92
+ let scope = null;
93
+ return {
94
+ rovenueId: () => cached,
95
+ appUserScope: () => scope,
96
+ identify(appUserId) {
97
+ if (EMAIL_SHAPED.test(appUserId)) {
98
+ warn(
99
+ "[rovenue] identify() was given an email address. App user ids are guessable identifiers to anyone holding your public key, which is visible in the browser. Use an opaque id your backend maps to the user instead."
100
+ );
101
+ }
102
+ scope = appUserId;
103
+ },
104
+ logOut() {
105
+ scope = null;
106
+ cached = createId();
107
+ storage.set(ROVENUE_ID_KEY, cached);
108
+ }
109
+ };
110
+ }
111
+
112
+ // src/storage.ts
113
+ function createMemoryStorage() {
114
+ const map = /* @__PURE__ */ new Map();
115
+ return {
116
+ get: (key) => map.get(key) ?? null,
117
+ set: (key, value) => {
118
+ map.set(key, value);
119
+ },
120
+ remove: (key) => {
121
+ map.delete(key);
122
+ }
123
+ };
124
+ }
125
+ var PROBE_KEY = "rovenue.__probe";
126
+ function createStorage() {
127
+ const memory = createMemoryStorage();
128
+ let backing = null;
129
+ try {
130
+ const candidate = globalThis.localStorage;
131
+ if (candidate) {
132
+ candidate.setItem(PROBE_KEY, "1");
133
+ candidate.removeItem(PROBE_KEY);
134
+ backing = candidate;
135
+ }
136
+ } catch {
137
+ backing = null;
138
+ }
139
+ if (!backing) return memory;
140
+ const store = backing;
141
+ return {
142
+ get(key) {
143
+ try {
144
+ return store.getItem(key) ?? memory.get(key);
145
+ } catch {
146
+ return memory.get(key);
147
+ }
148
+ },
149
+ set(key, value) {
150
+ try {
151
+ store.setItem(key, value);
152
+ } catch {
153
+ try {
154
+ store.removeItem(key);
155
+ } catch {
156
+ }
157
+ memory.set(key, value);
158
+ }
159
+ },
160
+ remove(key) {
161
+ try {
162
+ store.removeItem(key);
163
+ } catch {
164
+ }
165
+ memory.remove(key);
166
+ }
167
+ };
168
+ }
169
+
170
+ // src/cache.ts
171
+ var CACHE_KEY = "rovenue.entitlements";
172
+ function createEntitlementCache(storage, now = Date.now) {
173
+ return {
174
+ read() {
175
+ const raw = storage.get(CACHE_KEY);
176
+ if (!raw) return null;
177
+ try {
178
+ const parsed = JSON.parse(raw);
179
+ if (!parsed || typeof parsed !== "object" || typeof parsed.fetchedAt !== "number" || typeof parsed.entitlements !== "object" || parsed.entitlements === null) {
180
+ return null;
181
+ }
182
+ return parsed;
183
+ } catch {
184
+ return null;
185
+ }
186
+ },
187
+ write(entitlements) {
188
+ storage.set(
189
+ CACHE_KEY,
190
+ JSON.stringify({ entitlements, fetchedAt: now() })
191
+ );
192
+ },
193
+ clear() {
194
+ storage.remove(CACHE_KEY);
195
+ }
196
+ };
197
+ }
198
+ var QUEUE_KEY = "rovenue.events";
199
+ var EVENT_WIRE_VERSION = 1;
200
+ function createEventQueue(opts) {
201
+ const { storage, post } = opts;
202
+ const now = opts.now ?? (() => /* @__PURE__ */ new Date());
203
+ function read() {
204
+ const raw = storage.get(QUEUE_KEY);
205
+ if (!raw) return [];
206
+ try {
207
+ const parsed = JSON.parse(raw);
208
+ return Array.isArray(parsed) ? parsed : [];
209
+ } catch {
210
+ return [];
211
+ }
212
+ }
213
+ function write(events) {
214
+ const bounded = events.slice(-200);
215
+ storage.set(QUEUE_KEY, JSON.stringify(bounded));
216
+ }
217
+ let flushing = false;
218
+ let detach = null;
219
+ const queue = {
220
+ track(input) {
221
+ const event = {
222
+ ...input,
223
+ version: EVENT_WIRE_VERSION,
224
+ // Minted once, here. Reused verbatim on every retry so the server's
225
+ // dedup can recognise the replay.
226
+ eventId: createId(),
227
+ occurredAt: input.occurredAt ?? now().toISOString()
228
+ };
229
+ write([...read(), event]);
230
+ },
231
+ async flush() {
232
+ if (flushing) return;
233
+ flushing = true;
234
+ try {
235
+ const pending = read();
236
+ if (pending.length === 0) return;
237
+ const retained = [];
238
+ for (const event of pending) {
239
+ let accepted = false;
240
+ try {
241
+ accepted = await post(event);
242
+ } catch {
243
+ accepted = false;
244
+ }
245
+ if (!accepted) retained.push(event);
246
+ }
247
+ const seen = new Set(pending.map((e) => e.eventId));
248
+ const arrivedDuringFlush = read().filter((e) => !seen.has(e.eventId));
249
+ write([...retained, ...arrivedDuringFlush]);
250
+ } finally {
251
+ flushing = false;
252
+ }
253
+ },
254
+ clear() {
255
+ write([]);
256
+ },
257
+ start() {
258
+ const doc = globalThis.document;
259
+ const win = globalThis.window;
260
+ if (!doc && !win) return;
261
+ const onHide = () => {
262
+ if (!doc || doc.visibilityState === "hidden") void queue.flush();
263
+ };
264
+ const onPageHide = () => {
265
+ void queue.flush();
266
+ };
267
+ doc?.addEventListener("visibilitychange", onHide);
268
+ win?.addEventListener("pagehide", onPageHide);
269
+ detach = () => {
270
+ doc?.removeEventListener("visibilitychange", onHide);
271
+ win?.removeEventListener("pagehide", onPageHide);
272
+ };
273
+ },
274
+ stop() {
275
+ detach?.();
276
+ detach = null;
277
+ }
278
+ };
279
+ return queue;
280
+ }
281
+
282
+ // src/index.ts
283
+ function configure(options) {
284
+ if (!options.apiKey) throw new Error("[rovenue] configure() needs an apiKey");
285
+ if (!options.apiUrl) throw new Error("[rovenue] configure() needs an apiUrl");
286
+ const storage = options.storage ?? createStorage();
287
+ const identity = createIdentity(storage);
288
+ const cache = createEntitlementCache(storage);
289
+ const http = createHttpClient({
290
+ apiUrl: options.apiUrl,
291
+ publicKey: options.apiKey,
292
+ identity,
293
+ fetchImpl: options.fetchImpl
294
+ });
295
+ const events = createEventQueue({
296
+ storage,
297
+ post: async (event) => {
298
+ try {
299
+ await http.post("/events", event, { keepalive: true });
300
+ return true;
301
+ } catch (err) {
302
+ if (!(err instanceof RovenueApiError)) return false;
303
+ return err.status < 500 && err.status !== 429;
304
+ }
305
+ }
306
+ });
307
+ return {
308
+ http,
309
+ identity,
310
+ rovenueId: () => identity.rovenueId(),
311
+ identify: (appUserId) => identity.identify(appUserId),
312
+ logOut: async () => {
313
+ await events.flush().catch(() => void 0);
314
+ events.clear();
315
+ cache.clear();
316
+ identity.logOut();
317
+ },
318
+ async getEntitlements() {
319
+ try {
320
+ const data = await http.get("/me/entitlements");
321
+ cache.write(data.entitlements);
322
+ return data.entitlements;
323
+ } catch (err) {
324
+ const status = err instanceof RovenueApiError ? err.status : null;
325
+ const serveFromCache = status === null || status >= 500 || status === 429;
326
+ if (!serveFromCache) throw err;
327
+ const cached = cache.read();
328
+ if (cached) return cached.entitlements;
329
+ throw err;
330
+ }
331
+ },
332
+ track: (input) => events.track(input),
333
+ flushEvents: () => events.flush(),
334
+ startEventQueue: () => events.start(),
335
+ stopEventQueue: () => events.stop(),
336
+ getCachedEntitlements: () => cache.read()?.entitlements ?? null,
337
+ getCachedEntitlementsEntry: () => cache.read(),
338
+ async recordExposure({ experimentId, variantId, placementId }) {
339
+ await http.post(`/experiments/${encodeURIComponent(experimentId)}/expose`, {
340
+ variantId,
341
+ subscriberId: identity.rovenueId(),
342
+ ...placementId ? { placementId } : {},
343
+ platform: "web"
344
+ // No exposedAt. The native path omits it too, and the server
345
+ // stamps its own clock — deliberately, because ClickHouse
346
+ // partitions and TTLs `raw_exposures` on this column and revenue
347
+ // attribution compares `eventDate >= min(exposedAt)`. A device
348
+ // clock a day fast makes that visitor a permanent non-converter
349
+ // (their purchases predate their own exposure); one set in the past
350
+ // over-attributes pre-experiment revenue; one two years slow has
351
+ // the row TTL-deleted out of the denominator entirely.
352
+ }).catch(() => {
353
+ });
354
+ },
355
+ getOfferings: () => http.get("/offerings"),
356
+ // An unknown placement returns an empty envelope rather than a 404, so
357
+ // this resolves rather than throwing — the caller renders nothing.
358
+ // Encoded: an identifier carrying `/`, `?` or `#` would otherwise change
359
+ // the path or truncate itself into a query string, and the server would
360
+ // answer for a different (or no) placement.
361
+ getPlacement: (identifier) => http.get(`/placements/${encodeURIComponent(identifier)}`),
362
+ checkout: ({ idempotencyKey, ...body }) => http.post("/checkout", body, { idempotencyKey })
363
+ };
364
+ }
365
+
366
+ export { RovenueApiError, configure, createMemoryStorage, createStorage };
367
+ //# sourceMappingURL=index.js.map
368
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/client.ts","../src/identity.ts","../src/storage.ts","../src/cache.ts","../src/events.ts","../src/index.ts"],"names":["body","createId"],"mappings":"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","file":"index.js","sourcesContent":["import type { Identity } from \"./identity\";\n\n// =============================================================\n// HTTP client\n// =============================================================\n//\n// Every request goes to the BROWSER surface: `<apiUrl>/v1/web/<publicKey>`.\n//\n// The public key is in the path as well as in the Authorization header. The\n// path mounts the browser surface, and the server requires the path key to be\n// the key the request authenticated with, so a page cannot send another\n// project's key under its own allow-listed path.\n//\n// Every preflight is answered. If the key's origin list does not include the\n// page's origin, the real request is refused with 403 ORIGIN_NOT_ALLOWED,\n// which reaches this client as a RovenueApiError carrying that code. The fix\n// is a dashboard configuration step, which is why the docs lead with it.\n\n/** Platform reported to the server. Persisted once, on subscriber create. */\nconst PLATFORM = \"web\";\n\nexport interface RovenueResponse<T> {\n data: T;\n}\n\nexport interface RovenueErrorBody {\n error: { code: string; message: string };\n}\n\nexport class RovenueApiError extends Error {\n constructor(\n readonly status: number,\n readonly code: string,\n message: string,\n ) {\n super(message);\n this.name = \"RovenueApiError\";\n }\n}\n\nexport interface HttpClient {\n get<T>(path: string): Promise<T>;\n post<T>(\n path: string,\n body: unknown,\n init?: { idempotencyKey?: string; keepalive?: boolean },\n ): Promise<T>;\n /** Absolute base, e.g. `https://api.example/v1/web/pk_live_x`. */\n readonly baseUrl: string;\n}\n\nexport interface CreateHttpClientOptions {\n apiUrl: string;\n publicKey: string;\n identity: Identity;\n fetchImpl?: typeof fetch;\n}\n\nexport function createHttpClient(opts: CreateHttpClientOptions): HttpClient {\n const { apiUrl, publicKey, identity } = opts;\n const doFetch = opts.fetchImpl ?? globalThis.fetch;\n const baseUrl = `${apiUrl.replace(/\\/+$/, \"\")}/v1/web/${publicKey}`;\n\n function headers(extra?: Record<string, string>): Record<string, string> {\n return {\n Authorization: `Bearer ${publicKey}`,\n // ALWAYS the rovenueId. Never the app scope from identify() — that one\n // is client-local, and putting it here routes the request to an\n // orphaned subscriber.\n \"x-rovenue-app-user-id\": identity.rovenueId(),\n // BOTH headers, as the Rust core sends. /v1/placements, /v1/config and\n // /v1/experiments read the subscriber from `x-rovenue-user-id`, not\n // from the app-user one. Sending only the latter left every web request\n // anonymous: audience-targeted placement rows could never match (only\n // an `audienceId: null` row can with no attributes), and a null\n // subscriber forces the project holdout to 0 — so web traffic was\n // silently excluded from the holdout and polluted the baseline.\n \"x-rovenue-user-id\": identity.rovenueId(),\n \"x-rovenue-platform\": PLATFORM,\n \"Content-Type\": \"application/json\",\n ...extra,\n };\n }\n\n async function unwrap<T>(res: Response): Promise<T> {\n if (!res.ok) {\n let code = \"HTTP_ERROR\";\n let message = `Request failed with ${res.status}`;\n try {\n const body = (await res.json()) as RovenueErrorBody;\n if (body?.error) {\n code = body.error.code;\n message = body.error.message;\n }\n } catch {\n // A non-JSON error body (a proxy's HTML 502, say) must not mask the\n // status — the status is the useful part and it is already captured.\n }\n throw new RovenueApiError(res.status, code, message);\n }\n // /v1/events answers `202` with NO body, and several endpoints answer\n // 204. Calling res.json() on those throws a SyntaxError, which the event\n // queue would read as \"not acknowledged\" — replaying an event the server\n // had already ingested, on every flush and every page load, forever.\n if (res.status === 204 || res.status === 202) return undefined as T;\n const text = await res.text();\n if (text === \"\") return undefined as T;\n const body = JSON.parse(text) as RovenueResponse<T>;\n return body.data;\n }\n\n return {\n baseUrl,\n async get<T>(path: string): Promise<T> {\n const res = await doFetch(`${baseUrl}${path}`, {\n method: \"GET\",\n headers: headers(),\n });\n return unwrap<T>(res);\n },\n async post<T>(\n path: string,\n body: unknown,\n init?: { idempotencyKey?: string; keepalive?: boolean },\n ): Promise<T> {\n const res = await doFetch(`${baseUrl}${path}`, {\n method: \"POST\",\n headers: headers(\n init?.idempotencyKey\n ? { \"Idempotency-Key\": init.idempotencyKey }\n : undefined,\n ),\n body: JSON.stringify(body),\n // Survives the page being unloaded, which is the whole reason the\n // event queue can flush on pagehide at all. sendBeacon cannot be\n // used here: it sets no headers, and this API authenticates from\n // Authorization.\n ...(init?.keepalive ? { keepalive: true } : {}),\n });\n return unwrap<T>(res);\n },\n };\n}\n","import { createId } from \"@paralleldrive/cuid2\";\nimport type { SdkStorage } from \"./storage\";\n\n// =============================================================\n// Identity\n// =============================================================\n//\n// Two ids, and conflating them has already cost this codebase real breakage:\n//\n// rovenueId — the SDK's own permanent identifier for this browser. It is\n// what goes on the wire, always. Generated once, persisted,\n// regenerated on logOut().\n//\n// app scope — whatever the host application called this person via\n// identify(). It is CLIENT-LOCAL. It is not sent as the\n// identity header, and merging two subscribers is a\n// server-side operation through the secret-key transfer\n// endpoint, not something a public browser key may do.\n//\n// Sending the app scope on the wire instead of the rovenueId is what\n// produced orphan-subscriber routing before. The wire identity is the\n// rovenueId; there is no configuration that changes that.\n\nconst ROVENUE_ID_KEY = \"rovenue.rovenueId\";\n\n/**\n * Rough shape of an email address. Used only to warn, never to reject.\n *\n * The SDK's auth model rests on the app user id being unguessable: a public\n * key is visible in the browser, so anyone who can guess an id can read that\n * subscriber's entitlements. An email is the most common guessable id a\n * developer reaches for, so it earns a warning at the moment it is passed.\n *\n * It warns rather than throws because the server does not enforce this, and\n * breaking an application mid-flight over a policy its backend accepts would\n * be the SDK overreaching.\n */\nconst EMAIL_SHAPED = /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/;\n\nexport interface Identity {\n /** The permanent wire identity for this browser. */\n rovenueId(): string;\n /** The host application's own name for this person, if it set one. */\n appUserScope(): string | null;\n identify(appUserId: string): void;\n /** Forgets both, and mints a fresh rovenueId. */\n logOut(): void;\n}\n\nexport function createIdentity(\n storage: SdkStorage,\n warn: (message: string) => void = (m) => console.warn(m),\n): Identity {\n let cached = storage.get(ROVENUE_ID_KEY);\n if (!cached) {\n cached = createId();\n storage.set(ROVENUE_ID_KEY, cached);\n }\n let scope: string | null = null;\n\n return {\n rovenueId: () => cached as string,\n appUserScope: () => scope,\n identify(appUserId: string) {\n if (EMAIL_SHAPED.test(appUserId)) {\n warn(\n \"[rovenue] identify() was given an email address. App user ids are \" +\n \"guessable identifiers to anyone holding your public key, which \" +\n \"is visible in the browser. Use an opaque id your backend maps \" +\n \"to the user instead.\",\n );\n }\n scope = appUserId;\n },\n logOut() {\n scope = null;\n cached = createId();\n storage.set(ROVENUE_ID_KEY, cached);\n },\n };\n}\n","// =============================================================\n// Storage seam\n// =============================================================\n//\n// The SDK persists two things across page loads: the generated rovenueId and\n// the unsent event queue. Both need somewhere to live, and that somewhere is\n// not always available.\n//\n// This module defines the seam only. The browser-backed implementation, and\n// the reasons `localStorage` cannot simply be used directly, live in the\n// storage implementation added alongside the entitlement cache.\n\nexport interface SdkStorage {\n get(key: string): string | null;\n set(key: string, value: string): void;\n remove(key: string): void;\n}\n\n/**\n * In-memory storage. The default, and the fallback whenever a real store is\n * unavailable.\n *\n * Nothing here throws: a viewer whose browser refuses storage must still get\n * a working SDK, just one that forgets between page loads.\n */\nexport function createMemoryStorage(): SdkStorage {\n const map = new Map<string, string>();\n return {\n get: (key) => map.get(key) ?? null,\n set: (key, value) => {\n map.set(key, value);\n },\n remove: (key) => {\n map.delete(key);\n },\n };\n}\n\nconst PROBE_KEY = \"rovenue.__probe\";\n\n/**\n * Storage backed by `localStorage` where it works, memory where it does not.\n *\n * `localStorage` fails in three different ways and only the first is caught\n * by asking whether it is defined:\n *\n * 1. **Absent** — server-side rendering. This is why the probe runs on\n * first use and never at module scope: an access during import throws\n * before any consumer could guard it.\n * 2. **Present but throwing** — Safari private mode, and browsers with\n * site data blocked. `typeof localStorage` is `\"object\"` and the first\n * write raises.\n * 3. **Present and full** — a quota error on a write, long after the probe\n * said everything was fine. So every operation is guarded, not just the\n * probe: a store that passed once can still fail later.\n *\n * A failed write drops the value and returns. Losing a cached entitlement is\n * a lost convenience; taking the host application down over it is not a\n * trade the SDK gets to make on the developer's behalf.\n */\nexport function createStorage(): SdkStorage {\n const memory = createMemoryStorage();\n\n let backing: Storage | null = null;\n try {\n const candidate = globalThis.localStorage;\n if (candidate) {\n // Write-then-remove rather than a read: a store can be readable and\n // refuse writes, which is exactly case 2.\n candidate.setItem(PROBE_KEY, \"1\");\n candidate.removeItem(PROBE_KEY);\n backing = candidate;\n }\n } catch {\n backing = null;\n }\n\n if (!backing) return memory;\n const store = backing;\n\n return {\n get(key) {\n try {\n // `?? memory.get` and not just the try/catch: a FULL localStorage\n // refuses writes while `getItem` returns null without throwing, so a\n // value that fell back to memory on write was unreachable on read.\n // That silently dropped queued events in the module that documents\n // at-least-once delivery.\n return store.getItem(key) ?? memory.get(key);\n } catch {\n return memory.get(key);\n }\n },\n set(key, value) {\n try {\n store.setItem(key, value);\n } catch {\n // Quota, or storage revoked mid-session. Keep it in memory so the\n // value survives this page at least — and REMOVE the stale entry\n // first. `get` reads the real store before memory, so leaving an old\n // value there shadows the new one: the event queue would read the\n // pre-quota `[A]`, post A, then write `[]`, silently dropping the B\n // it thought it had persisted. The same shape resurrects a\n // logged-out rovenueId on the next page load.\n try {\n store.removeItem(key);\n } catch {\n // Nothing more to try; memory is now the only copy either way.\n }\n memory.set(key, value);\n }\n },\n remove(key) {\n try {\n store.removeItem(key);\n } catch {\n // Nothing to do: the key is unreachable either way.\n }\n memory.remove(key);\n },\n };\n}\n","import type { SdkStorage } from \"./storage\";\n\n// =============================================================\n// Last-known entitlements\n// =============================================================\n//\n// The web analogue of the native SDKs' MMKV / SQLite cache. Its job is to let\n// a page render something truthful before the network answers, and to keep\n// rendering it when the network does not answer at all.\n//\n// What it is NOT is an authorization decision. A cached entitlement is a\n// statement about what the server said last time, held in storage the viewer\n// can edit freely. Anything that must actually be enforced is enforced on the\n// server; this exists so a returning subscriber does not see a flash of the\n// paywall they already paid to remove.\n\nconst CACHE_KEY = \"rovenue.entitlements\";\n\nexport interface CachedEntitlements {\n entitlements: Record<string, unknown>;\n /** Epoch millis of the response this came from. */\n fetchedAt: number;\n}\n\nexport interface EntitlementCache {\n read(): CachedEntitlements | null;\n write(entitlements: Record<string, unknown>): void;\n clear(): void;\n}\n\nexport function createEntitlementCache(\n storage: SdkStorage,\n now: () => number = Date.now,\n): EntitlementCache {\n return {\n read() {\n const raw = storage.get(CACHE_KEY);\n if (!raw) return null;\n try {\n const parsed = JSON.parse(raw) as CachedEntitlements;\n // Storage is viewer-editable, so the shape is checked rather than\n // trusted. A malformed entry is treated as no entry: the SDK falls\n // back to the network instead of handing the caller a broken object.\n if (\n !parsed ||\n typeof parsed !== \"object\" ||\n typeof parsed.fetchedAt !== \"number\" ||\n typeof parsed.entitlements !== \"object\" ||\n parsed.entitlements === null\n ) {\n return null;\n }\n return parsed;\n } catch {\n return null;\n }\n },\n write(entitlements) {\n storage.set(\n CACHE_KEY,\n JSON.stringify({ entitlements, fetchedAt: now() }),\n );\n },\n clear() {\n storage.remove(CACHE_KEY);\n },\n };\n}\n","import { createId } from \"@paralleldrive/cuid2\";\nimport type { SdkStorage } from \"./storage\";\n\n// =============================================================\n// Event queue\n// =============================================================\n//\n// At-least-once delivery, which means three things and not one:\n//\n// 1. **Delete only after the server acknowledges.** Peek, post, then drop.\n// Removing an event on send turns a network blip into lost telemetry,\n// and telemetry that is silently lossy is worse than none — it produces\n// dashboards that look fine and are wrong.\n// 2. **Survive the page.** Unacknowledged events go back to storage, so the\n// next page load replays them.\n// 3. **Flush on the way out.** A timer alone loses whatever was queued in\n// the last interval, which on a paywall page is the interesting part.\n//\n// Replay is safe because each event carries a stable id the server dedupes\n// on. That id is minted ONCE, when the event is tracked — regenerating it per\n// attempt would turn every retry into a new event and double-count.\n//\n// ## Why not sendBeacon\n//\n// `navigator.sendBeacon` is the usual answer for flushing on unload, and it\n// does not work here: it cannot set request headers, and this API\n// authenticates from `Authorization`. (Identity itself would have been fine —\n// `/v1/events` takes the subscriber in the body.) `fetch(..., { keepalive:\n// true })` carries headers and survives unload, so it is the only path rather\n// than the fallback. Its 64 KB body cap is far above one event envelope.\n\nconst QUEUE_KEY = \"rovenue.events\";\n/** Wire format version the server expects (EVENT_WIRE_VERSION). */\nconst EVENT_WIRE_VERSION = 1;\n/** Events kept when the queue cannot drain. Oldest are dropped first. */\nconst MAX_QUEUED_EVENTS = 200;\n\nexport interface TrackInput {\n eventType: string;\n occurredAt?: string;\n subscriberId?: string;\n productId?: string;\n amount?: string;\n currency?: string;\n eventSourceUrl?: string;\n}\n\nexport interface QueuedEvent extends TrackInput {\n version: typeof EVENT_WIRE_VERSION;\n eventId: string;\n occurredAt: string;\n}\n\nexport interface EventQueue {\n track(input: TrackInput): void;\n /** Attempts every queued event once. Retains whatever is not acknowledged. */\n flush(): Promise<void>;\n /** Drops every queued event. Used when the identity they belong to goes. */\n clear(): void;\n /** Registers the unload listeners. No-op where there is no DOM. */\n start(): void;\n stop(): void;\n}\n\nexport interface CreateEventQueueOptions {\n storage: SdkStorage;\n /** Resolves true when the server accepted the event. */\n post: (event: QueuedEvent) => Promise<boolean>;\n now?: () => Date;\n}\n\nexport function createEventQueue(opts: CreateEventQueueOptions): EventQueue {\n const { storage, post } = opts;\n const now = opts.now ?? (() => new Date());\n\n function read(): QueuedEvent[] {\n const raw = storage.get(QUEUE_KEY);\n if (!raw) return [];\n try {\n const parsed = JSON.parse(raw);\n // Storage is viewer-editable. A malformed queue is dropped rather than\n // fed to the sender, which would throw on every flush forever.\n return Array.isArray(parsed) ? (parsed as QueuedEvent[]) : [];\n } catch {\n return [];\n }\n }\n\n function write(events: QueuedEvent[]): void {\n // Bounded: a viewer who is offline for a week must not accumulate an\n // unbounded queue in storage. Oldest go first — the newest events are\n // the ones still worth attributing.\n const bounded = events.slice(-MAX_QUEUED_EVENTS);\n storage.set(QUEUE_KEY, JSON.stringify(bounded));\n }\n\n let flushing = false;\n let detach: (() => void) | null = null;\n\n const queue: EventQueue = {\n track(input) {\n const event: QueuedEvent = {\n ...input,\n version: EVENT_WIRE_VERSION,\n // Minted once, here. Reused verbatim on every retry so the server's\n // dedup can recognise the replay.\n eventId: createId(),\n occurredAt: input.occurredAt ?? now().toISOString(),\n };\n write([...read(), event]);\n },\n\n async flush() {\n // A flush triggered by pagehide can overlap the timer's. Two senders\n // over one storage queue would each read the same events and post them\n // twice.\n if (flushing) return;\n flushing = true;\n try {\n const pending = read();\n if (pending.length === 0) return;\n\n const retained: QueuedEvent[] = [];\n for (const event of pending) {\n let accepted = false;\n try {\n accepted = await post(event);\n } catch {\n accepted = false;\n }\n // Every event is attempted, not just up to the first failure: one\n // permanently-rejected event must not strand every later one\n // behind it.\n if (!accepted) retained.push(event);\n }\n // Re-read rather than writing the snapshot back. `post` awaits, and\n // anything track() persisted during those awaits is in storage now;\n // writing `retained` alone would silently drop it — losing exactly\n // the paywall_view that a visibilitychange flush overlapped, which is\n // the at-least-once guarantee this module claims.\n const seen = new Set(pending.map((e) => e.eventId));\n const arrivedDuringFlush = read().filter((e) => !seen.has(e.eventId));\n write([...retained, ...arrivedDuringFlush]);\n } finally {\n flushing = false;\n }\n },\n\n clear() {\n write([]);\n },\n\n start() {\n // No DOM: server-side rendering. Tracking still works and is replayed\n // by whichever browser session picks the queue up.\n const doc = globalThis.document;\n const win = globalThis.window;\n if (!doc && !win) return;\n\n const onHide = () => {\n if (!doc || doc.visibilityState === \"hidden\") void queue.flush();\n };\n const onPageHide = () => {\n void queue.flush();\n };\n\n doc?.addEventListener(\"visibilitychange\", onHide);\n win?.addEventListener(\"pagehide\", onPageHide);\n detach = () => {\n doc?.removeEventListener(\"visibilitychange\", onHide);\n win?.removeEventListener(\"pagehide\", onPageHide);\n };\n },\n\n stop() {\n detach?.();\n detach = null;\n },\n };\n\n return queue;\n}\n","import { createHttpClient, RovenueApiError, type HttpClient } from \"./client\";\nimport { createIdentity, type Identity } from \"./identity\";\nimport { createMemoryStorage, createStorage, type SdkStorage } from \"./storage\";\nimport { createEntitlementCache, type CachedEntitlements } from \"./cache\";\nimport { createEventQueue, type TrackInput } from \"./events\";\n\nexport { RovenueApiError } from \"./client\";\nexport { createStorage, createMemoryStorage } from \"./storage\";\nexport type { SdkStorage } from \"./storage\";\nexport type { CachedEntitlements } from \"./cache\";\nexport type { TrackInput, QueuedEvent } from \"./events\";\n\n// =============================================================\n// @rovenue/web-sdk\n// =============================================================\n//\n// Nothing in this module touches `window`, `document` or `localStorage` at\n// import time. A server-rendered app imports and constructs the SDK during\n// SSR, where none of those exist, and a module-scope access would throw\n// before any consumer could guard it.\n\nexport interface RovenueOptions {\n /** The project's PUBLIC api key. Visible in the browser by design. */\n apiKey: string;\n /** Base URL of the Rovenue API, without a trailing path. */\n apiUrl: string;\n /** Injected for tests and for hosts that wrap fetch. */\n fetchImpl?: typeof fetch;\n /**\n * Where the rovenueId, the entitlement cache and the event queue persist.\n *\n * Defaults to {@link createStorage}, which uses `localStorage` where it\n * works and falls back to memory where it does not — including during\n * server rendering. Defaulting to memory instead would silently mint a new\n * subscriber on every page load, orphaning the cache and the queue with it,\n * and would only be correct for developers who read the docs.\n */\n storage?: SdkStorage;\n}\n\nexport interface EntitlementsResponse {\n entitlements: Record<string, unknown>;\n}\n\nexport interface CheckoutResult {\n sessionId: string;\n url: string;\n}\n\n/** One package slot of an offering, as GET /v1/offerings serves it. */\nexport interface WebPackage {\n /** The package slot id — what {@link Rovenue.checkout} takes as `packageIdentifier`. */\n packageIdentifier: string;\n /** The product's own identifier. Not accepted by checkout(). */\n identifier: string;\n /** The product type, e.g. `SUBSCRIPTION`. Web checkout sells subscriptions only. */\n type: string;\n /** The product's display name as configured in the dashboard. */\n displayName: string;\n /** The package's position within its offering, ascending. */\n order: number;\n /** Whether the dashboard marked this package as the one to highlight. */\n isPromoted: boolean;\n /** Identifiers of the accesses (entitlements) a purchase grants. */\n accessIds: string[];\n /** The product's store identifiers, keyed by store (e.g. `stripe`). */\n storeIds: Record<string, string>;\n /** Free-form metadata set on this package slot in the offering. */\n metadata: unknown;\n}\n\n/** An offering — `identifier` is what {@link Rovenue.checkout} takes as `offeringIdentifier`. */\nexport interface WebOffering {\n /** The offering's identifier — pass it to checkout() as `offeringIdentifier`. */\n identifier: string;\n /** Whether this is the project's default (current) offering. */\n isDefault: boolean;\n /** The offering's package slots, in display order. */\n packages: WebPackage[];\n /** Free-form offering metadata from the dashboard. */\n metadata: unknown;\n}\n\n/** The body of GET /v1/offerings. */\nexport interface WebOfferingsResponse {\n /** The project's offerings, as served to this subscriber. */\n offerings: WebOffering[];\n}\n\nexport interface Rovenue {\n /** The wire identity for this browser. */\n rovenueId(): string;\n /** Client-local. Merging subscribers is a server-side, secret-key operation. */\n identify(appUserId: string): void;\n /** Flushes queued events under the current identity, then rotates it. */\n logOut(): Promise<void>;\n getEntitlements(): Promise<Record<string, unknown>>;\n /**\n * Last-known entitlements, or null if nothing has been fetched yet.\n *\n * Synchronous and network-free, so a first paint can render the truth the\n * server gave last time instead of a flash of the paywall a subscriber has\n * already paid to remove. It is a cache, never an authorization decision:\n * the storage behind it is viewer-editable.\n */\n getCachedEntitlements(): Record<string, unknown> | null;\n /** The cache entry with its timestamp, for callers that need staleness. */\n getCachedEntitlementsEntry(): CachedEntitlements | null;\n /**\n * Queues an event for at-least-once delivery.\n *\n * Returns immediately: the event is persisted and sent by the queue, which\n * retries across page loads and flushes when the tab is hidden or closing.\n */\n track(input: TrackInput): void;\n /** Attempts every queued event once. Called automatically on unload. */\n flushEvents(): Promise<void>;\n /**\n * Registers the unload listeners that flush queued events.\n *\n * Not called by configure(): configure() also runs during server\n * rendering, where there is nothing to listen to. The React provider calls\n * this in an effect; a non-React host calls it once after construction.\n */\n startEventQueue(): void;\n stopEventQueue(): void;\n /**\n * Records that a subscriber was shown an experiment variant.\n *\n * Assignment alone is invisible to the analytics: a visitor bucketed into\n * an arm but missing from `exposure_events` still contributes revenue while\n * being absent from the denominator, which biases conversion rates and\n * breaks the sample-ratio check. The native path fires this immediately\n * after the draw; so does the web one.\n */\n recordExposure(input: {\n experimentId: string;\n variantId: string;\n placementId?: string;\n }): Promise<void>;\n getOfferings(): Promise<WebOfferingsResponse>;\n getPlacement(identifier: string): Promise<unknown>;\n /**\n * Starts a Stripe Checkout and returns the URL to send the browser to.\n *\n * The caller names a package, never a price: the amount is resolved\n * server-side from the offering, so a page that lies about what something\n * costs changes nothing about what is charged.\n *\n * Redirect URLs must be on one of the key's allowed origins (exact scheme,\n * host and port) or a verified custom domain over https.\n */\n checkout(input: {\n offeringIdentifier: string;\n packageIdentifier: string;\n successUrl: string;\n cancelUrl: string;\n idempotencyKey?: string;\n }): Promise<CheckoutResult>;\n /** Escape hatch for endpoints without a typed wrapper yet. */\n readonly http: HttpClient;\n readonly identity: Identity;\n}\n\nexport function configure(options: RovenueOptions): Rovenue {\n if (!options.apiKey) throw new Error(\"[rovenue] configure() needs an apiKey\");\n if (!options.apiUrl) throw new Error(\"[rovenue] configure() needs an apiUrl\");\n\n const storage = options.storage ?? createStorage();\n const identity = createIdentity(storage);\n const cache = createEntitlementCache(storage);\n const http = createHttpClient({\n apiUrl: options.apiUrl,\n publicKey: options.apiKey,\n identity,\n fetchImpl: options.fetchImpl,\n });\n const events = createEventQueue({\n storage,\n post: async (event) => {\n try {\n await http.post(\"/events\", event, { keepalive: true });\n return true;\n } catch (err) {\n // A 4xx means this event will never be accepted — a malformed\n // envelope, or a key that no longer exists. Retrying it forever\n // would block the queue behind it, so it is acknowledged (dropped)\n // rather than retained.\n //\n // 429 is the exception, and the one that matters most: a rate-limited\n // event is not rejected, it is deferred. Dropping it would lose\n // telemetry precisely when volume is highest, which is when the\n // numbers are most worth having. Same carve-out getEntitlements makes.\n if (!(err instanceof RovenueApiError)) return false;\n return err.status < 500 && err.status !== 429;\n }\n },\n });\n\n\n return {\n http,\n identity,\n rovenueId: () => identity.rovenueId(),\n identify: (appUserId) => identity.identify(appUserId),\n logOut: async () => {\n // Flush BEFORE the identity changes. Request headers are built at post\n // time, so an event still queued when the rovenueId rotates would be\n // delivered attributed to the new anonymous subscriber — the previous\n // person's paywall views landing on whoever logs in next.\n //\n // Awaited rather than fired and forgotten: the whole point is that it\n // completes while the old identity is still the current one. What the\n // server refuses is dropped rather than carried across, which is the\n // right trade — an event that cannot be attributed to the right\n // subscriber is worse than a missing one.\n await events.flush().catch(() => undefined);\n events.clear();\n // The cache belongs to the previous identity too. Leaving it would show\n // the next person the last one's entitlements.\n cache.clear();\n identity.logOut();\n },\n async getEntitlements() {\n try {\n const data = await http.get<EntitlementsResponse>(\"/me/entitlements\");\n cache.write(data.entitlements);\n return data.entitlements;\n } catch (err) {\n // Serving the cache is right for a failure the app cannot fix and the\n // viewer will recover from — offline, DNS, a 5xx. It is WRONG for a\n // 4xx: a revoked key, a wrong project or a rejected origin is a\n // configuration error, and answering it from cache hides it for as\n // long as the cache survives, which is exactly when a developer most\n // needs to see it.\n //\n // The one 4xx that is not a misconfiguration is 429, where backing\n // off and showing last-known is the correct behaviour.\n const status = err instanceof RovenueApiError ? err.status : null;\n const serveFromCache =\n status === null || status >= 500 || status === 429;\n if (!serveFromCache) throw err;\n\n const cached = cache.read();\n if (cached) return cached.entitlements;\n throw err;\n }\n },\n track: (input) => events.track(input),\n flushEvents: () => events.flush(),\n startEventQueue: () => events.start(),\n stopEventQueue: () => events.stop(),\n getCachedEntitlements: () => cache.read()?.entitlements ?? null,\n getCachedEntitlementsEntry: () => cache.read(),\n async recordExposure({ experimentId, variantId, placementId }) {\n await http\n .post(`/experiments/${encodeURIComponent(experimentId)}/expose`, {\n variantId,\n subscriberId: identity.rovenueId(),\n ...(placementId ? { placementId } : {}),\n platform: \"web\",\n // No exposedAt. The native path omits it too, and the server\n // stamps its own clock — deliberately, because ClickHouse\n // partitions and TTLs `raw_exposures` on this column and revenue\n // attribution compares `eventDate >= min(exposedAt)`. A device\n // clock a day fast makes that visitor a permanent non-converter\n // (their purchases predate their own exposure); one set in the past\n // over-attributes pre-experiment revenue; one two years slow has\n // the row TTL-deleted out of the denominator entirely.\n })\n .catch(() => {\n // Fire-and-forget, like the native path: a failed exposure must not\n // stop the paywall rendering. It is lost rather than queued because\n // an exposure replayed later would be timestamped wrong, and a\n // wrong timestamp is worse for an experiment than a missing row.\n });\n },\n getOfferings: () => http.get<WebOfferingsResponse>(\"/offerings\"),\n // An unknown placement returns an empty envelope rather than a 404, so\n // this resolves rather than throwing — the caller renders nothing.\n // Encoded: an identifier carrying `/`, `?` or `#` would otherwise change\n // the path or truncate itself into a query string, and the server would\n // answer for a different (or no) placement.\n getPlacement: (identifier) =>\n http.get(`/placements/${encodeURIComponent(identifier)}`),\n checkout: ({ idempotencyKey, ...body }) =>\n http.post<CheckoutResult>(\"/checkout\", body, { idempotencyKey }),\n };\n}\n"]}
package/dist/react.cjs ADDED
@@ -0,0 +1,108 @@
1
+ 'use strict';
2
+
3
+ require('./chunk-4DE2IREA.cjs');
4
+ var react = require('react');
5
+ var jsxRuntime = require('react/jsx-runtime');
6
+
7
+ var RovenueContext = react.createContext(null);
8
+ function RovenueProvider({
9
+ client,
10
+ children
11
+ }) {
12
+ react.useEffect(() => {
13
+ client.startEventQueue();
14
+ return () => client.stopEventQueue();
15
+ }, [client]);
16
+ return /* @__PURE__ */ jsxRuntime.jsx(RovenueContext.Provider, { value: client, children });
17
+ }
18
+ function useRovenue() {
19
+ const client = react.useContext(RovenueContext);
20
+ if (!client) {
21
+ throw new Error(
22
+ "[rovenue] useRovenue() was called outside a <RovenueProvider>. Wrap the tree that uses Rovenue hooks in one, passing the client you created with configure()."
23
+ );
24
+ }
25
+ return client;
26
+ }
27
+ function useEntitlements() {
28
+ const rovenue = useRovenue();
29
+ const [entitlements, setEntitlements] = react.useState(() => rovenue.getCachedEntitlements());
30
+ const [isLoading, setIsLoading] = react.useState(true);
31
+ const [error, setError] = react.useState(null);
32
+ const alive = react.useRef(true);
33
+ const load = react.useCallback(async () => {
34
+ setIsLoading(true);
35
+ try {
36
+ const next = await rovenue.getEntitlements();
37
+ if (!alive.current) return;
38
+ setEntitlements(next);
39
+ setError(null);
40
+ } catch (err) {
41
+ if (!alive.current) return;
42
+ setError(err instanceof Error ? err : new Error(String(err)));
43
+ } finally {
44
+ if (alive.current) setIsLoading(false);
45
+ }
46
+ }, [rovenue]);
47
+ react.useEffect(() => {
48
+ alive.current = true;
49
+ void load();
50
+ return () => {
51
+ alive.current = false;
52
+ };
53
+ }, [load]);
54
+ return { entitlements, isLoading, error, refresh: load };
55
+ }
56
+ function usePlacement(identifier) {
57
+ const rovenue = useRovenue();
58
+ const [placement, setPlacement] = react.useState(null);
59
+ const [isLoading, setIsLoading] = react.useState(true);
60
+ const [error, setError] = react.useState(null);
61
+ const alive = react.useRef(true);
62
+ react.useEffect(() => {
63
+ alive.current = true;
64
+ setIsLoading(true);
65
+ rovenue.getPlacement(identifier).then((value) => {
66
+ if (!alive.current) return;
67
+ setPlacement(value);
68
+ setError(null);
69
+ }).catch((err) => {
70
+ if (!alive.current) return;
71
+ setError(err instanceof Error ? err : new Error(String(err)));
72
+ }).finally(() => {
73
+ if (alive.current) setIsLoading(false);
74
+ });
75
+ return () => {
76
+ alive.current = false;
77
+ };
78
+ }, [rovenue, identifier]);
79
+ return { placement, isLoading, error };
80
+ }
81
+ function useCheckout() {
82
+ const rovenue = useRovenue();
83
+ const [isStarting, setIsStarting] = react.useState(false);
84
+ const [error, setError] = react.useState(null);
85
+ const start = react.useCallback(
86
+ async (input) => {
87
+ setIsStarting(true);
88
+ setError(null);
89
+ try {
90
+ const session = await rovenue.checkout(input);
91
+ globalThis.location.assign(session.url);
92
+ } catch (err) {
93
+ setError(err instanceof Error ? err : new Error(String(err)));
94
+ setIsStarting(false);
95
+ }
96
+ },
97
+ [rovenue]
98
+ );
99
+ return { start, isStarting, error };
100
+ }
101
+
102
+ exports.RovenueProvider = RovenueProvider;
103
+ exports.useCheckout = useCheckout;
104
+ exports.useEntitlements = useEntitlements;
105
+ exports.usePlacement = usePlacement;
106
+ exports.useRovenue = useRovenue;
107
+ //# sourceMappingURL=react.cjs.map
108
+ //# sourceMappingURL=react.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/react/provider.tsx","../src/react/hooks.ts"],"names":["createContext","useEffect","useContext","useState","useRef","useCallback"],"mappings":";;;;;;AAiBA,IAAM,cAAA,GAAiBA,oBAA8B,IAAI,CAAA;AAElD,SAAS,eAAA,CAAgB;AAAA,EAC9B,MAAA;AAAA,EACA;AACF,CAAA,EAGG;AACD,EAAAC,eAAA,CAAU,MAAM;AAId,IAAA,MAAA,CAAO,eAAA,EAAgB;AACvB,IAAA,OAAO,MAAM,OAAO,cAAA,EAAe;AAAA,EACrC,CAAA,EAAG,CAAC,MAAM,CAAC,CAAA;AAEX,EAAA,sCACG,cAAA,CAAe,QAAA,EAAf,EAAwB,KAAA,EAAO,QAAS,QAAA,EAAS,CAAA;AAEtD;AAEO,SAAS,UAAA,GAAsB;AACpC,EAAA,MAAM,MAAA,GAASC,iBAAW,cAAc,CAAA;AACxC,EAAA,IAAI,CAAC,MAAA,EAAQ;AAGX,IAAA,MAAM,IAAI,KAAA;AAAA,MACR;AAAA,KAGF;AAAA,EACF;AACA,EAAA,OAAO,MAAA;AACT;AC5BO,SAAS,eAAA,GAAqC;AACnD,EAAA,MAAM,UAAU,UAAA,EAAW;AAC3B,EAAA,MAAM,CAAC,cAAc,eAAe,CAAA,GAAIC,eAG9B,MAAM,OAAA,CAAQ,uBAAuB,CAAA;AAC/C,EAAA,MAAM,CAAC,SAAA,EAAW,YAAY,CAAA,GAAIA,eAAS,IAAI,CAAA;AAC/C,EAAA,MAAM,CAAC,KAAA,EAAO,QAAQ,CAAA,GAAIA,eAAuB,IAAI,CAAA;AAGrD,EAAA,MAAM,KAAA,GAAQC,aAAO,IAAI,CAAA;AAEzB,EAAA,MAAM,IAAA,GAAOC,kBAAY,YAAY;AACnC,IAAA,YAAA,CAAa,IAAI,CAAA;AACjB,IAAA,IAAI;AACF,MAAA,MAAM,IAAA,GAAO,MAAM,OAAA,CAAQ,eAAA,EAAgB;AAC3C,MAAA,IAAI,CAAC,MAAM,OAAA,EAAS;AACpB,MAAA,eAAA,CAAgB,IAAI,CAAA;AACpB,MAAA,QAAA,CAAS,IAAI,CAAA;AAAA,IACf,SAAS,GAAA,EAAK;AACZ,MAAA,IAAI,CAAC,MAAM,OAAA,EAAS;AAGpB,MAAA,QAAA,CAAS,GAAA,YAAe,QAAQ,GAAA,GAAM,IAAI,MAAM,MAAA,CAAO,GAAG,CAAC,CAAC,CAAA;AAAA,IAC9D,CAAA,SAAE;AACA,MAAA,IAAI,KAAA,CAAM,OAAA,EAAS,YAAA,CAAa,KAAK,CAAA;AAAA,IACvC;AAAA,EACF,CAAA,EAAG,CAAC,OAAO,CAAC,CAAA;AAEZ,EAAAJ,gBAAU,MAAM;AACd,IAAA,KAAA,CAAM,OAAA,GAAU,IAAA;AAChB,IAAA,KAAK,IAAA,EAAK;AACV,IAAA,OAAO,MAAM;AACX,MAAA,KAAA,CAAM,OAAA,GAAU,KAAA;AAAA,IAClB,CAAA;AAAA,EAIF,CAAA,EAAG,CAAC,IAAI,CAAC,CAAA;AAET,EAAA,OAAO,EAAE,YAAA,EAAc,SAAA,EAAW,KAAA,EAAO,SAAS,IAAA,EAAK;AACzD;AAgBO,SAAS,aAAa,UAAA,EAAoC;AAC/D,EAAA,MAAM,UAAU,UAAA,EAAW;AAC3B,EAAA,MAAM,CAAC,SAAA,EAAW,YAAY,CAAA,GAAIE,eAAkB,IAAI,CAAA;AACxD,EAAA,MAAM,CAAC,SAAA,EAAW,YAAY,CAAA,GAAIA,eAAS,IAAI,CAAA;AAC/C,EAAA,MAAM,CAAC,KAAA,EAAO,QAAQ,CAAA,GAAIA,eAAuB,IAAI,CAAA;AACrD,EAAA,MAAM,KAAA,GAAQC,aAAO,IAAI,CAAA;AAEzB,EAAAH,gBAAU,MAAM;AACd,IAAA,KAAA,CAAM,OAAA,GAAU,IAAA;AAChB,IAAA,YAAA,CAAa,IAAI,CAAA;AACjB,IAAA,OAAA,CACG,YAAA,CAAa,UAAU,CAAA,CACvB,IAAA,CAAK,CAAC,KAAA,KAAU;AACf,MAAA,IAAI,CAAC,MAAM,OAAA,EAAS;AACpB,MAAA,YAAA,CAAa,KAAK,CAAA;AAClB,MAAA,QAAA,CAAS,IAAI,CAAA;AAAA,IACf,CAAC,CAAA,CACA,KAAA,CAAM,CAAC,GAAA,KAAiB;AACvB,MAAA,IAAI,CAAC,MAAM,OAAA,EAAS;AACpB,MAAA,QAAA,CAAS,GAAA,YAAe,QAAQ,GAAA,GAAM,IAAI,MAAM,MAAA,CAAO,GAAG,CAAC,CAAC,CAAA;AAAA,IAC9D,CAAC,CAAA,CACA,OAAA,CAAQ,MAAM;AACb,MAAA,IAAI,KAAA,CAAM,OAAA,EAAS,YAAA,CAAa,KAAK,CAAA;AAAA,IACvC,CAAC,CAAA;AACH,IAAA,OAAO,MAAM;AACX,MAAA,KAAA,CAAM,OAAA,GAAU,KAAA;AAAA,IAClB,CAAA;AAAA,EACF,CAAA,EAAG,CAAC,OAAA,EAAS,UAAU,CAAC,CAAA;AAExB,EAAA,OAAO,EAAE,SAAA,EAAW,SAAA,EAAW,KAAA,EAAM;AACvC;AAuBO,SAAS,WAAA,GAA6B;AAC3C,EAAA,MAAM,UAAU,UAAA,EAAW;AAC3B,EAAA,MAAM,CAAC,UAAA,EAAY,aAAa,CAAA,GAAIE,eAAS,KAAK,CAAA;AAClD,EAAA,MAAM,CAAC,KAAA,EAAO,QAAQ,CAAA,GAAIA,eAAuB,IAAI,CAAA;AAErD,EAAA,MAAM,KAAA,GAAQE,iBAAA;AAAA,IACZ,OAAO,KAAA,KAAU;AACf,MAAA,aAAA,CAAc,IAAI,CAAA;AAClB,MAAA,QAAA,CAAS,IAAI,CAAA;AACb,MAAA,IAAI;AACF,QAAA,MAAM,OAAA,GAAU,MAAM,OAAA,CAAQ,QAAA,CAAS,KAAK,CAAA;AAG5C,QAAA,UAAA,CAAW,QAAA,CAAS,MAAA,CAAO,OAAA,CAAQ,GAAG,CAAA;AAAA,MACxC,SAAS,GAAA,EAAK;AACZ,QAAA,QAAA,CAAS,GAAA,YAAe,QAAQ,GAAA,GAAM,IAAI,MAAM,MAAA,CAAO,GAAG,CAAC,CAAC,CAAA;AAC5D,QAAA,aAAA,CAAc,KAAK,CAAA;AAAA,MACrB;AAAA,IACF,CAAA;AAAA,IACA,CAAC,OAAO;AAAA,GACV;AAEA,EAAA,OAAO,EAAE,KAAA,EAAO,UAAA,EAAY,KAAA,EAAM;AACpC","file":"react.cjs","sourcesContent":["import { createContext, useContext, useEffect, type ReactNode } from \"react\";\nimport type { Rovenue } from \"../index\";\n\n// =============================================================\n// Provider\n// =============================================================\n//\n// The client is CONSTRUCTED BY THE HOST and passed in, rather than built from\n// options here. A provider that took `{ apiKey, apiUrl }` would rebuild the\n// SDK whenever those props were reconstructed — which, given they are usually\n// written as an inline object literal, is every render. That rebuilds the\n// identity, the cache handle and the event queue each time, and the queue's\n// unload listeners would accumulate.\n//\n// Making the host own the instance also means a non-React part of the same\n// app shares one SDK rather than running a second, competing queue.\n\nconst RovenueContext = createContext<Rovenue | null>(null);\n\nexport function RovenueProvider({\n client,\n children,\n}: {\n client: Rovenue;\n children: ReactNode;\n}) {\n useEffect(() => {\n // Event flushing is a browser concern, so it starts here rather than in\n // configure(): configure() also runs during server rendering, where\n // there is nothing to listen to and nothing to flush.\n client.startEventQueue();\n return () => client.stopEventQueue();\n }, [client]);\n\n return (\n <RovenueContext.Provider value={client}>{children}</RovenueContext.Provider>\n );\n}\n\nexport function useRovenue(): Rovenue {\n const client = useContext(RovenueContext);\n if (!client) {\n // React's own message for a missing context is \"undefined is not an\n // object\", which sends the reader looking in the wrong file.\n throw new Error(\n \"[rovenue] useRovenue() was called outside a <RovenueProvider>. Wrap \" +\n \"the tree that uses Rovenue hooks in one, passing the client you \" +\n \"created with configure().\",\n );\n }\n return client;\n}\n","import { useCallback, useEffect, useRef, useState } from \"react\";\nimport { useRovenue } from \"./provider\";\n\nexport interface EntitlementsState {\n /** null means \"not known yet\", never \"this person has nothing\". */\n entitlements: Record<string, unknown> | null;\n isLoading: boolean;\n error: Error | null;\n refresh: () => Promise<void>;\n}\n\n/**\n * Entitlements, served from cache first.\n *\n * The initial value is the SDK's last-known cache rather than null, because\n * the alternative is a first paint that shows the paywall to someone who has\n * already paid to remove it. The network result replaces it when it arrives.\n *\n * `null` and `{}` are deliberately different: `{}` is a real answer meaning\n * the subscriber has no entitlements, `null` means we have not found out.\n * Collapsing them makes an outage indistinguishable from an unsubscribed\n * user, and that difference decides whether an app locks someone out.\n */\nexport function useEntitlements(): EntitlementsState {\n const rovenue = useRovenue();\n const [entitlements, setEntitlements] = useState<Record<\n string,\n unknown\n > | null>(() => rovenue.getCachedEntitlements());\n const [isLoading, setIsLoading] = useState(true);\n const [error, setError] = useState<Error | null>(null);\n // Survives unmount: setting state on an unmounted component is a leak, and\n // in StrictMode's double-mount it is also a race.\n const alive = useRef(true);\n\n const load = useCallback(async () => {\n setIsLoading(true);\n try {\n const next = await rovenue.getEntitlements();\n if (!alive.current) return;\n setEntitlements(next);\n setError(null);\n } catch (err) {\n if (!alive.current) return;\n // The cached value is left in place. Replacing it with null on a\n // transient failure would flash the paywall at a paying subscriber.\n setError(err instanceof Error ? err : new Error(String(err)));\n } finally {\n if (alive.current) setIsLoading(false);\n }\n }, [rovenue]);\n\n useEffect(() => {\n alive.current = true;\n void load();\n return () => {\n alive.current = false;\n };\n // `load` is stable for a given client, so this runs once per client and\n // not once per render — the failure that turns an SDK into a request\n // storm in production while looking fine locally.\n }, [load]);\n\n return { entitlements, isLoading, error, refresh: load };\n}\n\nexport interface PlacementState {\n placement: unknown;\n isLoading: boolean;\n error: Error | null;\n}\n\n/**\n * A placement's served content (a funnel envelope, or nothing).\n *\n * An unknown placement returns an empty envelope rather than a 404, so this\n * resolves with nothing to show instead of erroring — the caller renders\n * nothing, which is the behaviour a placement that has not been configured\n * yet should have.\n */\nexport function usePlacement(identifier: string): PlacementState {\n const rovenue = useRovenue();\n const [placement, setPlacement] = useState<unknown>(null);\n const [isLoading, setIsLoading] = useState(true);\n const [error, setError] = useState<Error | null>(null);\n const alive = useRef(true);\n\n useEffect(() => {\n alive.current = true;\n setIsLoading(true);\n rovenue\n .getPlacement(identifier)\n .then((value) => {\n if (!alive.current) return;\n setPlacement(value);\n setError(null);\n })\n .catch((err: unknown) => {\n if (!alive.current) return;\n setError(err instanceof Error ? err : new Error(String(err)));\n })\n .finally(() => {\n if (alive.current) setIsLoading(false);\n });\n return () => {\n alive.current = false;\n };\n }, [rovenue, identifier]);\n\n return { placement, isLoading, error };\n}\n\nexport interface CheckoutState {\n /** Starts a checkout and redirects the browser to Stripe. */\n start: (input: {\n offeringIdentifier: string;\n packageIdentifier: string;\n successUrl: string;\n cancelUrl: string;\n idempotencyKey?: string;\n }) => Promise<void>;\n isStarting: boolean;\n error: Error | null;\n}\n\n/**\n * Starts a Stripe Checkout and sends the browser to it.\n *\n * `isStarting` exists so a caller can disable the button. A second click\n * before the first session comes back would otherwise create a second\n * session — which Stripe's idempotency only prevents if the caller passes\n * the same key, and a caller that has not thought about it will not.\n */\nexport function useCheckout(): CheckoutState {\n const rovenue = useRovenue();\n const [isStarting, setIsStarting] = useState(false);\n const [error, setError] = useState<Error | null>(null);\n\n const start = useCallback<CheckoutState[\"start\"]>(\n async (input) => {\n setIsStarting(true);\n setError(null);\n try {\n const session = await rovenue.checkout(input);\n // Assigning rather than replacing: the buyer must be able to come\n // back to the page they left with the browser's own back button.\n globalThis.location.assign(session.url);\n } catch (err) {\n setError(err instanceof Error ? err : new Error(String(err)));\n setIsStarting(false);\n }\n },\n [rovenue],\n );\n\n return { start, isStarting, error };\n}\n"]}