@apex-inc/sdk 0.20.0 → 0.20.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.
package/dist/esm/index.js CHANGED
@@ -366,15 +366,36 @@ function getExperimentVisitorId() {
366
366
  document.cookie = `${key}=${encodeURIComponent(id)};expires=${d.toUTCString()};path=/;SameSite=Lax`;
367
367
  return id;
368
368
  }
369
- function getExperimentPreview() {
370
- if (!isBrowser)
369
+ function readExperimentPreview(search) {
370
+ if (!search)
371
371
  return null;
372
- const params = new URLSearchParams(window.location.search);
372
+ const q = search.startsWith("?") ? search.slice(1) : search;
373
+ const params = new URLSearchParams(q);
373
374
  const variant = params.get("_apex_preview");
374
- const expId = params.get("_apex_exp");
375
- if (variant)
376
- return { variant, expId };
377
- return null;
375
+ if (!isKnownVariant(variant))
376
+ return null;
377
+ const expId = params.get("_apex_exp")?.trim() || null;
378
+ return { variant, expId };
379
+ }
380
+ function getExperimentPreview(search) {
381
+ const resolved = search ?? (isBrowser ? window.location.search : "");
382
+ return readExperimentPreview(resolved);
383
+ }
384
+ async function fetchExperimentLineIds(apiBase, experimentId, workspaceKey) {
385
+ try {
386
+ const res = await fetch(`${apiBase}/api/experiments/${experimentId}/line?source=sdk`, {
387
+ credentials: "include",
388
+ headers: workspaceKey ? { "x-apex-workspace": workspaceKey } : {},
389
+ });
390
+ if (!res.ok)
391
+ return null;
392
+ const body = (await res.json());
393
+ const ids = body.data?.lineMemberIds ?? body.lineMemberIds;
394
+ return Array.isArray(ids) && ids.length > 0 ? ids : null;
395
+ }
396
+ catch {
397
+ return null;
398
+ }
378
399
  }
379
400
  // DBS-1 — durable browser session id (`apex_sid`), GA4-parity 30-min
380
401
  // inactivity rotation. Mirrors the snippet's cookie format
@@ -519,7 +540,8 @@ function fireExperimentExposure(experimentId, variant, apiBase, workspaceKey, bu
519
540
  * the server cannot be reached.
520
541
  * 4. Deterministic murmur-hash of `visitorId + experimentId` (fallback).
521
542
  *
522
- * Returns `"control"` during SSR / non-browser contexts. Fires the
543
+ * Returns `"control"` during SSR / non-browser contexts — unless
544
+ * `opts.search` carries a matching `_apex_preview`. Fires the
523
545
  * canonical `experiment_exposure` event (deduped) only when the visitor
524
546
  * is still eligible.
525
547
  *
@@ -529,20 +551,32 @@ function fireExperimentExposure(experimentId, variant, apiBase, workspaceKey, bu
529
551
  * if (arm === "variant_a" || arm === "variant_b") renderVariant(); else renderControl();
530
552
  */
531
553
  export async function getVariant(experimentId, opts = {}) {
554
+ const apiBase = (opts.apiUrl ?? config?.apiUrl ?? DEFAULT_API_URL).replace(/\/$/, "");
555
+ const workspaceKey = opts.workspaceKey ?? config?.workspaceKey;
556
+ const buildId = opts.buildId ?? config?.buildId;
557
+ // 1. Preview mode — author previewing, not a real exposure. Runs
558
+ // before the browser/consent gates so SSR and a declined cookie
559
+ // banner still show the URL arm. A family member's `_apex_exp`
560
+ // applies once we know the line.
561
+ const preview = getExperimentPreview(opts.search);
562
+ if (preview) {
563
+ const lineIds = opts.lineMemberIds ??
564
+ (isBrowser && preview.expId && preview.expId !== experimentId
565
+ ? await fetchExperimentLineIds(apiBase, experimentId, workspaceKey)
566
+ : null);
567
+ if (!preview.expId ||
568
+ preview.expId === experimentId ||
569
+ (lineIds && lineIds.includes(experimentId) && lineIds.includes(preview.expId))) {
570
+ return preview.variant;
571
+ }
572
+ return "control";
573
+ }
532
574
  if (!isBrowser)
533
575
  return "control";
534
576
  // Consent gate — without consent we neither persist a visitor id nor fire
535
577
  // an exposure, so everyone deterministically sees control until they opt in.
536
578
  if (!consentGranted)
537
579
  return "control";
538
- const apiBase = (opts.apiUrl ?? config?.apiUrl ?? DEFAULT_API_URL).replace(/\/$/, "");
539
- const workspaceKey = opts.workspaceKey ?? config?.workspaceKey;
540
- const buildId = opts.buildId ?? config?.buildId;
541
- // 1. Preview mode — author previewing, not a real exposure.
542
- const preview = getExperimentPreview();
543
- if (preview && (!preview.expId || preview.expId === experimentId)) {
544
- return preview.variant;
545
- }
546
580
  const cacheKey = `apex_var_${experimentId}`;
547
581
  const cached = localStorage.getItem(cacheKey);
548
582
  const visitorId = getExperimentVisitorId();
@@ -563,18 +597,22 @@ export async function getVariant(experimentId, opts = {}) {
563
597
  if (!res.ok)
564
598
  throw new Error(`${res.status}`);
565
599
  const data = (await res.json());
600
+ const runId = data.experimentId || experimentId;
566
601
  if (data.eligible === false) {
567
602
  // Staging / TestFlight / localhost: serve the arm so they can QA.
568
603
  // Do not fire an exposure — that is what would move the result.
569
604
  if (data.reason === "non_production_audience") {
570
- localStorage.setItem(cacheKey, data.variant);
605
+ localStorage.setItem(`apex_var_${runId}`, data.variant);
571
606
  return data.variant;
572
607
  }
573
608
  localStorage.setItem(cacheKey, "control");
574
609
  return "control";
575
610
  }
576
- localStorage.setItem(cacheKey, data.variant);
577
- fireExperimentExposure(experimentId, data.variant, apiBase, workspaceKey, buildId);
611
+ if (runId !== experimentId) {
612
+ localStorage.removeItem(cacheKey);
613
+ }
614
+ localStorage.setItem(`apex_var_${runId}`, data.variant);
615
+ fireExperimentExposure(runId, data.variant, apiBase, workspaceKey, buildId);
578
616
  return data.variant;
579
617
  }
580
618
  catch {