@anveshan/sdk
v0.4.0
Published
A/B testing and remote config for React Native and web — bucketing, variant resolution and conversion tracking.
Readme
@anveshan/sdk
A/B testing and remote config for React Native and web. Deterministic bucketing, variant resolution and conversion tracking, with the same resolver running on the device, in the browser and on the server — so a variant computed anywhere matches everywhere.
npm install @anveshan/sdkEntry points
| Import | What it is | Needs |
| --- | --- | --- |
| @anveshan/sdk | Client, bucketing and the pure resolver. No UI framework. | — |
| @anveshan/sdk/react | AnveshanProvider, useControl, and the instrumentation helpers. | react |
| @anveshan/sdk/native | Screen capture and live mirroring for the visual picker. Android, debug builds only. | react-native |
| @anveshan/sdk/babel-plugin | Auto-instruments static JSX so copy and styles are remotely overridable. | @babel/core |
Every peer is optional. A Node service resolving variants for server-rendered pages installs none of them.
Quick start
import { AnveshanProvider, useControl } from "@anveshan/sdk/react";
<AnveshanProvider options={{ accountId: "anv_live", endpoint: "https://…" }}>
<App />
</AnveshanProvider>;
function Hero() {
// The second argument is what the shipped binary renders — with no config,
// no network, or no experiment, the screen still works.
const headline = useControl("hero.headline", "Cold-pressed, always");
return <Text>{headline}</Text>;
}Reading a control is what reports the exposure, so an experiment only counts users who actually reached the screen.
Auto-instrumentation
babel.config.js:
module.exports = {
presets: ["module:@react-native/babel-preset"],
overrides: [
{
// Transforming node_modules is worse than useless here: RN's LogBox
// renders outside your provider tree, so instrumenting it replaces the
// stack trace you needed with a blank screen.
exclude: /node_modules/,
plugins: ["@anveshan/sdk/babel-plugin"],
},
],
};Then generate the element catalogue the dashboard's visual picker reads:
npx anveshan-manifest src anveshan-manifest.jsonIds are keyed on file basename + line, so two same-named files in different folders can collide — rename or scope if that bites.
Pass it to the provider with require, not a static import:
let manifest: unknown[] | undefined;
try {
manifest = require('./anveshan-manifest.json');
} catch {}
<AnveshanProvider accountId="…" manifest={manifest}>A static import makes the generated file a hard build dependency — a checkout
that has never run the generator dies at Unable to resolve module. React
Native's Metro config enables allowOptionalDependencies, so a require inside
a try/catch builds either way. Leave it undefined rather than [] when it is
missing: any non-nullish manifest is treated as one worth uploading, and an
empty array overwrites the dashboard's catalogue with nothing.
The manifest is a dashboard convenience, not a runtime dependency. Without it your controls still resolve and your experiments still run — only the visual picker's element list goes unpopulated.
Live mirroring
Nothing to wire. Install, rebuild, run a dev build — the provider mirrors the screen to the dashboard on its own. There is no button and no touch handler to add, because anything the host has to render is something the host has to remember to render.
<AnveshanProvider accountId="anv_live" endpoint={ENDPOINT} screen={route.name}>screen is optional and only labels the capture. mirrorCode defaults to
"demo" — the pairing code a PM types into the dashboard — and setting it to
null turns mirroring off.
Android only. The native module ships in this package and autolinks itself;
there is no iOS module yet, so isCaptureAvailable() is false there and
mirroring is a no-op. It is also dev-only, and enforced natively rather than by
convention: the module refuses to capture unless the host app is debuggable, so
a release build cannot take a screenshot even though autolinking registers it
everywhere.
What drives it
A native ViewTreeObserver.OnDrawListener, debounced, rather than a JS timer or
a touch handler.
A touch handler has to wrap your whole tree and still only sees taps — it misses a scroll settling, an image decoding, a fetch landing, a transition ending. A redraw covers all of them, since those are exactly the things that redraw the screen. The debounce is trailing, so an animation produces one capture when it finishes rather than sixty while it runs.
Frames identical to the last are dropped before upload. An idle screen
uploads nothing at all — which matters because the obvious implementation, a
2s setInterval, re-uploads the same JPEG thirty times a minute, and each one
is a write on the dashboard's side. That was enough to exhaust a Firestore free
tier in about five hours, with the likeliest case being the worst: a picker left
open on a screen nobody was looking at.
Driving it yourself
createMirrorSession() gives the same behaviour under manual control, and
captureCurrentScreen() / uploadCapture() are exported for anything more
bespoke.
const session = createMirrorSession({baseUrl, code: 'demo', screen: route.name});
session.captureNow();
session.activity(); // safe to call on every touch
session.stop();Development
Source lives in src/, published output is built by tsc:
npm run build -w @anveshan/sdk # or: npm run sdk:build from the repo root
npm test # from the repo root; needs Node >= 22Imports carry explicit .ts extensions so Node's type stripping and Metro both
resolve them directly; rewriteRelativeImportExtensions turns them into .js
on the way into dist/.
