@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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Rovenue
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,9 @@
1
+ 'use strict';
2
+
3
+ var __defProp = Object.defineProperty;
4
+ var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;
5
+ var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value);
6
+
7
+ exports.__publicField = __publicField;
8
+ //# sourceMappingURL=chunk-4DE2IREA.cjs.map
9
+ //# sourceMappingURL=chunk-4DE2IREA.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":[],"names":[],"mappings":"","file":"chunk-4DE2IREA.cjs"}
@@ -0,0 +1,7 @@
1
+ var __defProp = Object.defineProperty;
2
+ var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;
3
+ var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value);
4
+
5
+ export { __publicField };
6
+ //# sourceMappingURL=chunk-V6TY7KAL.js.map
7
+ //# sourceMappingURL=chunk-V6TY7KAL.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":[],"names":[],"mappings":"","file":"chunk-V6TY7KAL.js"}
package/dist/index.cjs ADDED
@@ -0,0 +1,373 @@
1
+ 'use strict';
2
+
3
+ var chunk4DE2IREA_cjs = require('./chunk-4DE2IREA.cjs');
4
+ var cuid2 = require('@paralleldrive/cuid2');
5
+
6
+ // src/client.ts
7
+ var PLATFORM = "web";
8
+ var RovenueApiError = class extends Error {
9
+ constructor(status, code, message) {
10
+ super(message);
11
+ chunk4DE2IREA_cjs.__publicField(this, "status", status);
12
+ chunk4DE2IREA_cjs.__publicField(this, "code", code);
13
+ this.name = "RovenueApiError";
14
+ }
15
+ };
16
+ function createHttpClient(opts) {
17
+ const { apiUrl, publicKey, identity } = opts;
18
+ const doFetch = opts.fetchImpl ?? globalThis.fetch;
19
+ const baseUrl = `${apiUrl.replace(/\/+$/, "")}/v1/web/${publicKey}`;
20
+ function headers(extra) {
21
+ return {
22
+ Authorization: `Bearer ${publicKey}`,
23
+ // ALWAYS the rovenueId. Never the app scope from identify() — that one
24
+ // is client-local, and putting it here routes the request to an
25
+ // orphaned subscriber.
26
+ "x-rovenue-app-user-id": identity.rovenueId(),
27
+ // BOTH headers, as the Rust core sends. /v1/placements, /v1/config and
28
+ // /v1/experiments read the subscriber from `x-rovenue-user-id`, not
29
+ // from the app-user one. Sending only the latter left every web request
30
+ // anonymous: audience-targeted placement rows could never match (only
31
+ // an `audienceId: null` row can with no attributes), and a null
32
+ // subscriber forces the project holdout to 0 — so web traffic was
33
+ // silently excluded from the holdout and polluted the baseline.
34
+ "x-rovenue-user-id": identity.rovenueId(),
35
+ "x-rovenue-platform": PLATFORM,
36
+ "Content-Type": "application/json",
37
+ ...extra
38
+ };
39
+ }
40
+ async function unwrap(res) {
41
+ if (!res.ok) {
42
+ let code = "HTTP_ERROR";
43
+ let message = `Request failed with ${res.status}`;
44
+ try {
45
+ const body2 = await res.json();
46
+ if (body2?.error) {
47
+ code = body2.error.code;
48
+ message = body2.error.message;
49
+ }
50
+ } catch {
51
+ }
52
+ throw new RovenueApiError(res.status, code, message);
53
+ }
54
+ if (res.status === 204 || res.status === 202) return void 0;
55
+ const text = await res.text();
56
+ if (text === "") return void 0;
57
+ const body = JSON.parse(text);
58
+ return body.data;
59
+ }
60
+ return {
61
+ baseUrl,
62
+ async get(path) {
63
+ const res = await doFetch(`${baseUrl}${path}`, {
64
+ method: "GET",
65
+ headers: headers()
66
+ });
67
+ return unwrap(res);
68
+ },
69
+ async post(path, body, init) {
70
+ const res = await doFetch(`${baseUrl}${path}`, {
71
+ method: "POST",
72
+ headers: headers(
73
+ init?.idempotencyKey ? { "Idempotency-Key": init.idempotencyKey } : void 0
74
+ ),
75
+ body: JSON.stringify(body),
76
+ // Survives the page being unloaded, which is the whole reason the
77
+ // event queue can flush on pagehide at all. sendBeacon cannot be
78
+ // used here: it sets no headers, and this API authenticates from
79
+ // Authorization.
80
+ ...init?.keepalive ? { keepalive: true } : {}
81
+ });
82
+ return unwrap(res);
83
+ }
84
+ };
85
+ }
86
+ var ROVENUE_ID_KEY = "rovenue.rovenueId";
87
+ var EMAIL_SHAPED = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
88
+ function createIdentity(storage, warn = (m) => console.warn(m)) {
89
+ let cached = storage.get(ROVENUE_ID_KEY);
90
+ if (!cached) {
91
+ cached = cuid2.createId();
92
+ storage.set(ROVENUE_ID_KEY, cached);
93
+ }
94
+ let scope = null;
95
+ return {
96
+ rovenueId: () => cached,
97
+ appUserScope: () => scope,
98
+ identify(appUserId) {
99
+ if (EMAIL_SHAPED.test(appUserId)) {
100
+ warn(
101
+ "[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."
102
+ );
103
+ }
104
+ scope = appUserId;
105
+ },
106
+ logOut() {
107
+ scope = null;
108
+ cached = cuid2.createId();
109
+ storage.set(ROVENUE_ID_KEY, cached);
110
+ }
111
+ };
112
+ }
113
+
114
+ // src/storage.ts
115
+ function createMemoryStorage() {
116
+ const map = /* @__PURE__ */ new Map();
117
+ return {
118
+ get: (key) => map.get(key) ?? null,
119
+ set: (key, value) => {
120
+ map.set(key, value);
121
+ },
122
+ remove: (key) => {
123
+ map.delete(key);
124
+ }
125
+ };
126
+ }
127
+ var PROBE_KEY = "rovenue.__probe";
128
+ function createStorage() {
129
+ const memory = createMemoryStorage();
130
+ let backing = null;
131
+ try {
132
+ const candidate = globalThis.localStorage;
133
+ if (candidate) {
134
+ candidate.setItem(PROBE_KEY, "1");
135
+ candidate.removeItem(PROBE_KEY);
136
+ backing = candidate;
137
+ }
138
+ } catch {
139
+ backing = null;
140
+ }
141
+ if (!backing) return memory;
142
+ const store = backing;
143
+ return {
144
+ get(key) {
145
+ try {
146
+ return store.getItem(key) ?? memory.get(key);
147
+ } catch {
148
+ return memory.get(key);
149
+ }
150
+ },
151
+ set(key, value) {
152
+ try {
153
+ store.setItem(key, value);
154
+ } catch {
155
+ try {
156
+ store.removeItem(key);
157
+ } catch {
158
+ }
159
+ memory.set(key, value);
160
+ }
161
+ },
162
+ remove(key) {
163
+ try {
164
+ store.removeItem(key);
165
+ } catch {
166
+ }
167
+ memory.remove(key);
168
+ }
169
+ };
170
+ }
171
+
172
+ // src/cache.ts
173
+ var CACHE_KEY = "rovenue.entitlements";
174
+ function createEntitlementCache(storage, now = Date.now) {
175
+ return {
176
+ read() {
177
+ const raw = storage.get(CACHE_KEY);
178
+ if (!raw) return null;
179
+ try {
180
+ const parsed = JSON.parse(raw);
181
+ if (!parsed || typeof parsed !== "object" || typeof parsed.fetchedAt !== "number" || typeof parsed.entitlements !== "object" || parsed.entitlements === null) {
182
+ return null;
183
+ }
184
+ return parsed;
185
+ } catch {
186
+ return null;
187
+ }
188
+ },
189
+ write(entitlements) {
190
+ storage.set(
191
+ CACHE_KEY,
192
+ JSON.stringify({ entitlements, fetchedAt: now() })
193
+ );
194
+ },
195
+ clear() {
196
+ storage.remove(CACHE_KEY);
197
+ }
198
+ };
199
+ }
200
+ var QUEUE_KEY = "rovenue.events";
201
+ var EVENT_WIRE_VERSION = 1;
202
+ function createEventQueue(opts) {
203
+ const { storage, post } = opts;
204
+ const now = opts.now ?? (() => /* @__PURE__ */ new Date());
205
+ function read() {
206
+ const raw = storage.get(QUEUE_KEY);
207
+ if (!raw) return [];
208
+ try {
209
+ const parsed = JSON.parse(raw);
210
+ return Array.isArray(parsed) ? parsed : [];
211
+ } catch {
212
+ return [];
213
+ }
214
+ }
215
+ function write(events) {
216
+ const bounded = events.slice(-200);
217
+ storage.set(QUEUE_KEY, JSON.stringify(bounded));
218
+ }
219
+ let flushing = false;
220
+ let detach = null;
221
+ const queue = {
222
+ track(input) {
223
+ const event = {
224
+ ...input,
225
+ version: EVENT_WIRE_VERSION,
226
+ // Minted once, here. Reused verbatim on every retry so the server's
227
+ // dedup can recognise the replay.
228
+ eventId: cuid2.createId(),
229
+ occurredAt: input.occurredAt ?? now().toISOString()
230
+ };
231
+ write([...read(), event]);
232
+ },
233
+ async flush() {
234
+ if (flushing) return;
235
+ flushing = true;
236
+ try {
237
+ const pending = read();
238
+ if (pending.length === 0) return;
239
+ const retained = [];
240
+ for (const event of pending) {
241
+ let accepted = false;
242
+ try {
243
+ accepted = await post(event);
244
+ } catch {
245
+ accepted = false;
246
+ }
247
+ if (!accepted) retained.push(event);
248
+ }
249
+ const seen = new Set(pending.map((e) => e.eventId));
250
+ const arrivedDuringFlush = read().filter((e) => !seen.has(e.eventId));
251
+ write([...retained, ...arrivedDuringFlush]);
252
+ } finally {
253
+ flushing = false;
254
+ }
255
+ },
256
+ clear() {
257
+ write([]);
258
+ },
259
+ start() {
260
+ const doc = globalThis.document;
261
+ const win = globalThis.window;
262
+ if (!doc && !win) return;
263
+ const onHide = () => {
264
+ if (!doc || doc.visibilityState === "hidden") void queue.flush();
265
+ };
266
+ const onPageHide = () => {
267
+ void queue.flush();
268
+ };
269
+ doc?.addEventListener("visibilitychange", onHide);
270
+ win?.addEventListener("pagehide", onPageHide);
271
+ detach = () => {
272
+ doc?.removeEventListener("visibilitychange", onHide);
273
+ win?.removeEventListener("pagehide", onPageHide);
274
+ };
275
+ },
276
+ stop() {
277
+ detach?.();
278
+ detach = null;
279
+ }
280
+ };
281
+ return queue;
282
+ }
283
+
284
+ // src/index.ts
285
+ function configure(options) {
286
+ if (!options.apiKey) throw new Error("[rovenue] configure() needs an apiKey");
287
+ if (!options.apiUrl) throw new Error("[rovenue] configure() needs an apiUrl");
288
+ const storage = options.storage ?? createStorage();
289
+ const identity = createIdentity(storage);
290
+ const cache = createEntitlementCache(storage);
291
+ const http = createHttpClient({
292
+ apiUrl: options.apiUrl,
293
+ publicKey: options.apiKey,
294
+ identity,
295
+ fetchImpl: options.fetchImpl
296
+ });
297
+ const events = createEventQueue({
298
+ storage,
299
+ post: async (event) => {
300
+ try {
301
+ await http.post("/events", event, { keepalive: true });
302
+ return true;
303
+ } catch (err) {
304
+ if (!(err instanceof RovenueApiError)) return false;
305
+ return err.status < 500 && err.status !== 429;
306
+ }
307
+ }
308
+ });
309
+ return {
310
+ http,
311
+ identity,
312
+ rovenueId: () => identity.rovenueId(),
313
+ identify: (appUserId) => identity.identify(appUserId),
314
+ logOut: async () => {
315
+ await events.flush().catch(() => void 0);
316
+ events.clear();
317
+ cache.clear();
318
+ identity.logOut();
319
+ },
320
+ async getEntitlements() {
321
+ try {
322
+ const data = await http.get("/me/entitlements");
323
+ cache.write(data.entitlements);
324
+ return data.entitlements;
325
+ } catch (err) {
326
+ const status = err instanceof RovenueApiError ? err.status : null;
327
+ const serveFromCache = status === null || status >= 500 || status === 429;
328
+ if (!serveFromCache) throw err;
329
+ const cached = cache.read();
330
+ if (cached) return cached.entitlements;
331
+ throw err;
332
+ }
333
+ },
334
+ track: (input) => events.track(input),
335
+ flushEvents: () => events.flush(),
336
+ startEventQueue: () => events.start(),
337
+ stopEventQueue: () => events.stop(),
338
+ getCachedEntitlements: () => cache.read()?.entitlements ?? null,
339
+ getCachedEntitlementsEntry: () => cache.read(),
340
+ async recordExposure({ experimentId, variantId, placementId }) {
341
+ await http.post(`/experiments/${encodeURIComponent(experimentId)}/expose`, {
342
+ variantId,
343
+ subscriberId: identity.rovenueId(),
344
+ ...placementId ? { placementId } : {},
345
+ platform: "web"
346
+ // No exposedAt. The native path omits it too, and the server
347
+ // stamps its own clock — deliberately, because ClickHouse
348
+ // partitions and TTLs `raw_exposures` on this column and revenue
349
+ // attribution compares `eventDate >= min(exposedAt)`. A device
350
+ // clock a day fast makes that visitor a permanent non-converter
351
+ // (their purchases predate their own exposure); one set in the past
352
+ // over-attributes pre-experiment revenue; one two years slow has
353
+ // the row TTL-deleted out of the denominator entirely.
354
+ }).catch(() => {
355
+ });
356
+ },
357
+ getOfferings: () => http.get("/offerings"),
358
+ // An unknown placement returns an empty envelope rather than a 404, so
359
+ // this resolves rather than throwing — the caller renders nothing.
360
+ // Encoded: an identifier carrying `/`, `?` or `#` would otherwise change
361
+ // the path or truncate itself into a query string, and the server would
362
+ // answer for a different (or no) placement.
363
+ getPlacement: (identifier) => http.get(`/placements/${encodeURIComponent(identifier)}`),
364
+ checkout: ({ idempotencyKey, ...body }) => http.post("/checkout", body, { idempotencyKey })
365
+ };
366
+ }
367
+
368
+ exports.RovenueApiError = RovenueApiError;
369
+ exports.configure = configure;
370
+ exports.createMemoryStorage = createMemoryStorage;
371
+ exports.createStorage = createStorage;
372
+ //# sourceMappingURL=index.cjs.map
373
+ //# sourceMappingURL=index.cjs.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":["__publicField","body","createId"],"mappings":"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","file":"index.cjs","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"]}