Skip to main content

exd TypeScript SDK (@exd/client)

The TypeScript SDK is a thin TS facade around a WASM build of the exd-client Rust crate. The Rust core owns the lint pipeline and eval engine; TypeScript handles fetch, tar.gz extraction, SSE framing, and persistence.

Installation

npm install @exd/client

Picking an entry

Two questions decide your imports.

1. Where does evaluation happen?

WhereEntryWASM in your bundle
In your process (browser or Node)@exd/client (lite) or @exd/client/fullyes
On the server, accessed over HTTP per call@exd/client/remoteno

2. How fresh do flag updates need to be?

FreshnessEntryWASM size (gz, target)
Whatever your poll interval is (default 30 s)@exd/client (lite)~80–120 KB
Sub-second; SSE-driven@exd/client/full + @exd/client/sse~130–180 KB
Always live (server does the work)@exd/client/remote0

Combined: 4 scenarios, 4 import recipes.

ScenarioRecipe
Node SSR, polling@exd/client + @exd/client/poll + @exd/client/cache/fs
Node SSR, SSE@exd/client/full + @exd/client/sse + @exd/client/cache/fs
SPA, polling@exd/client + @exd/client/poll + @exd/client/cache
SPA, SSE@exd/client/full + @exd/client/sse + @exd/client/cache
Anything, remote eval@exd/client/remote

Subpath exports

PathPurposeRuntime
@exd/clientLite core: boot, lintFiles, ExdClientNode + browser
@exd/client/fullFull core: same surface plus ClosureMirrorNode + browser
@exd/client/pollpoll, refreshOnce, archiveSourceFromUrlNode + browser
@exd/client/sseeventStreamNode (long-lived) + browser; not for serverless
@exd/client/groupExdClientGroup for multi-namespaceNode + browser
@exd/client/remoteExdRemote (no WASM, just fetch)Any
@exd/client/telemetryEvaluationRecord, EvaluationSink, consoleSink, stdoutSink, inMemorySink, encodeNdjsonNode + browser
@exd/client/cacheIdbArchiveCacheBrowser
@exd/client/cache/fsFsArchiveCacheNode
@exd/client/tarextractTarGz, parseTar, bytesToTextNode + browser
@exd/client/transportfetchArchive, getJson, postJsonNode + browser

You pick one of @exd/client or @exd/client/full per app — you cannot mix them in a single bundle without shipping both WASMs (which defeats the purpose of the split). @exd/client/sse declares @exd/client/full as a peer; @exd/client/poll works with either.

Quickstart — local eval, polling

import { ExdClient, boot } from "@exd/client";
import { poll, archiveSourceFromUrl } from "@exd/client/poll";
import { extractTarGz, bytesToText } from "@exd/client/tar";
import { fetchArchive } from "@exd/client/transport";

await boot();

const initial = await fetchArchive("https://exd.example.com/api/v1/tenants/acme/namespaces/demo/manifest", {
token: process.env.EXD_TOKEN!,
});
if (initial === null) throw new Error("304 on initial fetch — empty cache?");
const files = bytesToText(await extractTarGz(initial.bytes));

const client = await ExdClient.create("demo", files);
const stop = poll(
client,
archiveSourceFromUrl(
"https://exd.example.com/api/v1/tenants/acme/namespaces/demo/manifest",
{ token: process.env.EXD_TOKEN! },
),
{ intervalMs: 30_000 },
);

// On every request:
client.evalBool("ship-it", "production", { user_id: "u-123" }, false);

// On shutdown:
stop();

Quickstart — local eval, SSE

import { ExdClient, ClosureMirror, boot } from "@exd/client/full";
import { eventStream } from "@exd/client/sse";

await boot();
const client = await ExdClient.create("demo", {}); // empty; first event hydrates
const mirror = new ClosureMirror("demo");

const stop = eventStream(
"https://exd.example.com/api/v1/events",
client,
mirror,
{
token: process.env.EXD_TOKEN!,
members: [{ slug: "demo", flags: ["*"] }],
onUpdate: (e) => console.log("flags updated to closure", e.closureHash),
},
);

client.evalBool("ship-it", "production", { user_id: "u-123" }, false);

Quickstart — remote eval

import { ExdRemote } from "@exd/client/remote";

const remote = new ExdRemote({
server: "https://exd.example.com",
token: process.env.EXD_TOKEN!,
namespace: "demo",
environment: "production",
});

await remote.evalBool("ship-it", { user_id: "u-123" }, false);

// Recommended for SSR: one round-trip resolves every flag the page needs.
const all = await remote.evalAll({ user_id: "u-123" });

API reference

boot(wasmInput?)

Initialize the WASM module. Idempotent. wasmInput is anything wasm-pack's default export accepts: a URL, a path string, a Uint8Array, an already-instantiated WebAssembly.Module, or a Response. When omitted, the bundled .wasm next to the JS shim is used.

Lite vs full are independent — boot() from @exd/client initializes only the lite WASM; boot() from @exd/client/full initializes only the full WASM. Each boot() runtime-asserts the artifact's buildVariant() matches what the entry expects, throwing an informative error if the wrong artifact got bundled.

lintFiles(dirName, files)

Pure linter. Takes a path -> text object and returns the structured LintReport. Identical output across the lite and full builds — that's the single-validator invariant.

ExdClient

The local-eval client. ExdClient.create(dirName, files, options?) boots WASM if needed, lints, builds the typed model, and returns the client. Throws ExdLintError (carrying the LintReport) if lint fails.

MethodPurpose
eval(flag, env, ctx, options?)Generic eval; returns EvalResult | undefined
evalAll(env, ctx, options?)Bulk eval; returns Record<string, EvalResult>
evalBool/String/Int/Float/Json(flag, env, ctx, fallback, options?)Typed; returns fallback on any failure
refresh(files)Re-lint and atomically swap the typed state
close()Flush every attached telemetry sink
slug / flagVersion / environments() / flags()Introspection

The constructor options is { sinks?, onEvaluation?, dryRun? }. Each typed eval call produces one EvaluationRecord and dispatches it to every attached sink, subject to the same suppression rules the Rust SDK applies (see @exd/client/telemetry below).

The per-call options argument on the eval methods is { includeTesting?: boolean }. Pass includeTesting: true from internal admin UIs to evaluate rules in env blocks declared with testing = true; default false keeps them invisible and resolution falls through to the env's variant. See resolution § The rollout workflow.

client.evalBool("checkout-redesign", "production", { role: "admin" }, false, {
includeTesting: true,
});

@exd/client/telemetry

The SDK-side telemetry surface. Mirrors crates/exd-client/src/telemetry/ field-for-field: same EvaluationRecord JSON wire format, same suppression rules (dryRun, flag-namespace telemetry_enabled, flag-namespace + per-flag private_attributes, SHA-256 hashing of bucketing identifiers unless raw_entity_ids = true), same NDJSON encoding.

ExportPurpose
EvaluationRecord, EvaluationReason, VariantValue, ContextValueWire types
EvaluationSinkSink contract (record(...) + optional flush())
ClosureSinkWraps a (record) => void callback as an EvaluationSink
consoleSink()Dev-friendly sink — NDJSON via console.log
stdoutSink()Node sink — NDJSON via process.stdout.write
inMemorySink()Captures records into an in-memory array; useful for tests
encodeNdjson(record)One NDJSON line, trailing \n included
import { ExdClient } from "@exd/client";
import { stdoutSink, inMemorySink } from "@exd/client/telemetry";

const records = inMemorySink();
const client = await ExdClient.create("checkout", FILES, {
sinks: [stdoutSink(), records],
});
client.evalBool("ship-it", "production", { user_id: "u-1" }, false);
await client.close(); // flush sinks

@exd/client/full exposes the identical surface; pick whichever core matches your refresh strategy.

ClosureMirror (full only)

Backs the SSE consumer.

MethodPurpose
loadSnapshot(files, expectedHash?)Initialize / replace from a snapshot tarball
applyInline(prevHash, hash, ops, ns)Apply an inline closure-delta event
tomlFiles()Snapshot the closure as path -> text
closureHash / fileCountIntrospection

poll(client, source, options?)

Periodic refresh driver. Returns a stop() function. The driver only calls client.refresh(files) and reads client.flagVersion, so it works with both lite and full clients.

eventStream(url, client, mirror, options?) (full only)

SSE consumer for the v2 closure-delta protocol. Reconnects with exponential backoff; resumes via Last-Event-ID. Falls back to a fresh snapshot fetch on hash mismatch or event: resync.

ExdRemote

Remote-eval client. eval, evalAll, evalBool/String/Int/Float. Falls back to the caller-supplied default on any transport failure. Each method takes the same optional options argument as the local-eval ExdClient{ includeTesting?: boolean } — and forwards it to the server as the include_testing body field on POST /evaluate{,/all}.

Choosing a configuration

┌──────────────────┐
┌────────────── │ Manifest in your │
│ │ client at all? │
│ └────────┬─────────┘
│ no │ yes
▼ ▼
┌───────────────────┐ ┌───────────────────┐
│ @exd/client/ │ │ Sub-second update │
│ remote │ │ freshness? │
└───────────────────┘ └────────┬──────────┘

┌────────────────┼────────────────┐
│ no │ │ yes
▼ ▼
┌────────────────────┐ ┌───────────────────┐
│ @exd/client + │ │ @exd/client/full +│
│ @exd/client/poll │ │ @exd/client/sse │
└────────────────────┘ └───────────────────┘
(lite WASM) (full WASM)

Edge / serverless runtimes (Vercel functions, Lambda, Cloudflare Workers) can't sustain a long-lived connection — pick polling or remote eval there. Long-lived Node SSR can use either.

Build pipeline

The WASM artifacts are built by wasm-pack. CI runs both:

wasm-pack build crates/exd-client-wasm \
--target web --out-dir pkg-lite --out-name exd_eval \
--no-default-features --features eval

wasm-pack build crates/exd-client-wasm \
--target web --out-dir pkg-full --out-name exd_full \
--no-default-features --features closure-delta

The TypeScript package symlinks sdks/typescript/wasm/{lite,full} to crates/exd-client-wasm/pkg-{lite,full} so tsc and vitest can find the wasm-bindgen JS shims.

wasm-opt is disabled in CI by default ([package.metadata.wasm-pack.profile.release] in crates/exd-client-wasm/Cargo.toml); operators with the binaryen toolchain installed locally can flip it back on for production builds to shave ~20–30% more off the gzipped size.

List-typed context attributes

The evaluation context accepts string[] for multi-valued attributes like a user's roles, tags, or feature groups. List attributes work with the contains / not_contains operators (treating them as "list includes the operand string") and with in / not_in (treating them as set-overlap with the manifest operand list). Equality and string-shape operators (eq, starts_with, etc.) don't apply to lists and silently evaluate false.

client.evalBool(
"dashboard",
"production",
{ roles: ["admin", "viewer"], country: "US" },
false,
);

Mixed-type arrays (e.g. [1, "a"]) and arrays of non-strings are rejected at the bridge with a context.<key> error.

Limitations

  • The polling driver does not retry on transport failure — failures surface via onError and the next interval tries again. Bring your own backoff if you need it.
  • Telemetry / OTLP export is deferred (mirrors the Rust SDK).