TechFabricTechFabric
Reference

Server SDK reference

Node / Edge / SSR SDK, assignment in middleware, server components, API routes, and Workers.

Package: @fabricorg/experiments-node

The server SDK is the assignment layer for code that runs before (or instead of) the browser: Next.js middleware, Server Components, API routes, edge functions, mobile-API backends, and worker queues. It mirrors @fabricorg/experiments-web in surface, same treatment() / payload() / metadata() / expose() API, same precedence rules, same preview-token verification, but persists sticky decisions through cookies on the request/response cycle instead of localStorage.

pnpm add @fabricorg/experiments-node

Runs on Node 22+, Cloudflare Workers, Vercel Edge, Deno, and Bun. No node:* imports in the main path; uses Web Crypto for everything.

Quickstart, Next.js middleware

The most common entry point. Read sticky from the request cookie, assign, write sticky back to the response. The cookie format is byte-compatible with the browser SDK so a server-side pick flows through to the client-side treatment() without re-bucketing.

// middleware.ts
import { NextResponse, type NextRequest } from 'next/server';
import { NodeClient } from '@fabricorg/experiments-node';
import { forNextRequest, getOrCreateSubjectId } from '@fabricorg/experiments-node/next';

const client = new NodeClient({ manifestUrl: process.env.FX_MANIFEST_URL! });
await client.init();

export async function middleware(request: NextRequest) {
  const responseHeaders = new Headers();
  const ctx = forNextRequest(request, responseHeaders);
  const subjectId = getOrCreateSubjectId(request, responseHeaders);
  const r = await client.exposure('homepage-cta', subjectId, { ctx });

  const response = NextResponse.next();
  responseHeaders.forEach((v, k) => response.headers.append(k, v));
  if (r.variantKey) response.headers.set('x-fx-variant', r.variantKey);
  return response;
}

Quickstart, Cloudflare Workers / Vercel Edge / Deno

import { NodeClient } from '@fabricorg/experiments-node';
import { forFetchRequest } from '@fabricorg/experiments-node/edge';

let client: NodeClient | null = null;

export default {
  async fetch(request: Request, env: { FX_MANIFEST_URL: string }): Promise<Response> {
    if (!client) {
      client = new NodeClient({ manifestUrl: env.FX_MANIFEST_URL });
      await client.init();
    }
    const responseHeaders = new Headers();
    const ctx = forFetchRequest(request, responseHeaders);
    const r = await client.exposure('homepage-cta', subjectIdFrom(request), { ctx });
    return new Response(JSON.stringify({ variant: r.variantKey }), {
      headers: { 'content-type': 'application/json', ...Object.fromEntries(responseHeaders) },
    });
  },
  async scheduled(_event, env: { FX_MANIFEST_URL: string }) {
    // Cron-driven warmup so the next fetch() returns instantly.
    if (client) await client.refresh();
  },
};

Quickstart, Hono

import { Hono } from 'hono';
import { NodeClient } from '@fabricorg/experiments-node';
import { forHono } from '@fabricorg/experiments-node/hono';

const client = new NodeClient({ manifestUrl: env.FX_MANIFEST_URL });
await client.init();

const app = new Hono();
app.get('/', async (c) => {
  const ctx = forHono(c);
  const r = await client.exposure('homepage-cta', subjectIdFor(c.req), { ctx });
  return c.json({ variant: r.variantKey });
});

Quickstart, Express

import express from 'express';
import { NodeClient } from '@fabricorg/experiments-node';
import { forExpressRequest } from '@fabricorg/experiments-node/express';

const client = new NodeClient({ manifestUrl: process.env.FX_MANIFEST_URL! });
await client.init();

const app = express();
app.get('/', async (req, res) => {
  const ctx = forExpressRequest(req, res);
  const r = await client.exposure('homepage-cta', subjectIdFor(req), { ctx });
  res.send(`<h1>variant: ${r.variantKey}</h1>`);
});

Quickstart, bare Node HTTP

import { NodeClient, plainHeaders, nodeResponse } from '@fabricorg/experiments-node';
import { createServer } from 'node:http';

const client = new NodeClient({
  manifestUrl: 'https://manifest.example.com/v1/acme/manifest.json',
  recordExposure: (e) => warehouseClient.write(e),
});
await client.init();

createServer(async (req, res) => {
  const ctx = {
    headers: plainHeaders(req.headers),
    url: `http://${req.headers.host}${req.url}`,
    response: nodeResponse(res),
  };
  const r = await client.exposure('homepage-cta', subjectIdFor(req), { ctx });
  res.setHeader('content-type', 'text/html');
  res.end(`<h1>Variant: ${r.variantKey ?? 'control'}</h1>`);
}).listen(3000);

NodeClientOptions

Required

FieldTypeNotes
manifestUrlstringURL to the signed manifest JSON

Manifest cache

FieldTypeNotes
refreshIntervalMsnumberCache TTL in ms; default 30000. Reads beyond TTL trigger a background maybeRefresh() (stale-while-revalidate).
fetchImpltypeof fetchOverride the global fetch (proxies, custom retries)
onRefreshError(err) => voidSurface background refresh failures to your error tracker
requireSignedManifestbooleanDefaults to true. Disable only for isolated tests using trusted fixtures.
manifestJwksUrlstringPublic manifest-verification JWKS URL; derived from manifestUrl by default.
manifestJwksobjectInline manifest-verification JWKS for tests or preloaded keys.
manifestRevocationsUrlstringRevoked-version record URL; derived from manifestUrl by default.
manifestRevocations{ versions: number[] }Inline revocation record for tests or preloaded trust metadata.
maxManifestAgeMsnumberOptional replay window measured from the signed builtAt timestamp.
now() => DateTest clock used for deterministic exposure and conversion timestamps.

Telemetry

FieldTypeNotes
recordExposure(record) => voidSink for exposure records
recordConversion(record) => voidSink for conversion records (used by client.track())

Sticky storage

FieldTypeNotes
storageServerStickyStoragecookieStorage() (default), headerStorage(), inMemoryStorage(), noopStorage(), or your own

Eligibility

FieldTypeNotes
excludedbool | (ctx) => boolTruthy → all treatment() returns null, no exposures
decisionAdapter(ctx) => string | nullOverride the bucketer; return null to fall through

Targeting context (for audience.rule evaluation) lives on the per-request RequestContext as ctx.user, see YAML targeting for the rule grammar. Set it on the context before calling treatment().

const ctx = forNextRequest(request, responseHeaders);
ctx.user = { tier, country, accountAgeDays };
const r = await client.exposure('pro-onboarding', subjectId, { ctx });

Preview tokens

FieldTypeNotes
previewJwksUrlstringPublic JWKS URL, derived from manifestUrl if omitted
previewJwksobjectInline JWKS for tests / pre-fetched scenarios

NodeClient API

class NodeClient {
  init(): Promise<void>;
  refresh(): Promise<void>;
  setManifest(m: Manifest): void; // trusted direct injection; no delivery verification
  manifest(): Manifest | null;

  treatment(id: string, subjectId: string, ctx?: RequestContext): TreatmentResult;
  treatmentWithPreview(id: string, subjectId: string, ctx: RequestContext): Promise<TreatmentResult>;

  payload<T>(id: string, subjectId: string, ctx?: RequestContext): T | undefined;
  metadata<T>(id: string): T | undefined;

  exposure(id: string, subjectId: string, opts?: {
    tenantId?: string;
    ctx?: RequestContext;
    dimensions?: Record<string, string>;
  }): Promise<TreatmentResult>;
  expose(id: string, subjectId: string, tenantId?: string): Promise<void>;

  track(eventName: string, subjectId: string, opts?: { value?: number; tenantId?: string }): Promise<void>;
  close(): void;
}
MethodNotes
treatmentSynchronous; never blocks on a network round-trip. Reads sticky from ctx.headers, writes new sticky to ctx.response.
treatmentWithPreviewAsync because preview JWKS may need to be fetched. Use this when the request URL might carry a ?fxpreview=&fxtoken= from Studio.
exposureConvenience: assign + record exposure. For experiments with manualExposure: true, defers the record, call expose() later.
exposeEmit a deferred exposure (manual exposure timing).
metadataPer-experiment metadata bag, independent of variant.

Sticky storage adapters

cookieStorage(options?) (default)

Reads sticky from the Cookie header, writes via Set-Cookie. Cookie format byte-compatible with the browser SDK's cookieStorage() so a server-side decision flows through to a later client-side call without re-bucketing.

import { cookieStorage } from '@fabricorg/experiments-node';

const client = new NodeClient({
  manifestUrl,
  storage: cookieStorage({
    cookiePrefix: 'acme.fx.',
    sameSite: 'lax',
    secure: true,        // default true
    httpOnly: false,     // default false — browser SDK needs to read these
    domain: '.acme.com', // optional; cross-subdomain stickiness
  }),
});

headerStorage(name)

Backend-to-backend sticky via a custom request/response header. Useful when callers aren't browsers (service mesh, gRPC-Gateway).

storage: headerStorage('x-fx-sticky'),
// Format: "expId1=variant1,expId2=variant2"

inMemoryStorage()

Per-process map keyed by ctx.user.__subjectId__. Only useful for tests or single-tenant servers where you control the subject id.

noopStorage()

Every treatment() re-buckets. Pair with your own DB-backed sticky if you persist assignments elsewhere.

Decision precedence

Same as the browser SDK:

  1. Signed ?fxpreview= URL token (only when using treatmentWithPreview)
  2. Manifest divertTo
  3. Sticky storage (subject was already assigned)
  4. Test-level sampleRate exclusion
  5. decisionAdapter (skipped if returns null / unknown key / throws)
  6. Built-in murmur3 bucketing
  7. Per-variant recipeSampleRate gate

Examples

new NodeClient({
  manifestUrl,
  excluded: (ctx) => {
    const ua = ctx?.headers.get('user-agent') ?? '';
    return /bot|crawler|spider|googlebot/i.test(ua);
  },
});

Force variants for QA

new NodeClient({
  manifestUrl,
  decisionAdapter: ({ experimentId, ctx }) => {
    const isQa = ctx?.headers.get('x-acme-role') === 'qa';
    return isQa ? 'control' : null;
  },
});

Manual exposure (assign during render, log when it actually mattered)

const r = await client.exposure('checkout-flow', subjectId, { ctx });
// ... render the variant ...
// later, after the user submits:
await client.expose('checkout-flow', subjectId);

Custom storage backed by your DB

import type { ServerStickyStorage } from '@fabricorg/experiments-node';

const dbStorage: ServerStickyStorage = {
  read(ctx) {
    const subjectId = String(ctx.user?.id);
    return new Map(db.getStickyAssignments(subjectId));
  },
  write(ctx, expId, variantKey) {
    const subjectId = String(ctx.user?.id);
    void db.setStickyAssignment(subjectId, expId, variantKey);
  },
};

Edge runtime notes

  • No setInterval: manifest cache is stale-while-revalidate. First call after TTL fires off a background fetch() that doesn't block the caller.
  • Use client.refresh() from a Cloudflare Worker scheduled() handler if you want predictable freshness without serving any stale reads.
  • HTTP ETag / If-None-Match is honored automatically.
  • Web Crypto only: no node:crypto imports anywhere in the main path.

Cross-SDK sticky compatibility

The default cookie format (fx.<expId>=<variantKey>) is byte-identical between @fabricorg/experiments-node and @fabricorg/experiments-web. A pattern that just works:

  1. Next.js middleware runs client.exposure() server-side. SDK reads no cookie → buckets the user → writes Set-Cookie: fx.homepage-cta=….
  2. Browser receives the cookie, runs the tag-loader.
  3. Browser SDK's cookieStorage() reads the same cookie → returns sticky → no re-bucketing, no flicker.
  4. Both SDKs share the same exposure record (deduped by your warehouse on (experiment_id, subject_id, variant_key)).

Validating

pnpm --filter @fabricorg/experiments-node test
pnpm --filter @fabricorg/experiments-node type-check

See also

On this page