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?
| Where | Entry | WASM in your bundle |
|---|---|---|
| In your process (browser or Node) | @exd/client (lite) or @exd/client/full | yes |
| On the server, accessed over HTTP per call | @exd/client/remote | no |
2. How fresh do flag updates need to be?
| Freshness | Entry | WASM 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/remote | 0 |
Combined: 4 scenarios, 4 import recipes.
| Scenario | Recipe |
|---|---|
| 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
| Path | Purpose | Runtime |
|---|---|---|
@exd/client | Lite core: boot, lintFiles, ExdClient | Node + browser |
@exd/client/full | Full core: same surface plus ClosureMirror | Node + browser |
@exd/client/poll | poll, refreshOnce, archiveSourceFromUrl | Node + browser |
@exd/client/sse | eventStream | Node (long-lived) + browser; not for serverless |
@exd/client/group | ExdClientGroup for multi-namespace | Node + browser |
@exd/client/remote | ExdRemote (no WASM, just fetch) | Any |
@exd/client/telemetry | EvaluationRecord, EvaluationSink, consoleSink, stdoutSink, inMemorySink, encodeNdjson | Node + browser |
@exd/client/cache | IdbArchiveCache | Browser |
@exd/client/cache/fs | FsArchiveCache | Node |
@exd/client/tar | extractTarGz, parseTar, bytesToText | Node + browser |
@exd/client/transport | fetchArchive, getJson, postJson | Node + 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.
| Method | Purpose |
|---|---|
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.
| Export | Purpose |
|---|---|
EvaluationRecord, EvaluationReason, VariantValue, ContextValue | Wire types |
EvaluationSink | Sink contract (record(...) + optional flush()) |
ClosureSink | Wraps 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.
| Method | Purpose |
|---|---|
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 / fileCount | Introspection |
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
onErrorand the next interval tries again. Bring your own backoff if you need it. - Telemetry / OTLP export is deferred (mirrors the Rust SDK).