rift-node SDK — API Reference

Status: canonical reference · matches the shipped surface of @rift-vs/rift 0.12.1 (minEngineVersion 0.12.0) · elaborates RFC-003 §12 (Node/TS amendment) · reconciled 2026-07-16

This document is the single source of truth for the @rift-vs/rift public API: the cross-cutting decisions and the full grammar reference. It began as the 2026-07-09 design, produced from a full survey of the Rift engine wire grammar and admin API (rift-core, rift-http-proxy, docs/), the librift_ffi C-ABI v2 (26 symbols, crates/rift-ffi), RFC-003 and its §12 amendment, the sibling SDKs (rift-java merged DSL, rift-scala design), and prior art (WireMock, MSW, nock, Testcontainers, Playwright/Vitest fixtures). Every slice of that design has since shipped — §12 holds the delivered ledger — and the text below describes what the SDK does. Deviations from the original design are called out inline with their tracking issue. README and docs/*.md are task-shaped quick starts; this file is the reference.

Runnable snippets in this file are embed-checked against examples/*.ts by npm run docs:check (the <!-- docs:embed --> convention from #14); fences showing type signatures or elided sketches are reference material and carry no marker.

0. Reading example (the shipped DX)

import { rift, imposter, onGet, onPost, okJson, created, status, contains, times, Fault } from '@rift-vs/rift';

await using engine = await rift.embedded(); // or rift.connect(url) / rift.spawn()

const users = await engine.create(
  imposter('users').record()
    .stub(onGet('/api/users/1').willReturn(okJson({ id: 1, name: 'Alice' })))
    .stub(onPost('/api/users')
      .withHeader('content-type', contains('json'))
      .willReturn(created().latency(50), status(503))) // two responses = cycling
    .stub(onGet('/api/health').willReturn(okJson({ ok: true }).withFault(
      Fault.latency({ min: 100, max: 500 }, { probability: 0.3 })))));

await fetch(`${users.url}/api/users/1`);

await users.verify(onGet('/api/users/1'), times(1)); // throws VerificationError with a diff

1. Principles

  1. when(request) → respond(responses…) — the RFC-003 shared mental model. Builders are synchronous and pure; await appears at exactly two places: engine acquisition and engine.create(...) (the Testcontainers pattern).
  2. The API is total — every engine feature is reachable from the typed layer on every transport. Where a transport lacks a native path (FFI gaps), the SDK bridges internally; the user never sees a difference.
  3. Wire model is the escape hatch, never the fightfromJson / .raw() accept raw JSON verbatim (explicit ports respected); every builder build()s to plain wire types.
  4. Cross-SDK consistent, Node-idiomatic — grammar verbs match rift-java (onGet, willReturn, scenario().startingAt().when().respond().goTo(), verify(match, times(1))); spelling is Node-idiomatic (okJson(object), .latency(ms), handle.url, await using).
  5. Failures are self-diagnosing — verification failures render a WireMock-style near-miss diff; startup failures carry engine stderr; native-library failures name the exact file, version, and fix.

2. Packaging and module format

  • ESM-only, Node ≥ 20. No CJS build. Rationale: zero-dep core + global fetch + worker_threads + await using all target modern Node; both testkit targets are ESM-native. CJS consumers use dynamic import().
  • Two packages, one repo (npm workspaces, #39 — realizing the original design): core @rift-vs/rift lives in packages/rift-core and has no dependencies at all; the embedded transport is the separate @rift-vs/rift-embedded package (packages/rift-embedded, versioned in lockstep) carrying koffi as a real dependency. Core reaches it only through the dynamic import('@rift-vs/rift-embedded') inside rift.embedded()connect/spawn users never load it, and installing the embedded package is what opts a project in. The embedded package consumes core’s non-public machinery via the ./internal subpath (no semver guarantees — it exists for same-version embedded backends), keeping the public root surface curated. The former ./embedded subpath is retired; its surface is the embedded package’s root export.
  • package.json exports as shipped (types first in each condition block — the types-second ordering the original design flagged was fixed by #25):
{
  "name": "@rift-vs/rift",
  "type": "module",
  "exports": {
    ".":                  { "types": "./dist/index.d.ts",             "import": "./dist/index.js" },
    "./compat":           { "types": "./dist/compat/index.d.ts",      "import": "./dist/compat/index.js" },
    "./testkit/vitest":   { "types": "./dist/testkit/vitest.d.ts",    "import": "./dist/testkit/vitest.js" },
    "./testkit/jest":     { "types": "./dist/testkit/jest.d.ts",      "import": "./dist/testkit/jest.js" },
    "./intercept-undici": { "types": "./dist/intercept-undici.d.ts",  "import": "./dist/intercept-undici.js" },
    "./internal":         { "types": "./dist/internal.d.ts",          "import": "./dist/internal.js" }
  },
  "peerDependencies": { "undici": ">=6", "vitest": ">=1", "@rift-vs/rift-embedded": "*" },
  "peerDependenciesMeta": {
    "undici": { "optional": true }, "vitest": { "optional": true },
    "@rift-vs/rift-embedded": { "optional": true }  // the embedded transport opt-in (#39)
  }
}
  • Root export hygiene (shipped in #25, breaking but pre-publish): the legacy weak types (Predicate/Response/Stub/Imposter/ImposterConfig/ServerInfo from src/types.ts) left the root — root names belong to the DSL + wire model. The Mountebank-compat surface (create, CreateOptions, RiftServer, default export { create }) stays at the root (it is a permanent product surface) and is also importable from ./compat.

3. Client API — engine facade and handles

3.1 Entry points

export const rift: {
  connect(url: string, options?: ConnectOptions): Promise<RiftEngine>;
  spawn(options?: SpawnOptions): Promise<RiftEngine>;
  embedded(options?: EmbeddedOptions): Promise<RiftEngine>;  // dynamic import of @rift-vs/rift-embedded
};

interface ConnectOptions {
  apiKey?: string;                    // Authorization header for the admin plane; blank (empty
                                      // or whitespace-only) throws InvalidDefinition — omit instead
  headers?: Record<string, string>;
  timeoutMs?: number;                 // per-request; default 30_000
  versionCheck?: 'fail' | 'warn' | 'off';  // default 'fail'; compares GET /config to minEngineVersion
}

connect is async (it performs the version preflight). All three return the same RiftEngine interface — transports differ only in acquisition options.

3.2 RiftEngine

interface RiftEngine extends AsyncDisposable {
  readonly transport: 'remote' | 'spawn' | 'embedded';

  create(def: ImposterBuilder | wire.Imposter): Promise<ImposterHandle>;
  get(port: number): Promise<ImposterHandle>;               // attach to an existing imposter
  list(): Promise<ImposterSummary[]>;                       // { port, protocol, name?, numberOfRequests }
  deleteAll(): Promise<void>;
  replaceAll(defs: Array<ImposterBuilder | wire.Imposter>): Promise<ImposterHandle[]>;

  buildInfo(): Promise<BuildInfo>;   // { version, commit?, builtAt?, features: string[] }
  adminUrl(): Promise<string>;       // embedded: lazily starts the in-process admin plane

  intercept(options?: InterceptOptions): Promise<InterceptHandle>;   // §7

  readonly admin: AdminApi;          // typed low-level admin surface (escape hatch), §3.5
  close(): Promise<void>;            // idempotent
  readonly closed: boolean;
}

3.3 ImposterHandle

Returned by create/get. All mutators are Promise<void> unless noted.

interface ImposterHandle extends AsyncDisposable {
  readonly port: number;
  readonly url: string;              // `${protocol}://${reachableHost}:${port}` — 0.0.0.0 → 127.0.0.1
  readonly name?: string;
  readonly protocol: 'http' | 'https';

  // stub surgery
  addStub(stub: StubBuilder | wire.Stub, opts?: { index?: number }): Promise<void>;
  replaceStubs(...stubs: Array<StubBuilder | wire.Stub>): Promise<void>;
  updateStub(ref: number | { id: string }, stub: StubBuilder | wire.Stub): Promise<void>;
  deleteStub(ref: number | { id: string }): Promise<void>;
  stubs(): Promise<wire.Stub[]>;

  // verification (§6)
  recorded(filter?: RecordedFilter): Promise<RecordedRequest[]>;
  clearRecorded(): Promise<void>;
  verify(match: RequestMatch, count?: CountMatcher): Promise<void>;  // default atLeast(1)
  requests(opts?: { pollIntervalMs?: number; signal?: AbortSignal; match?: RequestMatch }):
    AsyncIterableIterator<RecordedRequest>;  // live iteration, polling-based (#26; SSE when rift#461 lands)

  // scenarios (§5.8)
  scenarios(flowId?: string): Promise<Array<{ name: string; state: string }>>;
  setScenarioState(name: string, state: string, flowId?: string): Promise<void>;
  resetScenarios(flowId?: string): Promise<void>;

  // spaces & flow state (§5.9)
  space(flowId: string): SpaceHandle;
  flowState(flowId: string): FlowStateHandle;

  // lifecycle & export
  enable(): Promise<void>;
  disable(): Promise<void>;
  clearProxyRecordings(): Promise<void>;   // DELETE savedProxyResponses
  toJson(opts?: { replayable?: boolean; removeProxies?: boolean }): Promise<wire.Imposter>;
  delete(): Promise<void>;                 // [Symbol.asyncDispose] delegates here (idempotent)
}

SpaceHandle scopes the same verbs to one flow id; delete() is the one-call teardown:

interface SpaceHandle {
  readonly flowId: string;
  addStub(stub: StubBuilder | wire.Stub): Promise<void>;   // POST spaces/{flowId}/stubs
  stubs(): Promise<wire.Stub[]>;
  recorded(match?: RequestMatch): Promise<RecordedRequest[]>;
  verify(match: RequestMatch, count?: CountMatcher): Promise<void>;
  scenarios(): Promise<Array<{ name: string; state: string }>>;
  setScenarioState(name: string, state: string): Promise<void>;
  resetScenarios(): Promise<void>;
  state: FlowStateHandle;
  delete(): Promise<void>;                 // stubs + recorded + scenario state, never global
}

interface FlowStateHandle {
  get<T = unknown>(key: string): Promise<T | undefined>;   // undefined = absent (not an error)
  set(key: string, value: unknown): Promise<void>;
  delete(key: string): Promise<void>;
}

3.4 Layering

testkit (vitest/jest)                 — fixtures over the client API
client API: RiftEngine + handles      — transport-agnostic sugar, one implementation
AdminApi (typed, total)               — per-transport: HTTP (remote/spawn) | FFI+loopback (embedded)
wire model + DSL                      — pure data

RiftEngine/handles are implemented once over the AdminApi interface; transports provide AdminApi implementations. This replaced the pre-M7 split where spawn returned {url, port, client} and remote returned a bare client (#21).

3.5 AdminApi (escape hatch, total wire-level surface)

RemoteClient covers the full admin route table, and AdminApi is the interface every transport implements: imposter CRUD (?replayable/removeProxies), stub CRUD by index and by id, savedRequests get/delete with match= filters, savedProxyResponses delete, enable/disable, scenarios get/put/reset, spaces, flow-state KV, config/logs/metrics, reload. Exact signatures are in issue #15 (client API).

4. Error model

class RiftError extends Error                    // base
class InvalidDefinition extends RiftError        // client-side validation or engine 400
class ImposterNotFound extends RiftError         // 404
class EngineError extends RiftError              // other engine failure; .code
class EngineUnavailable extends RiftError        // spawn/connect/load failure; .cause
class CommunicationError extends RiftError       // transport-level (HTTP/FFI)
class WireValidationError extends RiftError      // wire codec, in and out; .path .cause?
class VerificationError extends RiftError        // verify() miss; .expected .count .recorded .closest
class UnsupportedPredicateError extends RiftError// client-side verify hit xpath/inject
class EngineVersionError extends RiftError       // preflight: engine < minEngineVersion; .found .required
class NativeLibraryError extends RiftError       // cdylib resolution/ABI failure; .path? .classifier?
class InterceptUnavailable extends RiftError     // intercept not started/startable on this transport

All SDK-thrown errors are RiftError subclasses (the compat create() keeps its historical plain Errors). WireValidationError sits under RiftError like the rest (re-parented in #25).

WireValidationError guards both directions. Inbound it reports a malformed shape from fromJson; outbound (issue #112) every admin payload — imposters, stubs, flow-state values, intercept() options — is refused before it is sent if JSON cannot represent it honestly: a non-finite number (JSON.stringify would emit null), a bigint, a function, a symbol, or an undefined array element (nulled the same way — issue #119). An undefined object property is not an error: it is dropped, which is how an omitted optional stays off the wire. A container that keeps its payload where JSON.stringify cannot reach it — Map, Set, WeakMap, WeakSet, RegExp, Promise, ArrayBuffer, SharedArrayBuffer, DataView — is refused too (issue #126), because it would otherwise arrive as {} with every value gone: new Set(hosts), a natural way to dedupe, is the common way to hit this. Pass an array or plain object instead (and for a pattern, matches(/re/), which converts to the source string the engine expects). Two neighbours are deliberately not refused, because they lose nothing or lose it visibly: an Error (its enumerable own properties do serialize) and a typed-array view such as Uint8Array (an index-keyed object). Nor is an ordinary class instance whose state is private fields or getters, even though it also renders as {} — the refused set is an explicit list, not a rule about the output. .path locates the offending node as a JSONPath-like string — $[2].action.serve.statusCode, not a bare statusCode — so an element of a posted array is named by index instead of having to be bisected by hand (issue #118). Array indices are [2], identifier-safe keys are .name, and anything else is quoted ($.headers["Content-Type"]). The request is never sent, so a refusal leaves no partial state on the engine. Intercept rules are the one exception to the type: serve()/forward()/addRule() report the same failure as InvalidDefinition, because issue #101 fixed that surface to a single error type.

Some outbound failures originate outside the SDK’s own checks — a circular reference, which JSON.stringify rejects with a bare TypeError, or whatever a value’s own toJSON() threw. Those are re-thrown as WireValidationError with the original attached as .cause (issue #121), so its type and stack survive the wrapping; .path is $, since the throw names no key. .cause is undefined on a refusal the SDK detected itself, which is every case in the previous paragraph.

5. DSL — full grammar

5.1 Stub openers

stub(): StubBuilder                                   // bare (catch-all until refined)
on(method: string, path?: string | PathOpts): StubBuilder
onGet/onPost/onPut/onDelete/onPatch/onHead/onOptions(path?: string, opts?: PathOpts): StubBuilder
onAny(path: string, opts?: PathOpts): StubBuilder     // any method

Path params: a path containing :name segments (e.g. /users/:id) compiles to BOTH the stub-level routePattern (param extraction for templates/scripts — Rift’s routePattern is extraction-only, it never matches) AND a derived anchored regex path predicate ({ matches: { path: "^/users/[^/]+$" } }). Opt out with { params: false } to treat : as a literal. Param names are captured at the type level (StubBuilder<{ id: string }>) for editor hints; purely compile-time. A param-typed builder composes into every consuming position — imposter().stub(), scenario().when(), the ImposterHandle stub-surgery methods, and verify() — because those accept the AnyStubBuilder upper bound rather than the bare StubBuilder (#47). The wire output (routePattern + derived regex predicate) is unaffected.

// A `:name` segment makes the opener return a param-typed builder — and it composes everywhere.
await using engine = await rift.embedded();

const users = await engine.create(
  imposter('users').record()
    // ...into imposter().stub()
    .stub(onGet('/api/users/:id').willReturn(okJson({ id: 1, name: 'Alice' })))
    // ...into scenario().when()
    .scenario(
      scenario('activation')
        .when('start', onPut('/api/users/:id')).respond(status(202)).goTo('active')));

// ...into the ImposterHandle stub-surgery methods
await users.addStub(onGet('/api/users/:id/posts').willReturn(okJson([])));
await users.replaceStubs(onGet('/api/users/:id').willReturn(okJson({ id: 2, name: 'Bob' })));
await users.updateStub({ id: 'u' }, onGet('/api/users/:id').willReturn(okJson({ id: 3 })));

await fetch(`${users.url}/api/users/1`);

// ...into verify() (a param-typed builder is a valid RequestMatch)
await users.verify(onGet('/api/users/:id'), times(1));

5.2 Matchers (field-agnostic, bind via with* or field binders)

equals(v: JsonValue): Matcher          deepEquals(v: JsonValue): Matcher
contains(v: string): Matcher           startsWith(v: string): Matcher
endsWith(v: string): Matcher           matches(re: string | RegExp): Matcher
exists(): Matcher                      notExists(): Matcher      // exists: false

interface Matcher {
  caseSensitive(): Matcher;            // wire: caseSensitive: true
  keyCaseSensitive(): Matcher;         // wire: keyCaseSensitive: true
  except(re: string | RegExp): Matcher;// wire: except
  jsonpath(selector: string): Matcher; // wire: jsonpath: { selector }
  xpath(selector: string, ns?: Record<string, string>): Matcher; // wire: xpath: { selector, ns }
}

This replaced the pre-M7 field-first free functions (equals('path', v)) — breaking but pre-publish (#22). A bare string/object anywhere a Matcher is accepted means equals.

5.3 Predicates (composition) and stub refinement

// field binders — grouped under `req` to avoid clashing with node:path etc.
export const req: {
  method(m: string | Matcher): wire.Predicate;
  path(m: string | Matcher): wire.Predicate;
  body(m: string | object | Matcher): wire.Predicate;
  header(name: string, m: string | Matcher): wire.Predicate;
  query(name: string, m: string | number | Matcher): wire.Predicate;
};
and(...ps: wire.Predicate[]): wire.Predicate
or(...ps: wire.Predicate[]): wire.Predicate
not(p: wire.Predicate): wire.Predicate
injectPredicate(jsFn: string): wire.Predicate         // { inject: "function (config) {...}" }

// StubBuilder refiners (each ANDs one predicate; mirrors rift-java)
interface StubBuilder<P = {}> {
  withMethod(m: string | Matcher): this;
  withPath(m: string | Matcher): this;
  withBody(m: string | object | Matcher): this;
  withHeader(name: string, m: string | Matcher): this;
  withQuery(name: string, m: string | number | Matcher): this;
  when(p: wire.Predicate): this;                      // raw-predicate escape (kept)
  // stub-level fields
  id(id: string): this;                               // wire: id
  inSpace(flowId: string): this;                      // wire: space
  inScenario(name: string): this;                     // wire: scenarioName (grouping only, no FSM)
  routePattern(pattern: string): this;                // explicit override
  // responses
  willReturn(...rs: Array<ResponseBuilder | wire.StubResponse>): this;  // APPENDS (cycling)
  raw(patch: Partial<wire.Stub>): this;               // shallow-merged last
  build(): wire.Stub;
}

Semantics: willReturn appends on repeated calls (rift-java parity; shipped in #22). respond is an alias. Nothing is named then (builders must not be thenables).

5.4 Response builders

// constructors
ok(body?): R            okJson(body: JsonValue): R      created(body?): R
noContent(): R          notFound(body?): R              badRequest(body?): R
status(code: number, body?): R                          json(code, body): R    text(code, body): R
fault(kind: TcpFaultKind): R                            // bare top-level fault response
proxyTo(to: string): ProxyBuilder                        // §5.6
inject(jsFn: string): R                                  // Mountebank JS inject
script(spec: ScriptSpec): R                              // _rift.script-only response, §5.7

interface ResponseBuilder /* R */ {
  status(code: number): this;
  header(name: string, value: string | string[]): this;  // string[] = multi-value (Set-Cookie)
  headers(h: Record<string, string | string[]>): this;
  body(v: JsonValue): this;
  binaryBody(data: Uint8Array | string): this;           // base64-encodes; wire: _mode: "binary"
  templated(): this;                                     // wire: _rift.templated: true

  // behaviors (_behaviors) — execution order in-engine: copy → lookup → decorate → wait
  latency(ms: number | { min: number; max: number } | string): this;
      // number → wait: N; range → wait: {min,max}; string = JS fn source → wait: "function() {...}"
      // NEVER emit {"inject": ...} — docs show it but the engine's WaitBehavior parser rejects it
  repeat(n: number): this;
  decorate(jsFn: string): this;
  shellTransform(...commands: string[]): this;           // string per command; array = chained
  copy(spec: CopySpec | CopySpec[]): this;
  lookup(spec: LookupSpec | LookupSpec[]): this;
  behavior(raw: wire.Behaviors): this;                   // merge escape hatch

  withFault(f: RiftFault): this;                         // _rift.fault, §5.5
  raw(patch: Partial<wire.StubResponse>): this;
  build(): wire.StubResponse;
}

interface CopySpec {
  from: 'path' | 'method' | 'body' | { query: string } | { headers: string };
  into: string;                                          // "${TOKEN}"
  using: { method: 'regex' | 'jsonpath' | 'xpath'; selector: string;
           options?: { ignoreCase?: boolean; multiline?: boolean } };
}
interface LookupSpec {
  key: { from: CopySpec['from']; using: CopySpec['using'] };
  fromDataSource: { csv: { path: string; keyColumn: string; delimiter?: string } };
  into: string;
}

latency() deliberately never emits the Mountebank-documented wait: { inject: ... } random-delay form — the engine’s WaitBehavior parser rejects it (#23 design decision). Random delay in a range uses { min, max }; full JS control uses the fn-source string. wait: { inject } stays fromJson-only (see latency-testing.json in the conformance corpus).

5.5 Faults

type TcpFaultKind = 'CONNECTION_RESET_BY_PEER' | 'EMPTY_RESPONSE'
                  | 'RANDOM_DATA_THEN_CLOSE' | 'MALFORMED_RESPONSE_CHUNK';

export const Fault: {
  CONNECTION_RESET: 'CONNECTION_RESET_BY_PEER';
  EMPTY_RESPONSE: 'EMPTY_RESPONSE';
  RANDOM_DATA: 'RANDOM_DATA_THEN_CLOSE';
  MALFORMED_CHUNK: 'MALFORMED_RESPONSE_CHUNK';
  // _rift.fault builders (probabilistic, composable on any is-response)
  latency(ms: number | { min: number; max: number }, opts?: { probability?: number }): RiftFault;
  error(spec: { status?: number; body?: string; headers?: Record<string, string> },
        opts?: { probability?: number }): RiftFault;
  tcp(kind: TcpFaultKind, opts?: { probability?: number }): RiftFault;
};

fault(Fault.CONNECTION_RESET) → top-level { fault: "CONNECTION_RESET_BY_PEER" } response. ok().withFault(Fault.latency(1000))_rift.fault.latency. Multiple withFault calls merge into one _rift.fault block (latency + error + tcp coexist; engine precedence latency → tcp → error).

5.6 Proxy

interface ProxyBuilder {
  proxyOnce(): this; proxyAlways(): this; proxyTransparent(): this;   // wire: mode
  generatePredicates(...gens: PredicateGenerator[]): this;            // wire: predicateGenerators
  addWaitBehavior(on?: boolean): this;
  addDecorateBehavior(jsFn: string): this;
  injectHeader(name: string, value: string): this;                    // wire: injectHeaders
  rewritePath(from: string, to: string): this;                        // wire: pathRewrite (Rift ext)
  clientCert(pem: { key: string; cert: string }): this;               // mTLS to upstream
  latency(...) / repeat(...) / decorate(...) etc.                     // _behaviors now legal on proxy
  raw(patch: Partial<wire.StubResponse>): this;
  build(): wire.StubResponse;
}
interface PredicateGenerator {
  matches: { method?: true; path?: true; query?: true; body?: true; headers?: Record<string, true> };
  caseSensitive?: boolean; except?: string;
}

ProxyBuilder extends ResponseBuilder, so behavior chainers stay legal on a proxy response and are emitted — the pre-M7 silent drop of proxyTo(...).latency(500) is gone (#23).

5.7 Scripts

export const Script: {
  rhai(code: string): ScriptSpec;          js(code: string): ScriptSpec;
  rhaiFile(path: string): ScriptSpec;      jsFile(path: string): ScriptSpec;
  ref(name: string): ScriptSpec;           // named registry (_rift.scripts)
};
// wire: _rift.script: { engine?, code | file | ref }

Used by script(spec) (response generator), imposter().registerScript(name, spec) (registry).

5.8 Scenarios

scenario(name: string): ScenarioBuilder
interface ScenarioBuilder {
  startingAt(state: string): this;                       // must equal first step's state (checked)
  when(state: string, stub: StubBuilder): this;          // SNAPSHOTS the stub at call time
  respond(...rs: Array<ResponseBuilder | wire.StubResponse>): this;  // variadic → cycling in-state
  goTo(next: string): this;                              // omit = gate without transition
  build(): wire.Stub[];
}
// sugar so users don't spread:
imposter('x').scenario(scenario('checkout').startingAt('Started')...)

when snapshots (build()s) the stub immediately — later mutation of the passed builder never rewrites committed steps. respond is variadic (cycling in-state). Both shipped in #24.

Grouping without an FSM: a bare scenarioName on a stub (no required/new state — pure grouping/tagging) is set with StubBuilder.inScenario(name) (#36). The FSM-with-transitions path stays scenario().

5.9 Imposter builder

imposter(name?: string): ImposterBuilder
interface ImposterBuilder {
  port(n: number): this;                    // explicit ports ALWAYS respected
  host(h: string): this;
  protocol(p: 'http' | 'https'): this;
  https(tls?: { cert?: string; key?: string; mutualAuth?: boolean }): this;  // protocol + PEM
  record(): this;  recordMatches(): this;  allowCORS(): this;
  strictBehaviors(): this;
  defaultResponse(r: ResponseBuilder | wire.IsResponse): this;
  defaultForward(url: string): this;        // Rift: transparent forward for unmatched
  serviceName(s: string): this;  serviceInfo(v: JsonValue): this;
  stub(...stubs: Array<StubBuilder | wire.Stub>): this;
  scenario(s: ScenarioBuilder): this;       // appends s.build() stubs
  // _rift config
  flowState(cfg: { backend?: 'inmemory' | 'redis'; ttlSeconds?: number;
                   flowIdSource?: 'imposter_port' | `header:${string}`;
                   redis?: { url: string; poolSize?: number; keyPrefix?: string } }): this;
  flowIdFromHeader(name: string): this;     // sugar: flowIdSource: `header:${name}`
  metrics(port?: number): this;
  scriptEngine(cfg: { defaultEngine?: 'rhai' | 'javascript'; timeoutMs?: number }): this;
  registerScript(name: string, spec: ScriptSpec): this;
  raw(patch: Partial<wire.Imposter>): this;
  build(): wire.Imposter;
}

defaultResponse throws InvalidDefinition on non-is builders at call time and accepts a raw wire.IsResponse (#24).

6. Verification

6.1 Types

interface RecordedRequest {
  method: string;
  path: string;
  query: Record<string, string | string[]>;
  headers: Record<string, string | string[]>;
  body?: unknown;                 // string, or parsed JSON when the engine recorded it as such
  from: string;                   // wire: request_from (client addr)
  timestamp: string;              // RFC3339
  raw: wire.RecordedRequest;      // untouched wire object
}
type RequestMatch = StubBuilder | wire.Predicate | wire.Predicate[];
interface CountMatcher { readonly min: number; readonly max: number; describe(): string }
times(n): CountMatcher   atLeast(n): CountMatcher   atMost(n): CountMatcher
between(min, max): CountMatcher                     never(): CountMatcher  // times(0)

A StubBuilder used as a match contributes only its predicates (responses ignored).

6.2 Client-side predicate evaluation

Rift has no server-side verify endpoint yet (upstream: rift#494), so verify/recorded(filter) evaluate predicates in the SDK against recorded requests: equals, deepEquals, contains, startsWith, endsWith, matches, exists, and, or, not, honoring caseSensitive (default insensitive), keyCaseSensitive, except, plus a built-in jsonpath subset (dot + bracket + numeric index: $.a.b[0].c; filters/wildcards unsupported). xpath and inject predicates throw UnsupportedPredicateError naming the operator. Semantics mirror the engine (string coercion for query/header scalars, object-containment for equals on JSON bodies). Evaluator ergonomics follow-ups — field-name validation, typed regex errors, non-JSON-body diagnostics — are tracked in #33.

6.3 Failure rendering (VerificationError)

“Closest” = the recorded request satisfying the highest fraction of leaf predicate clauses; ties → most recent. Message format (snapshot-tested):

Verification failed for imposter "users" (port 55123)

Expected  GET /api/users/1        times(1)
Actual    0 of 3 recorded requests matched

Closest non-match — request #2 at 2026-07-09T10:12:03Z from 127.0.0.1:52114:
  method  GET                       ✓
  path    /api/users/2              ✗  expected equals "/api/users/1"
  header  accept: application/json  ✓

error.expected (predicates), error.count ({ matched, total, matcher }), error.recorded, error.closest are machine-readable. The testkit’s assertReceived reuses this renderer.

7. Intercept (TLS-MITM)

interface InterceptOptions { host?: string; port?: number; caCertPath?: string; caKeyPath?: string }
interface InterceptHandle {
  readonly url: string;             // http://host:port — set as the SUT's HTTPS proxy
  readonly port: number;
  serve(match: string | wire.Predicate[], response: ResponseBuilder | wire.IsResponse): Promise<void>;
      // string = host shorthand → { host, action: { serve } }
      // response is normalized to the engine's narrower wire.ServeStub (see below)
  forward(match: string | wire.Predicate[], to: ImposterHandle | number): Promise<void>;
  redirectTo(imposter: ImposterHandle): Promise<void>;    // catch-all forward rule
  rules(): Promise<wire.InterceptRule[]>;
  addRule(rule: wire.InterceptRule | wire.InterceptRule[]): Promise<void>;
  clearRules(): Promise<void>;
  caPem(): Promise<string>;
  caFile(dir?: string): Promise<string>;                  // writes PEM, returns path (for NODE_EXTRA_CA_CERTS)
  exportTruststore(opts: { format: 'pkcs12' | 'jks'; path: string; password?: string }): Promise<void>;
  env(): Promise<Record<string, string>>;                 // { HTTPS_PROXY, HTTP_PROXY, NODE_EXTRA_CA_CERTS }
}

serve() normalization (issue #101). The engine’s serve action is a ServeStub (statusCode: u16, headers: String -> String, body: Option<String>), which is narrower than the Mountebank-shaped IsResponse the DSL builds — so serve() converts rather than passing it through:

Input Result
body object/array/number/boolean compact JSON string — okJson({ok:true}) sends '{"ok":true}'
body string sent verbatim, never double-encoded
body absent or null omitted
statusCode numeric string ('404') coerced to a number
_mode: 'text' dropped — it is the engine’s only mode, so removing it changes nothing served
_behaviors.* (latency, repeat, decorate, shellTransform, copy, lookup, …) throws InvalidDefinition naming each one — the serve action cannot run behaviors
_rift.* (templated, script, fault.latency/error/tcp) throws InvalidDefinition naming each one — the serve action carries no _rift extension
any other unknown key throws InvalidDefinition — it would otherwise be dropped in silence
multi-value header (string[]) throws InvalidDefinition — joining would corrupt Set-Cookie
_mode: 'binary' or unrecognized throws InvalidDefinition — the base64 would be served as literal text
statusCode outside 100..999 throws InvalidDefinition — the engine cannot render it as a status line
body containing NaN/Infinity/-Infinity throws InvalidDefinition locating the offending value — JSON.stringify would silently emit null
Host, Connection, Content-Length, Transfer-Encoding (any case) throws InvalidDefinition — the engine’s proxy manages connection framing and drops these
header name or value containing CR/LF throws InvalidDefinition — the engine drops these to prevent response splitting

Use forward() to an imposter when you need a multi-value header, a binary body, or a status code outside 100..999, or addRule() to send a rule verbatim. A non-finite number has no JSON form at all, so no escape hatch applies there — send it as a string if the SUT expects one. Body key order follows your object; the imposter path re-serializes through Rust and emits sorted keys, so the two differ byte-wise (equivalent JSON) if a SUT hashes the body.

Behaviors and _rift extensions (issue #131) are rejected rather than dropped, and one error names every offending construct at once instead of making you discover them one run at a time. The reason they cannot be delivered is the engine’s, not the SDK’s: ServeStub carries only statusCode, headers and body, and its structs do not use deny_unknown_fields, so sending the extra fields anyway would be accepted-and-ignored engine-side with nothing to correlate against. This matters most for fault injection — a withFault(...) rule that registered and then answered a plain success would certify resilience the system under test does not have. Use redirectTo(imposter) (or forward()) when you need behaviors, templating, scripts or faults: those reach a real imposter and so have full stub fidelity. rift-java and rift-scala refuse the same set with the same message.

The last two rows have no escape hatch: the engine strips those same four names on the forward() request and response legs as well, and a CR/LF-bearing header is unsendable in valid HTTP on any path. addRule() is not a way around them either — it skips the normalization above, so the engine drops the header silently just as it did before. What addRule() does not skip is JSON representability: a rule carrying a non-finite number, a bigint, a function, a symbol, or one of the slot-backed containers above (Map, Set, WeakMap, WeakSet, RegExp, Promise, ArrayBuffer, SharedArrayBuffer, DataView) — anywhere, including statusCode and predicates — throws InvalidDefinition rather than reaching the engine as a null you never wrote (a null the transport could not catch afterwards, since it re-parses the serialized rule). It still does not range-check a finite statusCode. Note the four names above are exactly what the engine manages — Keep-Alive, TE and Upgrade are RFC 7230 hop-by-hop but are not stripped, so serve() sends them and the SUT receives them. The engine still appends its own Content-Length and Connection: close to every served response.

Per-transport availability (documented, typed):

  • embeddedengine.intercept(opts) calls rift_start_intercept (idempotent handle reuse).
  • spawn — must be requested at spawn: rift.spawn({ intercept: true | InterceptOptions }) maps to --intercept-port (+ CA flags). engine.intercept() without it throws InterceptUnavailable with the fix in the message.
  • remote — attach-only: probes GET /intercept/rules; 404 → InterceptUnavailable (“start the server with –intercept-port”). Runtime start/status parity is upstream rift#493.

Trust helpers: handle.env() covers child-process SUTs; for in-process undici/fetch, the optional subpath @rift-vs/rift/intercept-undici (peer-dep undici) exports interceptDispatcher(handle): Promise<ProxyAgent> wired with proxy URL + CA.

8. Transports

8.1 Remote / spawn

Spawn exposes the engine’s CLI flags as first-class options:

interface SpawnOptions {
  port?: number; host?: string; loglevel?: 'debug'|'info'|'warn'|'error'; logfile?: string;
  version?: string; binaryPath?: string; env?: Record<string, string>; mirror?: string;
  startupTimeoutMs?: number; shutdownTimeoutMs?: number;
  allowInjection?: boolean;                       // --allow-injection
  apiKey?: string;                                // --api-key (also used by the client); blank
                                                  // throws InvalidDefinition before the binary
                                                  // is resolved — omit to run without admin auth
                                                  // "blank" = whitespace under JS trim() OR Rust
                                                  // str::trim (the engine's), so U+0085 counts
  localOnly?: boolean; ipWhitelist?: string[]; origin?: string;
  datadir?: string; configfile?: string;
  defaultTls?: { cert: string; key: string };     // --default-tls-cert/key
  metricsPort?: number;
  intercept?: boolean | InterceptOptions;         // --intercept-port (+ CA paths, + auth)
}

Intercept credential. InterceptOptions.auth is { username, password } and requires engine

= 0.17.0 (issue #124). Which door you use matters:

Door Carries auth?
rift.spawn({ intercept: { auth } }) yes — the credential is engine startup configuration
rift.embedded() then engine.intercept({ auth }) yes — the listener is started in-process from these options
rift.spawn(...)/rift.connect(...) then engine.intercept({ auth }) no — throws InterceptUnavailable

That last row is a limitation of the engine, not a preference. On the spawn and remote transports engine.intercept() attaches to a listener the engine already brought up from --intercept-port; there is no runtime endpoint to hand a running listener a credential (rift#493). Rather than accept auth and silently drop it — handing back a handle to an unauthenticated proxy — the SDK refuses and points at rift.spawn({ intercept: { auth } }).

Two more behaviours worth knowing:

  • On the spawn door the credential is placed on the child’s environment, never on the command line: argv is world-readable through /proc/<pid>/cmdline and is captured by ps and auditd. For the same reason the username may not contain a colon there — that door colon-joins the two halves into one variable and the engine splits on the first colon — while the embedded runtime door, which carries them separately, accepts one. The option takes precedence over any ambient RIFT_INTERCEPT_AUTH, mirroring how apiKey beats MB_APIKEY.
  • A spawn passing auth probes the resolved binary’s --version before starting it and throws EngineVersionError below 0.17.0, or when the binary reports no recognizable version. Older engines have no --intercept-auth flag, so they never read the variable and would bring the listener up unauthenticated while appearing guarded. The check runs before the child is launched because the engine binds its intercept listener before its admin plane — asking the running engine instead would mean an open MITM proxy had already been accepting connections.

A blank username or password is refused on every door, using the same both-trim-dialect rule as the admin key.

RIFT_INTERCEPT_AUTH (user:pass) is also read from the environment by the engine, and a spawned child inherits it when no auth option overrides it. Because the engine (>= 0.17.0) refuses to start on a malformed value, a blank half, or a valid credential with no listener to guard, the SDK checks it before resolving a binary and reports an InvalidDefinition instead of an opaque child exit. So an ambient value requires intercept to be requested: rift.spawn() without it, and compat create() (which never starts a listener), reject it. Unset the variable, or ask for a listener.

SpawnedEngine.close() also closes its AdminApi client, so no usable client outlives a dead process.

8.2 Embedded

  • Separate package (§2, #39): the worker, koffi binding, and FFI plumbing are @rift-vs/rift-embedded (packages/rift-embedded), reached from core only via the dynamic import inside rift.embedded(); koffi is a real dependency of that package, not of core.
  • One dedicated worker_threads Worker owns the koffi handle, binding the 26-symbol C-ABI v2 (shipped in #8). Every native call runs synchronously on the worker and is atomically paired with rift_last_error() on that thread (the ABI’s error slot is per-OS-thread).
  • FFI-first with lazy loopback bridge: operations with FFI symbols use them; the admin long-tail (scenario get/set/reset, savedRequests/savedProxyResponses clear, enable/disable) routes through a lazily started in-process admin plane (rift_serve_admin on 127.0.0.1:0, random apiKey), started at most once. Imposter creation always goes through FFI — this bypasses the admin plane’s allowInjection: false default, so script/inject stubs work embedded with no flag. list()/get() are served from a local registry (port → submitted config) merged with rift_recorded counts. Upstream: rift#491 (FFI admin long-tail symbols — retires the bridge) and rift#492 (allowInjection option on rift_serve_admin).
  • Preflight: rift_build_info missing symbol → NativeLibraryError (“ABI v1 library, need v2”); version < minEngineVersionEngineVersionError (or console.warn with versionCheck: 'warn'). requireFeatures: ['javascript'] asserts compiled-in features.
  • CI: the embedded conformance lane runs against the built dist/ (the worker resolves ./worker.js relative to the compiled module; jest.embedded.config.js remaps src/ imports to dist/, #44). It is the required M8 gate on ubuntu + macos: it genuinely exercises the FFI and passes now that the #53 segfault (and the #62/#63/#65 follow-ons it unmasked) are fixed. A cdylib-fetch 404 self-skips the embedded describes rather than failing, so only a genuine embedded-test failure blocks merges. The Windows lane stays experimental.
interface EmbeddedOptions {
  libPath?: string;                 // wins over everything; also RIFT_FFI_LIB
  version?: string;                 // natives version pin; default = package minEngineVersion
  cacheDir?: string;
  download?: boolean;               // default true; false = resolve offline or throw
  versionCheck?: 'fail' | 'warn' | 'off';
  requireFeatures?: string[];
  keepAlive?: boolean;              // #70: hold the process alive while the engine is open —
                                    // the standalone mock-server shape. Default false: an idle
                                    // engine never blocks exit (awaited calls always complete).
}

8.3 Natives resolution (cdylib + spawn binary)

Order: libPath/RIFT_FFI_LIB → cache (${RIFT_CACHE_DIR ?? $XDG_CACHE_HOME ?? ~/.cache}/rift-node/ffi/<version>/librift_ffi-<classifier>.<ext>) → download via ffi-manifest.json from the release (mirror base: RIFT_DOWNLOAD_URL), SHA-256 from the manifest mandatory (no skip). Air-gap: RIFT_OFFLINE=1 → no network, error lists the exact file+URL+destination to place manually. Classifiers: linux-x86_64[-musl], linux-aarch64, darwin-{x86_64,aarch64}, windows-x86_64; musl detected via process.report glibc absence. npx rift-fetch [--bin] [--lib] [--version <v>] prefetches (default: both artifacts).

9. Testkit

Vitest (@rift-vs/rift/testkit/vitest)

export const riftTest = createRiftTest();          // default: embedded if installed, else spawn
export function createRiftTest(opts?: {
  transport?: 'embedded' | 'spawn' | { connect: string };
  engine?: EmbeddedOptions | SpawnOptions | ConnectOptions;
}): TestAPI<{ engine: RiftEngine }>;
  • engine is a worker-scoped fixture (one engine per Vitest worker — cheap for embedded and spawn). This is the isolation decision as shipped: per-worker engine + per-test imposter auto-teardown, not spaces; spaces-isolation for a shared connect engine remains a documented pattern, not automated.
  • The test-scoped engine the test receives is a proxy that records every create() and replaceAll(); those imposters are disposed after each test (auto-teardown). get() attachments are not auto-deleted.
  • Transport auto-detect: embedded when koffi (or @rift-vs/rift-embedded, #39) resolves, else spawn.
  • assertReceived(imposter, match, count?) re-exported = imposter.verify (shared renderer).

Usage (runnable, embed-checked):

import { riftTest } from '@rift-vs/rift/testkit/vitest';
import { imposter, onGet, okJson, times } from '@rift-vs/rift';

riftTest('looks up user', async ({ engine }) => {
  const users = await engine.create(imposter('users').record()
    .stub(onGet('/api/users/1').willReturn(okJson({ id: 1 }))));  // auto-teardown
  await fetch(`${users.url}/api/users/1`);
  await users.verify(onGet('/api/users/1'), times(1));
});

Jest (@rift-vs/rift/testkit/jest)

Jest has no fixture system; explicit helpers instead (no custom environment — ESM-hostile). Illustrative sketch (not runnable; the quick start in docs/ has the full form):

const rift = setupRift({ transport: 'spawn' });    // registers beforeAll/afterAll/afterEach
test('looks up user', async () => {
  const users = await rift.engine.create(imposter('users')...);   // auto-teardown afterEach
  ...
});

10. Mountebank compat (permanent)

create(options) / default export { create } keep their exact contract (including accepted-but-ignored redis/impostersRepository, any-HTTP-response readiness poll, EventEmitter events, SIGTERM→SIGKILL close). Internals migrated to fetch + spawn/resolve.ts in #25: axios and the duplicate binary.ts stack retired; findBinary/downloadBinary/getBinaryVersion remain as deprecated delegating wrappers. Known follow-up: create()’s child-process 'error' listener throws inside the emitter, crashing the host on spawn failure instead of rejecting — tracked as #28.

11. Conformance

Corpus replay harness (the corpus is published — rift v0.14.0 ships sdk-conformance-*.tar.gz, resolving the original rift#460 blocker): for each fixture, fromJson → create over each transport → replay recorded interactions → byte-compare responses. Expressibility gate: every fixture name must have an entry in test/conformance/dsl-coverage.ts mapping it to a DSL reconstruction whose build() output deep-equals the fixture (modulo defaults); a missing/failing entry fails CI naming the gap. Details in issues #7/#13. Remote + spawn lanes are required; the embedded lane runs against the built dist/ (#44) and is now required on ubuntu + macos (the FFI segfault #53 and its follow-ons are fixed). The Windows lane stays experimental.

12. Issue map — delivered ledger

Every slice of the original design has shipped:

Issue Delivered PR
#21 §3 engine facade + handles + AdminApi completion (M7) #29
#22 §5.1–5.3 matcher/predicate grammar (M7) #30
#23 §5.4–5.7 responses/behaviors/faults/scripts/proxy (M7) #31
#24 §5.8–5.9 imposter/stub/scenario completion (M7) #32
#25 §2/§10 export hygiene + legacy retirement (M7) #27
#6 §6 verification (M7) #34
#26 §3.3/§6 recorded-request async iteration (polling; SSE when rift#461 lands) #35
#7 §11 conformance harness, remote + spawn lanes (M7) #37
#9 §8.3 natives resolution (M8) #38
#8 §8.2 koffi worker binding (M8) #40
#10 §8.2 rift.embedded() wiring + preflight (M8) #41
#11 §7 intercept (M8) #42
#13 §11 corpus green over embedded (M8) #43
#12 §9 testkit (M8) #45
#14 docs quick starts + migration guide (M8) #46

Historical implementation order: #25 → #21 → #22/#23/#24 → #6 → #7 ‖ #8/#9 → #10 → #11/#12 → #13 → #14.

Open follow-ups (this repo):

Issue Tracks
#28 §10 — compat create() spawn-failure 'error' listener crashes the host
#33 §6.2 — evaluator ergonomics (field-name validation, typed regex errors, body diagnostics)
#39 §2 — @rift-vs/rift-embedded package split (deferred; trigger conditions in the issue)
#53 §8.2 — cross-platform segfault running the full librift_ffi/koffi binding

Open upstream (rift engine):

Issue Tracks
rift#461 SSE recorded-request stream (upgrades #26’s polling requests())
rift#473 docs redirect for the relocated quick starts
rift#491 FFI admin long-tail symbols (retires §8.2’s loopback bridge)
rift#492 allowInjection option on rift_serve_admin
rift#493 runtime intercept lifecycle endpoints (§7 remote parity)
rift#494 server-side verification endpoint (full-fidelity §6 verify)