styra-js 1.5.2

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.
@@ -0,0 +1,141 @@
1
+ export type StyraMode = "cs" | "ss";
2
+ export interface StyraConfig {
3
+ apiKey: string;
4
+ mode: StyraMode;
5
+ /**
6
+ * Cache TTL in milliseconds. Only applies in CS mode.
7
+ * Defaults to 5 minutes (300_000 ms).
8
+ * Ignored silently in SS mode.
9
+ */
10
+ cacheTTL?: number;
11
+ }
12
+ /** One entry in the status-code error handler registry */
13
+ type StatusHandler = (statusCode: number, url: string) => void;
14
+ export declare class StyraAB {
15
+ private readonly apiKey;
16
+ private readonly mode;
17
+ private readonly cacheTTL;
18
+ private readonly baseURL;
19
+ private readonly getSid;
20
+ constructor(apiKey: string, mode: StyraMode, cacheTTL: number, baseURL: string, getSid: () => string);
21
+ /**
22
+ * Fetch the version assigned to this user for a named A/B test.
23
+ *
24
+ * @param abTestName — the test identifier sent to the backend as a query param
25
+ * @param defaultValue — returned on any error or when paused without cache
26
+ * @param revokeAfter — ms before the request is aborted (default 3000)
27
+ *
28
+ * CS mode: lazy validity-expiry sweep → pause check → cache check.
29
+ * If cache is FRESH (within cacheTTL) → return it directly,
30
+ * NO API call is made at all.
31
+ * Only when cache is stale or absent do we actually call the
32
+ * API — and only then do we send `isCachedLS` (true = a stale
33
+ * entry existed, false = nothing existed at all) so backend
34
+ * can tell a "re-fetch of an expired assignment" apart from a
35
+ * genuinely new user/request for abversion counting.
36
+ * SS mode: always fetches fresh (isCachedLS always false)
37
+ */
38
+ getVersion<T = unknown>(abTestName: string, defaultValue?: T, revokeAfter?: number): Promise<T | null>;
39
+ /**
40
+ * Track an event against a named A/B test.
41
+ * Always unique per user — fires at most once per abTestName, ever
42
+ * (until that test's validity window + grace period expires).
43
+ *
44
+ * @param abTestName — the test identifier sent as a query param.
45
+ * No `revokeAfter` here — this method does not
46
+ * accept a custom timeout, only the test name.
47
+ *
48
+ * Decision order:
49
+ * 1. Lazy validity sweep (clears stale cache + tracked key if expired)
50
+ * 2. AB-cache presence check — we must already have a known/assigned
51
+ * variant value (e.g. "dark_theme") stored for this test. If the
52
+ * cache entry is ABSENT, we don't track at all — tracking a test
53
+ * the user was never actually assigned a cached variant for would
54
+ * pollute/degrade the numbers.
55
+ * 3. Already-tracked check — if the tracked-event key is already
56
+ * present, skip (always-unique, no API call).
57
+ * 4. Pause check → API call, mark tracked-key on success.
58
+ *
59
+ * Both (2) AND (3) must pass — cache present AND not-yet-tracked —
60
+ * before any network call is made.
61
+ */
62
+ track(abTestName: string): Promise<"OK" | "NOT OK">;
63
+ }
64
+ export declare class StyraEvent {
65
+ private readonly apiKey;
66
+ private readonly mode;
67
+ private readonly baseURL;
68
+ private readonly getSid;
69
+ constructor(apiKey: string, mode: StyraMode, baseURL: string, getSid: () => string);
70
+ /**
71
+ * Fire a named custom event — not tied to any A/B test.
72
+ *
73
+ * @param eventName — sent as a query param to /track-custom-event
74
+ * @param unique — if true, only fires once per session per eventName
75
+ *
76
+ * Decision order: unique check → pause check → API call
77
+ */
78
+ track(eventName: string, unique?: boolean): Promise<"OK" | "NOT OK">;
79
+ }
80
+ export declare class Styra {
81
+ private readonly apiKey;
82
+ private readonly mode;
83
+ private readonly cacheTTL;
84
+ private readonly baseURL;
85
+ /** styra.ab.getVersion() · styra.ab.track() */
86
+ readonly ab: StyraAB;
87
+ /** styra.event.track() */
88
+ readonly event: StyraEvent;
89
+ /** Override or extend status-code handlers at runtime */
90
+ readonly statusHandlers: Record<number, StatusHandler>;
91
+ private static readonly DEFAULT_BASE_URL;
92
+ constructor(config: StyraConfig);
93
+ private runInitChecks;
94
+ private getSid;
95
+ /**
96
+ * Fetch a remote config variable by key.
97
+ * Returns defaultValue (or null) on any error — never throws.
98
+ *
99
+ * @param key — variable identifier, sent as query param
100
+ * @param defaultValue — returned on error, pause without cache, or missing value
101
+ * @param revokeAfter — ms before the request is aborted (default 3000)
102
+ *
103
+ * CS mode: pause check → cache check → fetch → cache write
104
+ * SS mode: always fetches fresh
105
+ *
106
+ * Note: unlike ab.getVersion, this does NOT send isCachedLS and is not
107
+ * subject to AB validity-expiry — that logic is AB-specific.
108
+ */
109
+ getVariable<T = unknown>(key: string, defaultValue?: T, revokeAfter?: number): Promise<T | null>;
110
+ /**
111
+ * Evict a single variable key from the cache.
112
+ * No-op in SS mode.
113
+ */
114
+ evictCache(key: string): void;
115
+ /**
116
+ * Clear all Styra variable and A/B cache entries.
117
+ * Does NOT touch sid, events, paused, or meta.
118
+ * No-op in SS mode.
119
+ */
120
+ clearAllCache(): void;
121
+ /**
122
+ * Clear the stored SID.
123
+ * Next request will send sid: "none" and receive a fresh SID from the backend.
124
+ * Useful for logout flows.
125
+ * No-op in SS mode.
126
+ */
127
+ clearSID(): void;
128
+ /**
129
+ * Clear the unique events registry.
130
+ * After this, all unique events (ab.track + event.track) can fire again.
131
+ * No-op in SS mode.
132
+ */
133
+ clearEvents(): void;
134
+ /**
135
+ * Manually remove a specific endpoint from the paused registry.
136
+ * The next call to that endpoint will go through to the API immediately.
137
+ * No-op in SS mode.
138
+ */
139
+ unpause(endpoint: string): void;
140
+ }
141
+ export {};
package/dist/client.js ADDED
@@ -0,0 +1,895 @@
1
+ "use strict";
2
+ // =============================================================================
3
+ // Styra SDK v1.5.2
4
+ // Supports Client-Side (cs) and Server-Side (ss) modes.
5
+ //
6
+ // CS mode : localStorage cache, XOR encoded with (apiKey + hostname),
7
+ // SID via header, pause registry, unique event tracking,
8
+ // 15-day auto-renewal, server-instruction handling,
9
+ // per-abTest validity-based cache expiry
10
+ //
11
+ // SS mode : no cache, no localStorage, no SID — pure stateless fetch
12
+ //
13
+ // Public API surface:
14
+ // styra.getVariable(key, default?, revokeAfter?) — remote config value
15
+ // styra.ab.getVersion(abTestName, default?, revokeAfter?) — assigned A/B version
16
+ // styra.ab.track(abTestName) — track an A/B event (always unique per user)
17
+ // styra.event.track(eventName, unique?) — track a custom event
18
+ //
19
+ // All methods are silent on errors — devs get [Styra] console logs,
20
+ // end users never see a crash or an undefined.
21
+ //
22
+ // ── v1.5.2 changes ───────────────────────────────────────────────────────────
23
+ // 1. ab.getVersion: if a FRESH cache entry exists (within cacheTTL), it is
24
+ // returned directly — NO API call is made at all. Only when the cache is
25
+ // stale or absent does the SDK actually call the backend, and only then
26
+ // does it send `isCachedLS: true/false` — true = a stale entry existed
27
+ // locally (re-fetch of an expired assignment), false = nothing existed at
28
+ // all (genuinely new request — backend should count a new "abversion").
29
+ // 2. ab.getVersion now stores the backend-provided `validity` (timestamp)
30
+ // alongside the cached AB value. Both ab.getVersion and ab.track lazily
31
+ // check this validity (+2 day grace) before touching that test's stored
32
+ // data, and wipe it out (cache + tracked-event key) if expired.
33
+ // 3. ab.track no longer accepts a `unique` flag — it is now ALWAYS unique
34
+ // per user (tracked-event key check is unconditional). It also now takes
35
+ // a SINGLE parameter (abTestName only, no revokeAfter) and additionally
36
+ // requires a fresh/known AB-cache entry to exist for that test before it
37
+ // will track at all — tracking without a known assigned variant would
38
+ // pollute the numbers, so it's skipped silently in that case.
39
+ // 4. getVariable and ab.getVersion now accept a `revokeAfter` ms param
40
+ // (default 3000ms) — the underlying HTTP request is aborted if it takes
41
+ // longer than this. ab.track does NOT have a revokeAfter param.
42
+ // =============================================================================
43
+ Object.defineProperty(exports, "__esModule", { value: true });
44
+ exports.Styra = exports.StyraEvent = exports.StyraAB = void 0;
45
+ // ─────────────────────────────────────────────
46
+ // Constants
47
+ // ─────────────────────────────────────────────
48
+ const SDK_VERSION = "3.2.0";
49
+ const CACHE_FORMAT_VERSION = 1;
50
+ const DEFAULT_TTL_MS = 5 * 60 * 1000; // 5 minutes
51
+ const RENEWAL_TTL_MS = 15 * 24 * 60 * 60 * 1000; // 15 days
52
+ const PAUSE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
53
+ const VALIDITY_GRACE_MS = 2 * 24 * 60 * 60 * 1000; // 2 days grace after validity
54
+ const DEFAULT_REVOKE_AFTER = 3000; // 3s default request timeout
55
+ const LOG_PREFIX = "[Styra]";
56
+ const SID_ABSENT = "none";
57
+ // ─────────────────────────────────────────────
58
+ // localStorage key map
59
+ // Every styra:: key lives here — no magic strings elsewhere.
60
+ // ─────────────────────────────────────────────
61
+ const LS = {
62
+ META: "styra::meta",
63
+ SID: "styra::sid",
64
+ EVENTS: "styra::events",
65
+ PAUSED: "styra::paused",
66
+ AB: (abTestName) => `styra::ab::${abTestName}`,
67
+ CACHE: (key) => `styra::cache::${key}`,
68
+ };
69
+ // ─────────────────────────────────────────────
70
+ // Logger
71
+ // ─────────────────────────────────────────────
72
+ const log = {
73
+ info: (msg, ...a) => console.info(`${LOG_PREFIX} ${msg}`, ...a),
74
+ warn: (msg, ...a) => console.warn(`${LOG_PREFIX} ⚠️ ${msg}`, ...a),
75
+ error: (msg, ...a) => console.error(`${LOG_PREFIX} ❌ ${msg}`, ...a),
76
+ debug: (msg, ...a) => console.debug(`${LOG_PREFIX} 🔍 ${msg}`, ...a),
77
+ };
78
+ // ─────────────────────────────────────────────
79
+ // XOR encode / decode
80
+ //
81
+ // XOR key = apiKey + hostname (concatenated).
82
+ // A stolen apiKey alone cannot decode anything stored in localStorage
83
+ // because the hostname half of the key is not in the bundle — it comes
84
+ // from the runtime environment.
85
+ // ─────────────────────────────────────────────
86
+ function buildXorKey(apiKey) {
87
+ const hostname = typeof window !== "undefined" ? window.location.hostname : "";
88
+ return apiKey + hostname;
89
+ }
90
+ function xorEncode(xorKey, payload) {
91
+ const json = JSON.stringify(payload);
92
+ let out = "";
93
+ for (let i = 0; i < json.length; i++) {
94
+ const xored = json.charCodeAt(i) ^ xorKey.charCodeAt(i % xorKey.length);
95
+ out += xored.toString(16).padStart(4, "0");
96
+ }
97
+ return out;
98
+ }
99
+ function xorDecode(xorKey, encoded) {
100
+ try {
101
+ let out = "";
102
+ for (let i = 0; i < encoded.length; i += 4) {
103
+ const charCode = parseInt(encoded.slice(i, i + 4), 16) ^
104
+ xorKey.charCodeAt((i / 4) % xorKey.length);
105
+ out += String.fromCharCode(charCode);
106
+ }
107
+ return JSON.parse(out);
108
+ }
109
+ catch {
110
+ return null;
111
+ }
112
+ }
113
+ // ─────────────────────────────────────────────
114
+ // Status-code error handler registry
115
+ // Advanced users can add / override via styra.statusHandlers[code] = fn
116
+ // ─────────────────────────────────────────────
117
+ const STATUS_HANDLERS = {
118
+ 400: (c, u) => log.error(`Bad request (${c}) — check query params. URL: ${u}`),
119
+ 401: (c, u) => log.error(`Unauthorized (${c}) — API key may be invalid. URL: ${u}`),
120
+ 403: (c, u) => log.error(`Forbidden (${c}) — API key lacks access. URL: ${u}`),
121
+ 404: (c, u) => log.warn(`Not found (${c}) — resource doesn't exist. URL: ${u}`),
122
+ 411: (c, u) => log.error(`Length required (${c}) — missing Content-Length. URL: ${u}`),
123
+ 429: (c, u) => log.warn(`Rate limited (${c}) — consider increasing cacheTTL. URL: ${u}`),
124
+ 500: (c, u) => log.error(`Internal server error (${c}) — Styra backend issue. URL: ${u}`),
125
+ 502: (c, u) => log.error(`Bad gateway (${c}) — Styra may be down. URL: ${u}`),
126
+ 503: (c, u) => log.error(`Service unavailable (${c}) — Styra temporarily down. URL: ${u}`),
127
+ };
128
+ function dispatchStatusError(statusCode, url) {
129
+ const handler = STATUS_HANDLERS[statusCode];
130
+ if (handler) {
131
+ handler(statusCode, url);
132
+ }
133
+ else {
134
+ log.error(`Unexpected HTTP ${statusCode} from ${url}`);
135
+ }
136
+ }
137
+ // ─────────────────────────────────────────────
138
+ // localStorage primitives
139
+ // ─────────────────────────────────────────────
140
+ function lsGet(key) {
141
+ try {
142
+ return localStorage.getItem(key);
143
+ }
144
+ catch {
145
+ return null;
146
+ }
147
+ }
148
+ function lsSet(key, value) {
149
+ try {
150
+ localStorage.setItem(key, value);
151
+ }
152
+ catch (err) {
153
+ log.warn(`localStorage write failed for "${key}": ${err}`);
154
+ }
155
+ }
156
+ function lsRemove(key) {
157
+ try {
158
+ localStorage.removeItem(key);
159
+ }
160
+ catch { /* silent */ }
161
+ }
162
+ function lsGetJson(key) {
163
+ const raw = lsGet(key);
164
+ if (!raw)
165
+ return null;
166
+ try {
167
+ return JSON.parse(raw);
168
+ }
169
+ catch {
170
+ return null;
171
+ }
172
+ }
173
+ function lsSetJson(key, value) {
174
+ try {
175
+ lsSet(key, JSON.stringify(value));
176
+ }
177
+ catch (err) {
178
+ log.warn(`JSON stringify failed for "${key}": ${err}`);
179
+ }
180
+ }
181
+ // ─────────────────────────────────────────────
182
+ // Meta — global session clock + encoded domain
183
+ // ─────────────────────────────────────────────
184
+ function readMeta() {
185
+ return lsGetJson(LS.META);
186
+ }
187
+ function writeMeta(apiKey) {
188
+ const hostname = typeof window !== "undefined" ? window.location.hostname : "";
189
+ const entry = {
190
+ issuedAt: Date.now(),
191
+ _d: xorEncode(buildXorKey(apiKey), hostname),
192
+ };
193
+ lsSetJson(LS.META, entry);
194
+ }
195
+ function decodedDomain(apiKey, encoded) {
196
+ const v = xorDecode(buildXorKey(apiKey), encoded);
197
+ return typeof v === "string" ? v : null;
198
+ }
199
+ // ─────────────────────────────────────────────
200
+ // SID
201
+ // ─────────────────────────────────────────────
202
+ function readSid(apiKey) {
203
+ const entry = lsGetJson(LS.SID);
204
+ if (!entry)
205
+ return SID_ABSENT;
206
+ if (entry.validity && Date.now() > new Date(entry.validity).getTime()) {
207
+ lsRemove(LS.SID);
208
+ log.debug("SID validity date passed — requesting a new one.");
209
+ return SID_ABSENT;
210
+ }
211
+ const v = xorDecode(buildXorKey(apiKey), entry._v);
212
+ return typeof v === "string" ? v : SID_ABSENT;
213
+ }
214
+ function writeSid(apiKey, sid, validity) {
215
+ const entry = {
216
+ _v: xorEncode(buildXorKey(apiKey), sid),
217
+ issuedAt: Date.now(),
218
+ validity: validity !== null && validity !== void 0 ? validity : "",
219
+ };
220
+ lsSetJson(LS.SID, entry);
221
+ log.debug("SID stored.");
222
+ }
223
+ // ─────────────────────────────────────────────
224
+ // Pause registry
225
+ // ─────────────────────────────────────────────
226
+ function isPaused(endpoint) {
227
+ var _a;
228
+ const reg = (_a = lsGetJson(LS.PAUSED)) !== null && _a !== void 0 ? _a : {};
229
+ const entry = reg[endpoint];
230
+ if (!entry)
231
+ return false;
232
+ return Date.now() - entry.pausedAt < PAUSE_TTL_MS;
233
+ }
234
+ function setPaused(endpoint) {
235
+ var _a;
236
+ const reg = (_a = lsGetJson(LS.PAUSED)) !== null && _a !== void 0 ? _a : {};
237
+ reg[endpoint] = { pausedAt: Date.now() };
238
+ lsSetJson(LS.PAUSED, reg);
239
+ log.debug(`Endpoint "${endpoint}" paused for 24 hr.`);
240
+ }
241
+ function clearPausedEntry(endpoint) {
242
+ var _a;
243
+ const reg = (_a = lsGetJson(LS.PAUSED)) !== null && _a !== void 0 ? _a : {};
244
+ if (reg[endpoint]) {
245
+ delete reg[endpoint];
246
+ lsSetJson(LS.PAUSED, reg);
247
+ log.debug(`Endpoint "${endpoint}" unpaused.`);
248
+ }
249
+ }
250
+ // ─────────────────────────────────────────────
251
+ // Unique event registry
252
+ // ─────────────────────────────────────────────
253
+ function hasEventFired(eventName) {
254
+ var _a;
255
+ const reg = (_a = lsGetJson(LS.EVENTS)) !== null && _a !== void 0 ? _a : {};
256
+ return reg[eventName] === true;
257
+ }
258
+ function markEventFired(eventName) {
259
+ var _a;
260
+ const reg = (_a = lsGetJson(LS.EVENTS)) !== null && _a !== void 0 ? _a : {};
261
+ reg[eventName] = true;
262
+ lsSetJson(LS.EVENTS, reg);
263
+ log.debug(`Unique event "${eventName}" marked as fired.`);
264
+ }
265
+ function clearEventKey(eventName) {
266
+ var _a;
267
+ const reg = (_a = lsGetJson(LS.EVENTS)) !== null && _a !== void 0 ? _a : {};
268
+ if (reg[eventName] !== undefined) {
269
+ delete reg[eventName];
270
+ lsSetJson(LS.EVENTS, reg);
271
+ log.debug(`Tracked-event key "${eventName}" cleared.`);
272
+ }
273
+ }
274
+ // ─────────────────────────────────────────────
275
+ // Cache (plain getVariable cache — no validity concept)
276
+ // ─────────────────────────────────────────────
277
+ function readCache(apiKey, lsKey, ttl) {
278
+ const raw = lsGet(lsKey);
279
+ if (!raw)
280
+ return null;
281
+ try {
282
+ const entry = JSON.parse(raw);
283
+ if (!entry || entry.v !== CACHE_FORMAT_VERSION) {
284
+ lsRemove(lsKey);
285
+ return null;
286
+ }
287
+ if (Date.now() - entry.ts > ttl) {
288
+ lsRemove(lsKey);
289
+ log.debug(`Cache expired for "${lsKey}"`);
290
+ return null;
291
+ }
292
+ log.debug(`Cache hit for "${lsKey}"`);
293
+ return xorDecode(buildXorKey(apiKey), entry.data);
294
+ }
295
+ catch {
296
+ return null;
297
+ }
298
+ }
299
+ function writeCache(apiKey, lsKey, data) {
300
+ const entry = {
301
+ v: CACHE_FORMAT_VERSION,
302
+ ts: Date.now(),
303
+ data: xorEncode(buildXorKey(apiKey), data),
304
+ };
305
+ lsSetJson(lsKey, entry);
306
+ }
307
+ // ─────────────────────────────────────────────
308
+ // AB cache (validity-aware — separate from plain cache above)
309
+ // ─────────────────────────────────────────────
310
+ /**
311
+ * Read the raw AB cache entry without any TTL/validity filtering.
312
+ * Used internally to check existence (for isCachedLS) before deciding
313
+ * whether to treat this as a fresh request.
314
+ */
315
+ function readAbCacheRaw(lsKey) {
316
+ const raw = lsGet(lsKey);
317
+ if (!raw)
318
+ return null;
319
+ try {
320
+ const entry = JSON.parse(raw);
321
+ if (!entry || entry.v !== CACHE_FORMAT_VERSION) {
322
+ lsRemove(lsKey);
323
+ return null;
324
+ }
325
+ return entry;
326
+ }
327
+ catch {
328
+ return null;
329
+ }
330
+ }
331
+ /** Decode + TTL-check an AB cache entry. Returns null if missing/expired by TTL. */
332
+ function readAbCache(apiKey, lsKey, ttl) {
333
+ const entry = readAbCacheRaw(lsKey);
334
+ if (!entry)
335
+ return null;
336
+ if (Date.now() - entry.ts > ttl) {
337
+ log.debug(`AB cache TTL-expired for "${lsKey}"`);
338
+ return null;
339
+ }
340
+ log.debug(`AB cache hit for "${lsKey}"`);
341
+ return xorDecode(buildXorKey(apiKey), entry.data);
342
+ }
343
+ function writeAbCache(apiKey, lsKey, data, validity) {
344
+ const entry = {
345
+ v: CACHE_FORMAT_VERSION,
346
+ ts: Date.now(),
347
+ data: xorEncode(buildXorKey(apiKey), data),
348
+ validity,
349
+ };
350
+ lsSetJson(lsKey, entry);
351
+ }
352
+ /**
353
+ * Lazily check whether this abTestName's stored data (cache entry +
354
+ * tracked-event key) has passed its backend-issued validity date
355
+ * (+ grace period). If so, wipe both so the test is treated as fresh.
356
+ *
357
+ * Called at the top of both ab.getVersion and ab.track before touching
358
+ * any stored state for that abTestName.
359
+ */
360
+ function expireAbTestIfStale(abTestName) {
361
+ const lsKey = LS.AB(abTestName);
362
+ const entry = readAbCacheRaw(lsKey);
363
+ if (!entry || !entry.validity)
364
+ return;
365
+ const validityMs = new Date(entry.validity).getTime();
366
+ if (Number.isNaN(validityMs))
367
+ return;
368
+ const expiresAt = validityMs + VALIDITY_GRACE_MS;
369
+ if (Date.now() <= expiresAt)
370
+ return;
371
+ // Past validity + grace — wipe cache and tracked-event key for this test.
372
+ lsRemove(lsKey);
373
+ clearEventKey(`ab::${abTestName}`);
374
+ log.debug(`AB test "${abTestName}" past validity (+2d grace) — cache & tracked key cleared.`);
375
+ }
376
+ // ─────────────────────────────────────────────
377
+ // Reset helpers
378
+ // ─────────────────────────────────────────────
379
+ /**
380
+ * Renewal reset — clears everything EXCEPT styra::paused.
381
+ * Used for 15-day renewal and domain change.
382
+ */
383
+ function renewalReset(apiKey, reason) {
384
+ try {
385
+ Object.keys(localStorage)
386
+ .filter((k) => k.startsWith("styra::") && k !== LS.PAUSED)
387
+ .forEach(lsRemove);
388
+ }
389
+ catch { /* localStorage inaccessible */ }
390
+ writeMeta(apiKey);
391
+ log.info(`Session reset — ${reason}. Paused registry preserved.`);
392
+ }
393
+ /**
394
+ * Full reset — clears everything including styra::paused.
395
+ * Only triggered by act: "clcLocal" from the backend.
396
+ */
397
+ function fullReset(apiKey) {
398
+ try {
399
+ Object.keys(localStorage)
400
+ .filter((k) => k.startsWith("styra::"))
401
+ .forEach(lsRemove);
402
+ }
403
+ catch { /* silent */ }
404
+ writeMeta(apiKey);
405
+ log.debug("Full session reset by server instruction (act: clcLocal).");
406
+ }
407
+ // ─────────────────────────────────────────────
408
+ // Core HTTP helper
409
+ //
410
+ // Supports an abortable timeout (`revokeAfter` ms). If the request hasn't
411
+ // resolved within that window, it's aborted and treated as a network
412
+ // failure (same silent-failure contract as any other network error).
413
+ // ─────────────────────────────────────────────
414
+ async function httpGet(url, apiKey, sid, params, revokeAfter = DEFAULT_REVOKE_AFTER) {
415
+ const fullURL = params
416
+ ? `${url}?${new URLSearchParams(params).toString()}`
417
+ : url;
418
+ const controller = new AbortController();
419
+ const timeoutId = setTimeout(() => controller.abort(), revokeAfter);
420
+ try {
421
+ const res = await globalThis.fetch(fullURL, {
422
+ method: "GET",
423
+ headers: { "x-api-key": apiKey, "sid": sid },
424
+ signal: controller.signal,
425
+ });
426
+ let data = null;
427
+ try {
428
+ data = (await res.json());
429
+ }
430
+ catch { /* non-JSON body */ }
431
+ if (!res.ok)
432
+ dispatchStatusError(res.status, fullURL);
433
+ return { ok: res.ok, status: res.status, data };
434
+ }
435
+ catch (err) {
436
+ if (err instanceof Error && err.name === "AbortError") {
437
+ log.warn(`Request to ${fullURL} aborted — exceeded revokeAfter (${revokeAfter}ms).`);
438
+ }
439
+ else {
440
+ log.error(`Network error fetching ${fullURL}: ${err}`);
441
+ }
442
+ return { ok: false, status: 0, data: null };
443
+ }
444
+ finally {
445
+ clearTimeout(timeoutId);
446
+ }
447
+ }
448
+ // ─────────────────────────────────────────────
449
+ // Response processor — strict execution order
450
+ //
451
+ // Order matters:
452
+ // 1. act: clcLocal → wipe everything first so nothing written after gets cleared
453
+ // 2. sid → store new SID
454
+ // 3. p → update pause registry
455
+ // 4. cache write → only on success, CS mode (plain getVariable cache)
456
+ // 5. ab cache write → only on success, CS mode (validity-aware AB cache)
457
+ // 6. unique event → only on success
458
+ // ─────────────────────────────────────────────
459
+ function processResponse(data, apiKey, mode, opts) {
460
+ // 1. Server-instructed full reset
461
+ if (data.act === "clcLocal") {
462
+ fullReset(apiKey);
463
+ }
464
+ // 2. New SID from backend
465
+ if (data.sid) {
466
+ writeSid(apiKey, data.sid);
467
+ }
468
+ // 3. Pause signal
469
+ if (data.p === true) {
470
+ setPaused(opts.endpoint);
471
+ }
472
+ else if (data.p === false) {
473
+ clearPausedEntry(opts.endpoint);
474
+ }
475
+ // p absent → no change
476
+ // 4/5. Cache write (CS mode, success only)
477
+ if (data.success &&
478
+ mode === "cs" &&
479
+ opts.cacheKey !== undefined &&
480
+ opts.cacheTTL !== undefined &&
481
+ data.value !== undefined &&
482
+ data.value !== null) {
483
+ if (opts.isAbCache) {
484
+ writeAbCache(apiKey, opts.cacheKey, data.value, data.validity);
485
+ }
486
+ else {
487
+ writeCache(apiKey, opts.cacheKey, data.value);
488
+ }
489
+ }
490
+ // 6. Mark unique event as fired (success only)
491
+ if (data.success && opts.unique && opts.eventName) {
492
+ markEventFired(opts.eventName);
493
+ }
494
+ }
495
+ // ─────────────────────────────────────────────
496
+ // StyraAB — styra.ab namespace
497
+ // ─────────────────────────────────────────────
498
+ class StyraAB {
499
+ constructor(apiKey, mode, cacheTTL, baseURL, getSid) {
500
+ this.apiKey = apiKey;
501
+ this.mode = mode;
502
+ this.cacheTTL = cacheTTL;
503
+ this.baseURL = baseURL;
504
+ this.getSid = getSid;
505
+ }
506
+ /**
507
+ * Fetch the version assigned to this user for a named A/B test.
508
+ *
509
+ * @param abTestName — the test identifier sent to the backend as a query param
510
+ * @param defaultValue — returned on any error or when paused without cache
511
+ * @param revokeAfter — ms before the request is aborted (default 3000)
512
+ *
513
+ * CS mode: lazy validity-expiry sweep → pause check → cache check.
514
+ * If cache is FRESH (within cacheTTL) → return it directly,
515
+ * NO API call is made at all.
516
+ * Only when cache is stale or absent do we actually call the
517
+ * API — and only then do we send `isCachedLS` (true = a stale
518
+ * entry existed, false = nothing existed at all) so backend
519
+ * can tell a "re-fetch of an expired assignment" apart from a
520
+ * genuinely new user/request for abversion counting.
521
+ * SS mode: always fetches fresh (isCachedLS always false)
522
+ */
523
+ async getVersion(abTestName, defaultValue, revokeAfter = DEFAULT_REVOKE_AFTER) {
524
+ var _a, _b;
525
+ if (!this.apiKey)
526
+ return defaultValue !== null && defaultValue !== void 0 ? defaultValue : null;
527
+ const endpoint = "ab-test";
528
+ const cacheKey = LS.AB(abTestName);
529
+ // Lazy expiry sweep for this specific test before doing anything else
530
+ if (this.mode === "cs") {
531
+ expireAbTestIfStale(abTestName);
532
+ }
533
+ // Pause check (CS only) — still respects existing cache if present
534
+ if (this.mode === "cs" && isPaused(endpoint)) {
535
+ log.debug(`ab.getVersion("${abTestName}") skipped — endpoint paused.`);
536
+ const cached = readAbCache(this.apiKey, cacheKey, this.cacheTTL);
537
+ return (_b = (_a = cached) !== null && _a !== void 0 ? _a : defaultValue) !== null && _b !== void 0 ? _b : null;
538
+ }
539
+ // Cache check (CS only) — fresh cache short-circuits, no API call at all.
540
+ let isCachedLS = false;
541
+ if (this.mode === "cs") {
542
+ const cached = readAbCache(this.apiKey, cacheKey, this.cacheTTL);
543
+ if (cached !== null) {
544
+ // Fresh hit — return immediately, never touch the network.
545
+ return cached;
546
+ }
547
+ // Cache was either stale (TTL passed) or never existed at all.
548
+ // Presence check tells us which, for the isCachedLS flag below.
549
+ isCachedLS = readAbCacheRaw(cacheKey) !== null;
550
+ }
551
+ // Fetch — only reached when cache is stale or absent.
552
+ // isCachedLS tells backend whether this is a re-fetch of an existing
553
+ // (now-expired) assignment vs. a brand new request.
554
+ const { ok, data } = await httpGet(`${this.baseURL}/${endpoint}`, this.apiKey, this.getSid(), { abTestName, isCachedLS: String(isCachedLS) }, revokeAfter);
555
+ if (!ok || !data)
556
+ return defaultValue !== null && defaultValue !== void 0 ? defaultValue : null;
557
+ processResponse(data, this.apiKey, this.mode, {
558
+ endpoint,
559
+ cacheKey,
560
+ cacheTTL: this.cacheTTL,
561
+ isAbCache: true,
562
+ });
563
+ if (!data.success || data.value == null)
564
+ return defaultValue !== null && defaultValue !== void 0 ? defaultValue : null;
565
+ return data.value;
566
+ }
567
+ /**
568
+ * Track an event against a named A/B test.
569
+ * Always unique per user — fires at most once per abTestName, ever
570
+ * (until that test's validity window + grace period expires).
571
+ *
572
+ * @param abTestName — the test identifier sent as a query param.
573
+ * No `revokeAfter` here — this method does not
574
+ * accept a custom timeout, only the test name.
575
+ *
576
+ * Decision order:
577
+ * 1. Lazy validity sweep (clears stale cache + tracked key if expired)
578
+ * 2. AB-cache presence check — we must already have a known/assigned
579
+ * variant value (e.g. "dark_theme") stored for this test. If the
580
+ * cache entry is ABSENT, we don't track at all — tracking a test
581
+ * the user was never actually assigned a cached variant for would
582
+ * pollute/degrade the numbers.
583
+ * 3. Already-tracked check — if the tracked-event key is already
584
+ * present, skip (always-unique, no API call).
585
+ * 4. Pause check → API call, mark tracked-key on success.
586
+ *
587
+ * Both (2) AND (3) must pass — cache present AND not-yet-tracked —
588
+ * before any network call is made.
589
+ */
590
+ async track(abTestName) {
591
+ if (!this.apiKey)
592
+ return "NOT OK";
593
+ const endpoint = "track-event";
594
+ const eventKey = `ab::${abTestName}`; // namespaced in events registry
595
+ const cacheKey = LS.AB(abTestName);
596
+ // Lazy expiry sweep — if this test's validity window has lapsed,
597
+ // both the AB cache and tracked-key are cleared so it can fire again
598
+ // for the new cycle (and correctly fail the cache-presence check below
599
+ // until a fresh getVersion() call repopulates it).
600
+ if (this.mode === "cs") {
601
+ expireAbTestIfStale(abTestName);
602
+ }
603
+ // AB-cache presence check — must have a known assigned variant cached
604
+ // for this test, otherwise tracking is skipped entirely.
605
+ if (readAbCacheRaw(cacheKey) === null) {
606
+ log.debug(`ab.track("${abTestName}") skipped — no cached AB assignment present.`);
607
+ return "NOT OK";
608
+ }
609
+ // Already-tracked check — always enforced now, no opt-out.
610
+ if (hasEventFired(eventKey)) {
611
+ log.debug(`ab.track("${abTestName}") skipped — already fired (always-unique).`);
612
+ return "NOT OK";
613
+ }
614
+ // Pause check
615
+ if (this.mode === "cs" && isPaused(endpoint)) {
616
+ log.debug(`ab.track("${abTestName}") skipped — endpoint paused.`);
617
+ return "NOT OK";
618
+ }
619
+ // Fetch
620
+ const { ok, data } = await httpGet(`${this.baseURL}/${endpoint}`, this.apiKey, this.getSid(), { abTestName, unique: "true" });
621
+ if (!ok || !data)
622
+ return "NOT OK";
623
+ processResponse(data, this.apiKey, this.mode, {
624
+ endpoint,
625
+ eventName: eventKey,
626
+ unique: true,
627
+ });
628
+ return data.success ? "OK" : "NOT OK";
629
+ }
630
+ }
631
+ exports.StyraAB = StyraAB;
632
+ // ─────────────────────────────────────────────
633
+ // StyraEvent — styra.event namespace
634
+ // (unchanged in v1.6.0 — keeps the unique flag, no revokeAfter)
635
+ // ─────────────────────────────────────────────
636
+ class StyraEvent {
637
+ constructor(apiKey, mode, baseURL, getSid) {
638
+ this.apiKey = apiKey;
639
+ this.mode = mode;
640
+ this.baseURL = baseURL;
641
+ this.getSid = getSid;
642
+ }
643
+ /**
644
+ * Fire a named custom event — not tied to any A/B test.
645
+ *
646
+ * @param eventName — sent as a query param to /track-custom-event
647
+ * @param unique — if true, only fires once per session per eventName
648
+ *
649
+ * Decision order: unique check → pause check → API call
650
+ */
651
+ async track(eventName, unique = false) {
652
+ if (!this.apiKey)
653
+ return "NOT OK";
654
+ const endpoint = "track-custom-event";
655
+ const eventKey = `custom::${eventName}`; // namespaced in events registry
656
+ // Unique check
657
+ if (unique && hasEventFired(eventKey)) {
658
+ log.debug(`event.track("${eventName}") skipped — already fired (unique=true).`);
659
+ return "NOT OK";
660
+ }
661
+ // Pause check
662
+ if (this.mode === "cs" && isPaused(endpoint)) {
663
+ log.debug(`event.track("${eventName}") skipped — endpoint paused.`);
664
+ return "NOT OK";
665
+ }
666
+ // Fetch
667
+ const { ok, data } = await httpGet(`${this.baseURL}/${endpoint}`, this.apiKey, this.getSid(), { eventName, unique: String(unique) });
668
+ if (!ok || !data)
669
+ return "NOT OK";
670
+ processResponse(data, this.apiKey, this.mode, {
671
+ endpoint,
672
+ eventName: eventKey,
673
+ unique,
674
+ });
675
+ return data.success ? "OK" : "NOT OK";
676
+ }
677
+ }
678
+ exports.StyraEvent = StyraEvent;
679
+ // ─────────────────────────────────────────────
680
+ // Main Styra class
681
+ // ─────────────────────────────────────────────
682
+ class Styra {
683
+ constructor(config) {
684
+ var _a;
685
+ /** Override or extend status-code handlers at runtime */
686
+ this.statusHandlers = STATUS_HANDLERS;
687
+ // ── Critical config guards ──────────────────────────────────────────────
688
+ if (!config.apiKey || typeof config.apiKey !== "string") {
689
+ log.error("apiKey is required and must be a string. SDK is non-functional.");
690
+ this.apiKey = "";
691
+ this.mode = "ss";
692
+ this.cacheTTL = 0;
693
+ this.baseURL = Styra.DEFAULT_BASE_URL;
694
+ this.ab = new StyraAB("", "ss", 0, this.baseURL, () => SID_ABSENT);
695
+ this.event = new StyraEvent("", "ss", this.baseURL, () => SID_ABSENT);
696
+ return;
697
+ }
698
+ if (!["cs", "ss"].includes(config.mode)) {
699
+ log.error(`mode must be "cs" or "ss". Received: "${config.mode}". SDK is non-functional.`);
700
+ this.apiKey = "";
701
+ this.mode = "ss";
702
+ this.cacheTTL = 0;
703
+ this.baseURL = Styra.DEFAULT_BASE_URL;
704
+ this.ab = new StyraAB("", "ss", 0, this.baseURL, () => SID_ABSENT);
705
+ this.event = new StyraEvent("", "ss", this.baseURL, () => SID_ABSENT);
706
+ return;
707
+ }
708
+ this.apiKey = config.apiKey;
709
+ this.mode = config.mode;
710
+ this.baseURL = Styra.DEFAULT_BASE_URL;
711
+ // ── Mode-specific setup ─────────────────────────────────────────────────
712
+ if (this.mode === "ss") {
713
+ if (config.cacheTTL !== undefined) {
714
+ log.warn("cacheTTL is ignored in SS mode.");
715
+ }
716
+ this.cacheTTL = 0;
717
+ if (typeof window !== "undefined") {
718
+ log.warn('mode is "ss" but a browser window was detected. Use mode: "cs" in browser environments.');
719
+ }
720
+ log.info(`Initialised in SS (server-side) mode. SDK v${SDK_VERSION}`);
721
+ }
722
+ else {
723
+ if (typeof window === "undefined") {
724
+ log.warn('mode is "cs" but no browser window detected. Use mode: "ss" on the server.');
725
+ }
726
+ this.cacheTTL = (_a = config.cacheTTL) !== null && _a !== void 0 ? _a : DEFAULT_TTL_MS;
727
+ this.runInitChecks();
728
+ log.info(`Initialised in CS (client-side) mode. Cache TTL: ${this.cacheTTL / 1000}s. SDK v${SDK_VERSION}`);
729
+ }
730
+ // ── Sub-namespaces ──────────────────────────────────────────────────────
731
+ // Bind getSid so sub-classes always read from this instance's state
732
+ const getSid = () => this.getSid();
733
+ this.ab = new StyraAB(this.apiKey, this.mode, this.cacheTTL, this.baseURL, getSid);
734
+ this.event = new StyraEvent(this.apiKey, this.mode, this.baseURL, getSid);
735
+ }
736
+ // ─────────────────────────────────────────────
737
+ // Init checks (CS mode only, runs once on construction)
738
+ // ─────────────────────────────────────────────
739
+ runInitChecks() {
740
+ const meta = readMeta();
741
+ if (!meta) {
742
+ writeMeta(this.apiKey);
743
+ log.debug("First visit — meta initialised.");
744
+ return;
745
+ }
746
+ // Domain change check
747
+ const stored = decodedDomain(this.apiKey, meta._d);
748
+ const current = typeof window !== "undefined" ? window.location.hostname : "";
749
+ if (stored !== null && stored !== current) {
750
+ renewalReset(this.apiKey, `domain changed from "${stored}" to "${current}"`);
751
+ return;
752
+ }
753
+ // 15-day renewal check
754
+ if (Date.now() - meta.issuedAt > RENEWAL_TTL_MS) {
755
+ renewalReset(this.apiKey, "15-day renewal");
756
+ return;
757
+ }
758
+ log.debug("Session valid — no reset needed.");
759
+ }
760
+ // ─────────────────────────────────────────────
761
+ // SID reader — shared by this class and sub-namespaces
762
+ // ─────────────────────────────────────────────
763
+ getSid() {
764
+ if (this.mode === "ss")
765
+ return SID_ABSENT;
766
+ return readSid(this.apiKey);
767
+ }
768
+ // ─────────────────────────────────────────────
769
+ // Public: getVariable
770
+ // ─────────────────────────────────────────────
771
+ /**
772
+ * Fetch a remote config variable by key.
773
+ * Returns defaultValue (or null) on any error — never throws.
774
+ *
775
+ * @param key — variable identifier, sent as query param
776
+ * @param defaultValue — returned on error, pause without cache, or missing value
777
+ * @param revokeAfter — ms before the request is aborted (default 3000)
778
+ *
779
+ * CS mode: pause check → cache check → fetch → cache write
780
+ * SS mode: always fetches fresh
781
+ *
782
+ * Note: unlike ab.getVersion, this does NOT send isCachedLS and is not
783
+ * subject to AB validity-expiry — that logic is AB-specific.
784
+ */
785
+ async getVariable(key, defaultValue, revokeAfter = DEFAULT_REVOKE_AFTER) {
786
+ var _a, _b;
787
+ if (!this.apiKey)
788
+ return defaultValue !== null && defaultValue !== void 0 ? defaultValue : null;
789
+ const endpoint = "get-variable";
790
+ const cacheKey = LS.CACHE(key);
791
+ // Pause check (CS only)
792
+ if (this.mode === "cs" && isPaused(endpoint)) {
793
+ log.debug(`getVariable("${key}") skipped — endpoint paused.`);
794
+ const cached = readCache(this.apiKey, cacheKey, this.cacheTTL);
795
+ return (_b = (_a = cached) !== null && _a !== void 0 ? _a : defaultValue) !== null && _b !== void 0 ? _b : null;
796
+ }
797
+ // Cache check (CS only)
798
+ if (this.mode === "cs") {
799
+ const cached = readCache(this.apiKey, cacheKey, this.cacheTTL);
800
+ if (cached !== null)
801
+ return cached;
802
+ }
803
+ // Fetch
804
+ const { ok, data } = await httpGet(`${this.baseURL}/${endpoint}`, this.apiKey, this.getSid(), { query: key }, revokeAfter);
805
+ if (!ok || !data)
806
+ return defaultValue !== null && defaultValue !== void 0 ? defaultValue : null;
807
+ processResponse(data, this.apiKey, this.mode, {
808
+ endpoint,
809
+ cacheKey,
810
+ cacheTTL: this.cacheTTL,
811
+ });
812
+ if (!data.success || data.value == null)
813
+ return defaultValue !== null && defaultValue !== void 0 ? defaultValue : null;
814
+ return data.value;
815
+ }
816
+ // ─────────────────────────────────────────────
817
+ // Public: cache utilities (CS mode only)
818
+ // ─────────────────────────────────────────────
819
+ /**
820
+ * Evict a single variable key from the cache.
821
+ * No-op in SS mode.
822
+ */
823
+ evictCache(key) {
824
+ if (this.mode === "ss") {
825
+ log.warn("evictCache() has no effect in SS mode.");
826
+ return;
827
+ }
828
+ lsRemove(LS.CACHE(key));
829
+ log.debug(`Cache evicted for key "${key}"`);
830
+ }
831
+ /**
832
+ * Clear all Styra variable and A/B cache entries.
833
+ * Does NOT touch sid, events, paused, or meta.
834
+ * No-op in SS mode.
835
+ */
836
+ clearAllCache() {
837
+ if (this.mode === "ss") {
838
+ log.warn("clearAllCache() has no effect in SS mode.");
839
+ return;
840
+ }
841
+ try {
842
+ Object.keys(localStorage)
843
+ .filter((k) => k.startsWith("styra::cache::") || k.startsWith("styra::ab::"))
844
+ .forEach(lsRemove);
845
+ log.info("All Styra cache cleared.");
846
+ }
847
+ catch (err) {
848
+ log.warn(`clearAllCache() failed: ${err}`);
849
+ }
850
+ }
851
+ // ─────────────────────────────────────────────
852
+ // Public: session utilities
853
+ // ─────────────────────────────────────────────
854
+ /**
855
+ * Clear the stored SID.
856
+ * Next request will send sid: "none" and receive a fresh SID from the backend.
857
+ * Useful for logout flows.
858
+ * No-op in SS mode.
859
+ */
860
+ clearSID() {
861
+ if (this.mode === "ss") {
862
+ log.warn("clearSID() has no effect in SS mode.");
863
+ return;
864
+ }
865
+ lsRemove(LS.SID);
866
+ log.debug("SID cleared — next request will fetch a new one.");
867
+ }
868
+ /**
869
+ * Clear the unique events registry.
870
+ * After this, all unique events (ab.track + event.track) can fire again.
871
+ * No-op in SS mode.
872
+ */
873
+ clearEvents() {
874
+ if (this.mode === "ss") {
875
+ log.warn("clearEvents() has no effect in SS mode.");
876
+ return;
877
+ }
878
+ lsRemove(LS.EVENTS);
879
+ log.debug("Events registry cleared.");
880
+ }
881
+ /**
882
+ * Manually remove a specific endpoint from the paused registry.
883
+ * The next call to that endpoint will go through to the API immediately.
884
+ * No-op in SS mode.
885
+ */
886
+ unpause(endpoint) {
887
+ if (this.mode === "ss") {
888
+ log.warn("unpause() has no effect in SS mode.");
889
+ return;
890
+ }
891
+ clearPausedEntry(endpoint);
892
+ }
893
+ }
894
+ exports.Styra = Styra;
895
+ Styra.DEFAULT_BASE_URL = "https://styra-service-backend.onrender.com/web-config/api/v1.0";
@@ -0,0 +1 @@
1
+ export * from "./client";
package/dist/index.js ADDED
@@ -0,0 +1,17 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __exportStar = (this && this.__exportStar) || function(m, exports) {
14
+ for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
15
+ };
16
+ Object.defineProperty(exports, "__esModule", { value: true });
17
+ __exportStar(require("./client"), exports);
package/package.json ADDED
@@ -0,0 +1,19 @@
1
+ {
2
+ "name": "styra-js",
3
+ "version": "1.5.2",
4
+ "main": "dist/index.js",
5
+ "module": "dist/index.js",
6
+ "types": "dist/index.d.ts",
7
+ "files": [
8
+ "dist"
9
+ ],
10
+ "scripts": {
11
+ "build": "tsc"
12
+ },
13
+ "dependencies": {
14
+ "axios": "^1.6.0"
15
+ },
16
+ "devDependencies": {
17
+ "typescript": "^5.9.3"
18
+ }
19
+ }