// Duelpilot flags: the server-side SDK for code tests. Version 1. // // Copy this one file into your repository (e.g. lib/duelpilot-flags.ts). No dependencies; runs on // Node 18+, Bun, Deno, Cloudflare Workers and Vercel/Next.js (Node and Edge runtimes). // // import { createFlags, visitorIdFrom } from "./duelpilot-flags"; // const flags = createFlags({ siteKey: "dp…", edgeUrl: "https://edge.duelpilot.com" }); // const arm = await flags.variant("e12", { visitorId: visitorIdFrom(cookieHeader) ?? "" }); // if (arm === "v1") { /* the new code path */ } else { /* control (also for null) */ } // // - It buckets exactly like the Duelpilot script in the browser (same hash, same config from // GET {edgeUrl}/f/{siteKey}.json, cached 60 s), so a visitor gets the same arm on the server // and in the browser when both use the same visitor id (the `dp_vid` cookie). // - null always means "show the control": unknown test, not eligible, no consent, bot, kill // switch, the test has ended. Treat null exactly like "c". // - Tests that assign by your own visitor groups (your site puts each visitor in a group, e.g. a // random segment A–F): pass the visitor's group, `variant("e12", { visitorId, group: "D" })`. // Without it such a test answers null (control). Copies of this file from before 29 Oct 2026 // ignore groups mode: update the file before launching a code test in groups mode. // - Counting: `count: true` (default) reports the exposure to Duelpilot's counter on the first // variant() call for a visitor. When the Duelpilot script is on the page, prefer `count: false` // here and call `duelpilot.variant("e12")` in the browser: it counts once per visitor, with consent. // - Never put a Duelpilot MCP token here. `serverKey` (dps_…, from Settings → Code tests) only lifts // the per-IP rate limit for server pings; keep it in an environment variable. // // Do not change the hashing below: it must stay byte-for-byte equal to Duelpilot's bucketing. export type Device = "mobile" | "tablet" | "desktop"; export type FlagContext = { /** Stable visitor id: the dp_vid cookie (visitorIdFrom) or your own first-party id. Empty → null. */ visitorId: string; /** Required (true) when the site's consent mode isn't "off": without consent everyone gets control. */ consent?: boolean; /** The visitor's User-Agent: bots get null, and the device is derived from it unless `device` is given. */ userAgent?: string | null; device?: Device; /** ISO 3166-1 alpha-2, e.g. from your CDN's geo header. */ country?: string | null; /** e.g. "nl-NL" (Accept-Language's first entry). */ lang?: string | null; /** Custom attributes, as duelpilot.setAttributes() in the browser. */ attributes?: Record; /** First visit in this session (only splits the counts by segment). Default false. */ isNew?: boolean; /** The ?dp_force= value of the request (QA, previews): forced arms are never counted. */ force?: string | null; /** * Tests that assign by your own visitor groups (Settings → General → Your visitor groups): this * visitor's group as your site assigns it, e.g. "D". Its mapped arm is returned; no group, or a * group mapped to "not in the test" → null. Ignored by tests that use Duelpilot's random split. */ group?: string | null; }; /** Where exposures already reported are remembered (default: in memory, per process). Use Redis etc. across instances. */ export type SeenStore = { has(key: string): boolean | Promise; add(key: string): void | Promise }; export type FlagsOptions = { siteKey: string; /** Duelpilot's edge, e.g. https://edge.duelpilot.com (the host of your Duelpilot script tag). */ edgeUrl: string; /** dps_… from Settings → Code tests (env var). Optional. */ serverKey?: string; /** Report exposures and track() conversions to Duelpilot. Default true. */ count?: boolean; /** Config cache, default 60 000 ms (Duelpilot publishes within a minute anyway). */ ttlMs?: number; /** Network timeout for the config and pings, default 1500 ms. */ timeoutMs?: number; store?: SeenStore; fetch?: typeof fetch; onError?: (err: unknown) => void; }; // ─── Config shape (GET /f/{siteKey}.json) ────────────────────────────────── type Condition = { k: string; v?: unknown; name?: string; op?: string; value?: string }; type Experiment = { id: string; run: string; seed: string; aseed: string; alloc: number; layer?: { id: string; seed: string; from: number; to: number }; target: { aud?: { all?: Condition[]; any?: Condition[] } }; goals: string[]; arms: { id: string; w: number }[]; winner?: string; /** Groups mode: group value → arm (the browser reads the group itself; on the server pass ctx.group). */ g?: { s: string; k: string; m: Record }; }; export type FlagConfig = { v: 1; ver: string; site: string; ingest: string; consent: string; holdout?: { bp: number; seed: string }; off?: boolean; flags: Experiment[]; }; // ─── Bucketing (identical to Duelpilot's src/lib/shared/bucketing.ts) ───────── function utf8(str: string): number[] { const out: number[] = []; for (let i = 0; i < str.length; i++) { let c = str.charCodeAt(i); if (c >= 0xd800 && c <= 0xdbff && i + 1 < str.length) { const d = str.charCodeAt(i + 1); if (d >= 0xdc00 && d <= 0xdfff) { c = 0x10000 + ((c - 0xd800) << 10) + (d - 0xdc00); i++; } } if (c < 0x80) out.push(c); else if (c < 0x800) out.push(0xc0 | (c >> 6), 0x80 | (c & 63)); else if (c < 0x10000) out.push(0xe0 | (c >> 12), 0x80 | ((c >> 6) & 63), 0x80 | (c & 63)); else out.push(0xf0 | (c >> 18), 0x80 | ((c >> 12) & 63), 0x80 | ((c >> 6) & 63), 0x80 | (c & 63)); } return out; } /** MurmurHash3 x86 32-bit over the UTF-8 bytes of `key`. */ export function murmur3(key: string, seed = 0): number { const bytes = utf8(key); const len = bytes.length; const nblocks = len >> 2; const c1 = 0xcc9e2d51; const c2 = 0x1b873593; let h = seed >>> 0; for (let i = 0; i < nblocks; i++) { const j = i * 4; let k = bytes[j] | (bytes[j + 1] << 8) | (bytes[j + 2] << 16) | (bytes[j + 3] << 24); k = Math.imul(k, c1); k = (k << 15) | (k >>> 17); k = Math.imul(k, c2); h ^= k; h = (h << 13) | (h >>> 19); h = (Math.imul(h, 5) + 0xe6546b64) | 0; } let k = 0; const tail = nblocks * 4; switch (len & 3) { case 3: k ^= bytes[tail + 2] << 16; // falls through case 2: k ^= bytes[tail + 1] << 8; // falls through case 1: k ^= bytes[tail]; k = Math.imul(k, c1); k = (k << 15) | (k >>> 17); k = Math.imul(k, c2); h ^= k; } h ^= len; h ^= h >>> 16; h = Math.imul(h, 0x85ebca6b); h ^= h >>> 13; h = Math.imul(h, 0xc2b2ae35); h ^= h >>> 16; return h >>> 0; } /** For ports to other languages: murmur3(input) with seed 0 must give these values. */ export const TEST_VECTORS: [string, number][] = [ ["", 0], ["hello", 613153351], ["visitor-1|V|seed", 1717488045], ["bezoeker-ü|A|e12", 3878126445], ["😀", 3199479546], ]; const bp = (key: string) => Math.floor((murmur3(key) / 4294967296) * 10000); function pickArm(vid: string, seed: string, arms: { id: string; w: number }[]): string | null { let total = 0; for (const a of arms) total += a.w > 0 ? a.w : 0; if (total <= 0) return null; const x = (murmur3(`${vid}|V|${seed}`) / 4294967296) * total; let acc = 0; for (const a of arms) { if (a.w <= 0) continue; acc += a.w; if (x < acc) return a.id; } for (let i = arms.length - 1; i >= 0; i--) if (arms[i].w > 0) return arms[i].id; return null; } /** * The arm for this visitor id, or null (outside the layer or the allocation). Groups mode (exp.g): * the arm mapped to `group`, or null when there is no group or it isn't mapped. */ export function assign(vid: string, exp: Pick, group?: string | null): string | null { if (exp.layer) { const slot = bp(`${vid}|L|${exp.layer.seed}`); if (slot < exp.layer.from || slot >= exp.layer.to) return null; } // Groups mode has no allocation (/f sends alloc 0 so copies of this file without groups support answer null). if (!exp.g && !(bp(`${vid}|A|${exp.aseed}`) < exp.alloc)) return null; if (exp.winner) return exp.winner; if (exp.g) { const id = group == null ? null : exp.g.m[group]; return exp.arms.some((a) => a.id === id && a.w > 0) ? id! : null; } return pickArm(vid, exp.seed, exp.arms); } export function inHoldout(vid: string, h: { bp: number; seed: string } | undefined): boolean { return !!h && h.bp > 0 && bp(`${vid}|H|${h.seed}`) < h.bp; } // ─── Targeting (code tests use device, country, lang and attribute conditions) ─ const BOT = /bot|crawl|spider|slurp|bingpreview|facebookexternalhit|headless|lighthouse|pingdom|gtmetrix|python-requests|curl\/|wget|httpclient|phantomjs|prerender|embedly|whatsapp|telegrambot|slackbot|discordbot/i; export function deviceOf(ua: string): Device { if (/iPad/.test(ua)) return "tablet"; if (/Android/.test(ua) && !/Mobile/.test(ua)) return "tablet"; if (/Tablet|PlayBook|Silk|Kindle/.test(ua)) return "tablet"; if (/Mobi|iPhone|iPod|Windows Phone|Opera Mini|IEMobile/.test(ua)) return "mobile"; return "desktop"; } function strOp(op: string | undefined, actual: string | null, expected?: string): boolean { switch (op) { case "exists": return actual != null && actual !== ""; case "absent": return actual == null || actual === ""; case "eq": return actual != null && actual === expected; case "neq": return actual == null || actual !== expected; case "contains": return actual != null && expected != null && actual.includes(expected); default: return false; // anything else can't be evaluated on a server: not eligible } } function condition(c: Condition, ctx: FlagContext, device: Device): boolean { const list = Array.isArray(c.v) ? (c.v as string[]) : []; switch (c.k) { case "device": return list.includes(device); case "country": return !!ctx.country && list.map((x) => x.toUpperCase()).includes(ctx.country.toUpperCase()); case "lang": { const l = (ctx.lang ?? "").toLowerCase(); return list.some((x) => l === x.toLowerCase() || l.startsWith(x.toLowerCase() + "-")); } case "attr": { const v = c.name != null ? ctx.attributes?.[c.name] : undefined; return strOp(c.op, v == null ? null : String(v), c.value); } default: return false; } } function eligible(exp: Experiment, ctx: FlagContext, device: Device): boolean { const a = exp.target.aud; if (!a) return true; if (a.all) for (const c of a.all) if (!condition(c, ctx, device)) return false; if (a.any && a.any.length) return a.any.some((c) => condition(c, ctx, device)); return true; } function forcedArm(force: string | null | undefined, key: string): string | null { if (!force) return null; for (const pair of force.split(",")) { const [e, a] = pair.split(":"); if (e === key && a) return a; } return null; } // ─── Visitor id helpers ──────────────────────────────────────────────────── /** The dp_vid cookie from a Cookie header (null when absent). */ export function visitorIdFrom(cookieHeader: string | null | undefined): string | null { const m = /(?:^|;\s*)dp_vid=([^;]*)/.exec(cookieHeader ?? ""); if (!m) return null; try { return decodeURIComponent(m[1]) || null; } catch { return null; } } export function newVisitorId(): string { return globalThis.crypto.randomUUID(); } /** * Set-Cookie value for dp_vid. First-party, readable by the Duelpilot script (not HttpOnly) so the * browser buckets the same way. Set it only where your consent rules allow it. */ export function visitorCookie(id: string, o: { maxAgeDays?: number; secure?: boolean; domain?: string } = {}): string { return [ `dp_vid=${encodeURIComponent(id)}`, "Path=/", `Max-Age=${Math.round((o.maxAgeDays ?? 365) * 86400)}`, "SameSite=Lax", ...(o.secure === false ? [] : ["Secure"]), ...(o.domain ? [`Domain=${o.domain}`] : []), ].join("; "); } // ─── The client ──────────────────────────────────────────────────────────── class MemoryStore implements SeenStore { private seen = new Map(); constructor(private max = 50_000) {} has(k: string) { return this.seen.has(k); } add(k: string) { if (this.seen.size >= this.max) this.seen.delete(this.seen.keys().next().value as string); this.seen.set(k, true); } } const hex = (s: string, seed: number) => murmur3(s, seed).toString(16).padStart(8, "0"); export type Flags = { /** The arm ("c", "v1", …) for this visitor, or null (= control). Reports the exposure once. */ variant(key: string, ctx: FlagContext): Promise; /** A conversion on the server (e.g. a paid order), for every code test this visitor was exposed to. */ track(goal: string, ctx: FlagContext & { value?: number }): Promise; /** Waits for pending pings (call it in waitUntil/after() on serverless platforms). */ flush(): Promise; /** The current flag config (cached), or null when it can't be loaded. */ config(): Promise; }; export function createFlags(o: FlagsOptions): Flags { const edge = o.edgeUrl.replace(/\/+$/, ""); const doFetch = o.fetch ?? globalThis.fetch.bind(globalThis); const ttl = o.ttlMs ?? 60_000; const timeout = o.timeoutMs ?? 1500; const store = o.store ?? new MemoryStore(); const counting = o.count !== false; let cached: { at: number; etag: string | null; cfg: FlagConfig } | null = null; let loading: Promise | null = null; const pending = new Set>(); const report = (e: unknown) => { try { o.onError?.(e); } catch { /* never let a logger break a page */ } }; async function load(): Promise { try { const res = await doFetch(`${edge}/f/${encodeURIComponent(o.siteKey)}.json`, { headers: cached?.etag ? { "If-None-Match": cached.etag } : {}, signal: AbortSignal.timeout(timeout), }); if (res.status === 304 && cached) cached = { ...cached, at: Date.now() }; else if (res.ok) { const cfg = (await res.json()) as FlagConfig; if (cfg && cfg.v === 1 && Array.isArray(cfg.flags)) cached = { at: Date.now(), etag: res.headers.get("etag"), cfg }; } else throw new Error(`Duelpilot flag config: HTTP ${res.status}`); } catch (e) { report(e); if (cached) cached = { ...cached, at: Date.now() - ttl / 2 }; // stale while the edge is unreachable } return cached?.cfg ?? null; } async function config(): Promise { if (cached && Date.now() - cached.at < ttl) return cached.cfg; loading ??= load().finally(() => (loading = null)); return loading; } function send(cfg: FlagConfig, batch: Record): void { const p = doFetch(cfg.ingest || `${edge}/c`, { method: "POST", headers: { "Content-Type": "text/plain;charset=UTF-8", "User-Agent": "duelpilot-flags/1 (server)", ...(o.serverKey ? { "X-Duelpilot-Server-Key": o.serverKey } : {}), }, body: JSON.stringify({ s: cfg.site, ...batch }), signal: AbortSignal.timeout(timeout), }) .then(() => undefined) .catch(report) .finally(() => pending.delete(p)); pending.add(p); } type Choice = { exp: Experiment; arm: string; forced: boolean; device: Device } | null; async function choose(cfg: FlagConfig | null, key: string, ctx: FlagContext): Promise { if (!cfg || cfg.off) return null; const exp = cfg.flags.find((e) => e.id === key); if (!exp) return null; const device = ctx.device ?? deviceOf(ctx.userAgent ?? ""); const f = forcedArm(ctx.force, key); if (f) return exp.arms.some((a) => a.id === f) ? { exp, arm: f, forced: true, device } : null; const vid = ctx.visitorId; if (!vid) return null; if (cfg.consent !== "off" && ctx.consent !== true) return null; if (ctx.userAgent != null && BOT.test(ctx.userAgent)) return null; // Groups mode: your own split decides who is in, so the site holdout doesn't apply. if (!exp.g && inHoldout(vid, cfg.holdout)) return null; if (!eligible(exp, ctx, device)) return null; const arm = assign(vid, exp, ctx.group); return arm ? { exp, arm, forced: false, device } : null; } const seg = (device: Device, ctx: FlagContext) => `${device[0]}:${ctx.isNew ? "n" : "r"}`; return { config, async variant(key, ctx) { try { const cfg = await config(); const p = await choose(cfg, key, ctx); if (!p) return null; if (counting && !p.forced && !p.exp.winner) { const k = `x|${p.exp.run}|${ctx.visitorId}`; if (!(await store.has(k))) { await store.add(k); // Deterministic batch id: the edge drops a repeat from another instance for 24 h. const id = `x-${p.exp.run}-${hex(k, 1)}${hex(k, 2)}`.slice(0, 64); send(cfg!, { b: id, e: [[p.exp.run, p.arm, seg(p.device, ctx)]] }); } } return p.arm; } catch (e) { report(e); return null; } }, async track(goal, ctx) { if (!counting) return; try { const cfg = await config(); for (const exp of cfg?.flags ?? []) { if (!exp.goals.includes(goal) || exp.winner) continue; const p = await choose(cfg, exp.id, ctx); if (!p || p.forced) continue; // Conversions count only after an exposure this store saw (as in the browser). if (!(await store.has(`x|${exp.run}|${ctx.visitorId}`))) continue; const k = `c|${exp.run}|${goal}|${ctx.visitorId}`; if (await store.has(k)) continue; await store.add(k); const value = typeof ctx.value === "number" && Number.isFinite(ctx.value) ? ctx.value : undefined; const id = `c-${exp.run}-${hex(k, 1)}${hex(k, 2)}`.slice(0, 64); send(cfg!, { b: id, c: [[exp.run, p.arm, seg(p.device, ctx), goal, ...(value !== undefined ? [value] : [])]] }); } } catch (e) { report(e); } }, async flush() { await Promise.all([...pending]); }, }; }