@personizely/sdk
v0.1.0
Published
Personizely JavaScript SDK - isomorphic full-stack experiment, feature-flag and personalization decisioning for the browser, Node and the edge
Maintainers
Readme
@personizely/sdk
Isomorphic full-stack experiment, feature-flag and personalization decisioning for Personizely — in the browser, Node and at the edge.
The SDK speaks the same protocol as the Personizely snippet: it loads your per-website datafile, decides campaigns locally (no per-decision network call), and reports impressions/conversions to the tracker. You reference the real Personizely ids you already have — run a campaign by its id, read the response.
- Zero dependencies, dual ESM/CJS, fully typed.
- Local decisioning — deterministic, sticky bucketing with no round-trips.
- Isomorphic — one visitor in the browser, many concurrent visitors on a server.
- Pluggable stickiness — deterministic hashing by default; cookie, memory or your own store.
Scope: the SDK decides full-stack campaigns — feature flags plus full-stack experiments and personalizations. Content, theme, product, shipping, URL-split and template campaigns carry execution details that the Personizely snippet applies to the page/checkout, so they are not run by the SDK (
runCampaignreturnsnullfor them, andrunAllCampaignsskips them).
npm install @personizely/sdkQuick start
import { Personizely } from '@personizely/sdk'
const ply = new Personizely({ apiKey: 'your-website-key' })
await ply.ready()
// In the browser the visitor defaults to the `_ply` cookie. Server-side, set it:
ply.setVisitor('user-42', { plan: 'pro' })
// Run a campaign by its Personizely id — the promise resolves to the response.
const result = await ply.runCampaign(1234)
// Full-stack feature flags:
if (result?.getFeature('checkout-v2').enabled) {
const color = result.getFeature('checkout-v2').get('color', 'blue')
}
// A/B experiment — control vs. variant:
if (result && !result.control) {
render(result.variationId, result.variationName)
}
// Track events & conversions, then drain before exit (Node):
ply.trackEvent('add_to_cart', 4999)
ply.trackGoal('purchase', 4999)
await ply.flush()Concepts
The visitor lives on the client
Set the visitor once; every method reads it as ambient state — no id on each call.
ply.setVisitor('user-42', { plan: 'pro' }, { url: 'https://shop.test/pricing' })
await ply.runCampaign(1234)
ply.trackEvent('viewed_pricing')attributes are your own values (used by audience rules like custom fields and
data). context describes the request — everything is optional and evaluated
only if provided:
context: {
url, referrer, userAgent, // → device / os / browser / query / entry-page
utm: { source, medium, campaign }, // → utm rules
locale, ip, cart, cookies, // ip → geo via /filters; cart → cart rules
}In the browser, context is auto-derived from location / navigator / document.
Serving many visitors on one server
Mutating a shared visitor would race across concurrent requests. Create a cheap per-request scope instead — it shares the loaded datafile and transport:
const ply = new Personizely({ apiKey }) // once, at boot
await ply.ready()
app.get('/', async (req) => {
const visitor = ply.forVisitor(req.userId, { plan: req.plan }, {
userAgent: req.headers['user-agent'],
url: req.url,
ip: req.ip
})
const flag = (await visitor.runCampaign(1234))?.getFeature('checkout-v2')
// ...
})Running everything at once
const all = await ply.runAllCampaigns() // decides every campaign in the datafile
all.get(1234)?.control
all.getFeature('checkout-v2').enabled // search a flag across all full-stack results
for (const result of all) { /* ... */ }Bucketing (stickiness)
By default assignments are a deterministic hash of (visitorId, campaignId):
sticky across calls and processes, with zero storage. Swap it out via bucketing:
import { Personizely, hashBucketing, cookieBucketing, memoryBucketing } from '@personizely/sdk'
new Personizely({ apiKey, bucketing: cookieBucketing() }) // browser: random + cookie persistence
new Personizely({ apiKey, bucketing: hashBucketing('salt') }) // reshuffle buckets with a saltBring your own (e.g. Redis) by implementing the interface — the SDK owns the weighted-share math, so an adapter only handles persistence and/or randomness:
import type { BucketingAdapter } from '@personizely/sdk'
const redisBucketing: BucketingAdapter = {
async read (key) { return JSON.parse(await redis.get(`ply:${key.visitorId}:${key.experimentId}`) || 'null') },
async write (key, a) { await redis.set(`ply:${key.visitorId}:${key.experimentId}`, JSON.stringify(a)) }
}Tracking
| Method | Effect |
| --- | --- |
| runCampaign(id) / runAllCampaigns() | Auto-emit a campaign/view impression when the visitor lands in a campaign (variant or control), deduped per visitor. |
| trackEvent(name, value?) | Custom event; auto-attributes matching action: 'event' goals. |
| trackGoal(nameOrId, value?) | Fire a specific goal (conversion) by name or id. |
| identifyVisitor(traits) | Attach identity traits to the visitor. |
| flush() | Await every in-flight request — call before a short-lived process exits. |
Values are monetary amounts in your currency's minor unit convention as the snippet uses them. Requests go to the tracker host from your datafile.
Datafile
The SDK fetches {cdnUrl}/{apiKey}.json. To decide with a datafile you already
have (SSR, edge, tests), pass it directly and skip the fetch:
const ply = new Personizely({ apiKey, datafile }) // raw `data` object or normalizedRefresh it when campaigns change (scopes pick it up by reference):
await ply.refresh() // manual
new Personizely({ apiKey, pollInterval: 60_000 }) // or autoOptions
| Option | Default | Description |
| --- | --- | --- |
| apiKey | — | Public website key. Required unless a datafile with websiteId is passed. |
| visitor | _ply cookie (browser) | { id, attributes, context }. |
| datafile | — | Pre-supplied config; skips the CDN fetch. |
| cdnUrl | Personizely CDN | Datafile host. |
| trackerUrl | datafile.server | Tracker host override. |
| bucketing | hashBucketing() | Stickiness adapter. |
| fetch | global fetch | Custom fetch (old Node, proxies, tests). |
| pollInterval | — | Auto-refresh the datafile every N ms. |
| onAudienceUnresolved | 'exclude' | How to treat filters the SDK can't resolve (fail-closed by default). |
| consent | — | false marks tracking as no-consent. |
| onError | — | Callback for transport/refresh errors. |
License
MIT
