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 fight — fromJson / .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. All but ServerInfo were later deleted outright (#155): their is.statusCode: number contradicted the string the engine reads back. 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
  stubWarnings?: 'ignore' | 'warn' | 'fail';  // default 'warn'; see "Engine warnings" below
}

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>;
      // the read shape differs from what the DSL posts (engine >= 0.18.0): a response's behaviors come
      // back as an ordered `behaviors` array plus a response-level `repeat`, not `_behaviors`; both
      // are typed on wire.StubResponse and round-trip through fromJson unchanged
  warnings(): Promise<wire.EngineWarning[]>;  // the engine's current stub analysis, every kind; [] if none
  delete(): Promise<void>;                 // [Symbol.asyncDispose] delegates here (idempotent)
}

statusCode on the read path. toJson(), stubs() and space().stubs() return is.statusCode as a string ('200'): the engine writes Mountebank’s format on imposter and space stubs, whatever type was posted, and wire.IsResponse.statusCode is number | string for it. Intercept rules() return it as a number, since the rule store holds one. Compare with Number(is.statusCode) (or String(...)) rather than toEqual({ statusCode: 200 }).

Reading a toJson() result back with the 0.18.0 shapes typed (wire.StubResponse.behaviors, .repeat, wire.RiftResponseExtension.dataset, wire.RiftImposterConfig.sequencing):

import type { wire } from '@rift-vs/rift';

/** The engine's own count for a response, whichever spelling the document uses. */
export function repeatOf(response: wire.StubResponse): number {
  // A response-level `repeat` wins over `_behaviors.repeat`; the engine never writes `0`.
  return response.repeat ?? response._behaviors?.repeat ?? 1;
}

/** The steps a read-back response runs, in execution order. */
export function stepsOf(response: wire.StubResponse): string[] {
  return (response.behaviors ?? []).flatMap((step) => Object.keys(step));
}

/** Where a dataset-backed lookup stores its row, or `undefined` when the response has none. */
export function datasetTargetOf(response: wire.StubResponse): string | undefined {
  const binding = response._rift?.dataset;
  return binding === undefined ? undefined : `${binding.name}[${binding.keyColumn}] -> ${binding.into}`;
}

export function sequencingModeOf(imposter: wire.Imposter): string | undefined {
  return imposter._rift?.sequencing?.mode;
}

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).

Engine warnings (issue #170). The engine analyses every imposter it loads and reports what it found as _rift.warnings: a key it parses and does not act on (config_key_ignored, engine ≥ 0.18.0 — recordMatches, _rift.metrics, _rift.proxy), _rift.stateOps on a response that never runs them (state_ops_never_runs, ≥ 0.18.0), and duplicate, shadowed or catch-all stubs (duplicate_id, exact_duplicate, potentially_shadowed, catch_all, catch_all_not_last, with a truncated summary past the engine’s cap). handle.warnings() returns them fresh — the engine recomputes them on every stub change. create() and replaceAll() also act on them, per the stubWarnings option of connect / spawn / embedded:

stubWarnings Effect
'warn' (default) one console.warn per warning: rift: imposter "orders" (port 4545): <message> [<warningType>]
'fail' the imposter(s) just created are deleted again and StubWarningsError is thrown; .imposters lists each offender’s port, name and warnings. For replaceAll() that is the whole batch — the PUT already replaced the previous set, which is not restored, so the engine is left with no imposters
'ignore' nothing; no extra call is made

catch_all and truncated never trigger the policy: a predicate-less stub is the DSL’s ordinary default-response shape. create() over connect/spawn reads the warnings from the POST /imposters reply; replaceAll() (whose PUT reply carries none) and create() over the embedded transport (which reads them through rift_stub_warnings) make one extra call per imposter. A warnings block the SDK cannot read is reported under 'warn' and fails the call under 'fail' — never read as “no warnings”. _rift.warnings is read-only: a toJson() result posted back has it stripped.

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, and stubWarnings(port) (the engine’s _rift.warnings for one imposter, [] when absent). 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
class StubWarningsError extends RiftError        // stubWarnings: 'fail' tripped; .imposters [{ port, name?, warnings }]

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";
                                                         // a string must be canonical base64 (throws otherwise)
  templated(): this;                                     // wire: _rift.templated: true
  // flow-state writes run after this `is` response, in order (wire: _rift.stateOps; engine >= 0.18.0 —
  // create()/replaceAll() refuse it on an older engine, which drops the block on parse; same gate as
  // requireClientCertificate). Refused at build() on proxy/inject/fault/script responses.
  stateOps(...ops: wire.StateOp[]): this;
  setState(key: string, value: string): this;            // value is a  template; previousValue in scope
  incrementState(key: string, by?: number): this;        // integer, default 1, may be negative
  deleteState(key: string): this;
  clearFlowState(): this;

  // behaviors (_behaviors) — run on `is`; on proxy/inject on what they returned (>= 0.18.0); on a fault
  // or script-only response only `repeat` applies (the rest lands in _rift.warnings). decorate /
  // shellTransform / a function-string latency are scripting surfaces: allowInjection (spawn) or
  // --allow-injection (remote) or the engine answers 400 invalid injection; embedded FFI is ungated.
  // Execution order in-engine (>= 0.18.0, Mountebank's): wait → lookup → copy →
  // shellTransform → decorate (<= 0.17.0: wait → copy → lookup → decorate → shellTransform). For another
  // order send the `behaviors` array form alone via raw() — the engine ignores it next to _behaviors.
  latency(ms: number | { min: number; max: number } | string): this;
      // number → wait: N; range → wait: {min,max}; string = JS fn source → wait: "function() {...}"
      // number: non-negative integer; range: 0 <= min <= max — anything else throws InvalidDefinition
      // (the engine refuses it at the config door since 0.18.0). repeat(n): positive integer.
      // 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;      // "${ROW}"; the response text reads "${ROW}[column]". Engine >= 0.18.0 never
                     // re-scans substituted text (rift#1203): a "${ROW}[secret]" the CLIENT sent —
                     // via copy, a template or a header — is served literally where Mountebank
                     // 2.9.1 expanded it. "${ROW}[${COL}]" with a copy into ${COL} still works.
}

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 — one entry per name,
                                                                      // case-insensitive; a second spelling throws
  rewritePath(from: string, to: string): this;                        // wire: pathRewrite (Rift ext)
  /** @deprecated */ clientCert(pem: { key: string; cert: string }): this; // no effect: Rift's proxy sends no
                                                                      // client certificate; dropped on parse
  latency(...) / repeat(...) / decorate(...) etc.                     // _behaviors on a proxy: engine >= 0.18.0 runs
                                                                      // them on the upstream response (<= 0.17.0 ignored)
  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). What the engine does with them is a version fact: ≥ 0.18.0 runs the block on the upstream’s response before recording it (rift#1189) — the generated stub holds the transformed result, a proxyOnce replay is not transformed again, a wait is not counted in addWaitBehavior’s latency, and a failing behavior records nothing; ≤ 0.17.0 accepted the block and ignored it. decorate, shellTransform and a function-string latency are scripting surfaces and need allowInjection on a proxy response too (rift#1181).

Recording an origin behind a private CA (upstreamTrust)

The engine’s proxy client trusts the OS certificate store; an origin issued by a private or corporate CA fails with invalid peer certificate: UnknownIssuer until that CA is added. Since engine 0.18.0 the trust is a per-engine option, one policy per engine, set where the engine is started:

type UpstreamTrust =
  | { caFile: string }      // PEM file, APPENDED to the OS trust store (path resolved at option time)
  | { caPem: string }       // the same anchor inline — embedded only, the CLI has no inline flag
  | { skipVerify: true };   // accept any certificate; development only, the SDK emits a process warning

await using engine = await rift.spawn({ upstreamTrust: { caFile: 'certs/corp-ca.pem' } });
await using engine = await rift.embedded({ upstreamTrust: { caPem: fs.readFileSync('corp-ca.pem', 'utf8') } });
Variant spawn embedded connect
{ caFile } --upstream-ca-file upstreamCaFile serve option —
{ caPem } throws InvalidDefinition (pass caFile) upstreamCaPem serve option —
{ skipVerify: true } --upstream-tls-skip-verify upstreamTlsSkipVerify serve option —

connect() takes no trust: it belongs to whoever started that engine. The same trust governs https: config sources and the intercept tunnel’s WebSocket relay (§7).

Older engines are refused, never ignored. On spawn the resolved binary’s version is probed (like intercept.auth): below 0.18.0, or unrecognisable, EngineVersionError. On embedded the gate is presence, never the version — a cdylib before 0.17.0 has no list and ignores an unknown serve key rather than refusing it; from 0.17.0 on the list is authoritative — so the exact key must appear in rift_build_info().serveOptions (engine.buildInfo().serveOptions on the embedded transport), else EngineUnavailable. Setting upstreamTrust also starts the embedded loopback admin plane eagerly: the engine installs the trust inside rift_serve_admin, and an imposter or intercept listener created before that call would keep the default trust.

Not SSL_CERT_FILE: the engine honours it, but it replaces the trust store, so pointing it at a lone private CA silently drops every public root. caFile appends, which is why it exists.

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). Every constructor names an engine (rhai/js explicitly, *File by extension), so imposter().scriptEngine({ defaultEngine }) only decides the engine of a bare { code } script sent through raw() or fromJson (engine ≥ 0.18.0 honours it, rift#1159; earlier engines ran such a script as Rhai regardless). _rift.script is a scripting surface: it needs allowInjection on spawn / --allow-injection on a remote engine, and the embedded FFI is not gated. A _behaviors block beside a script-only response applies only repeat.

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; mutualAuth
                                                                             // REQUIRES a client cert on engine >= 0.18.0
  requireClientCertificate(caPems?: readonly string[]): this; // https + mutualAuth; with anchors also
                                                     // rejectUnauthorized + ca (1 → string, N → array).
                                                     // Gated in create()/replaceAll(): engine < 0.18.0 or an
                                                     // unreadable version → EngineVersionError (fail closed;
                                                     // versionCheck 'off' skips, 'warn' lets an unreadable
                                                     // version through). The raw engine.admin surface is not gated.
  record(): this;  allowCORS(): this;
  /** @deprecated */ recordMatches(): this;  // no effect on any engine; >= 0.18.0 reports config_key_ignored
  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'; ttlSeconds?: number;
                   flowIdSource?: 'imposter_port' | `header:${string}` }): this;
  flowIdFromHeader(name: string): this;     // sugar: flowIdSource: `header:${name}`
  /** @deprecated */ metrics(port?: number): this;  // no effect: metrics are process-wide (SpawnOptions.metricsPort);
                                                   // >= 0.18.0 reports config_key_ignored
  scriptEngine(cfg: { defaultEngine?: 'rhai' | 'javascript' | 'js'; timeoutMs?: number }): this;
      // defaultEngine is honoured by engine >= 0.18.0 (rift#1159) for a script that names no engine:
      // own `engine`, else `file` extension, else this, else rhai. Script.* always names one, so it
      // only reaches a bare { code } script sent via raw()/fromJson
  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
  status?: number;                // how it was answered (engine >= 0.18.0); undefined = not recorded
                                  // (still in flight, handling errored, or an older engine)
  latencyMs?: number;             // ms; 0 is a real reading, undefined = not recorded
  node?: string;                  // set only by a clustered journal; always undefined on a single engine
  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 | String[] — one line per value, body: any JSON; engine >= 0.18.0), 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[]) sent as one header line per value (engine >= 0.18.0; an older engine answers with an opaque serde error). An empty array throws — it would send nothing
a second spelling of a header name (Content-Type + content-type, e.g. okJson().header('content-type', …)) throws InvalidDefinition naming both spellings — the engine merges them into one multi-value header and serves it twice
header name that is not an HTTP token ('X Bad', ' x') throws InvalidDefinition — the engine skips the header with only a log line
_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 value containing a control character (CR/LF, NUL, DEL, …; HTAB is fine) throws InvalidDefinition — the engine drops that value with only a log line; for CR/LF that is the response-splitting guard

Use forward() to an imposter when you need 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 manages Content-Length and the Connection header itself on every served response (tunnels are keep-alive since 0.18.0).

Tunnel semantics (engine ≥ 0.18.0)

What the tunnel does with traffic no rule claims is the engine’s contract, not the SDK’s; nothing here changes an SDK call, but a system under test behaves differently through the tunnel than it did on ≤ 0.17.0:

  • WebSocket (rift#997): an Upgrade: websocket handshake no rule matches is relayed to the origin and the upgraded connection is pumped both ways until either side closes. A rule that does match the handshake serves its response and no upgrade happens — serve() on it is how you simulate the endpoint refusing. Frames are never inspected, matched or recorded (WebSocket mocking is out of scope). Only websocket takes this path; h2c and any other Upgrade value are unchanged. The origin leg uses the process-wide outbound trust (upstreamTrust, §5.6).
  • HTTP/2 (rift#996): ALPN offers h2, http/1.1 and the tunnel serves whichever is negotiated (≤ 0.17.0 pinned http/1.1, silently downgrading an h2 client). A tunnel serves at most 32 concurrent h2 streams, so the body cap below stays a per-tunnel bound of 32 MiB. A rule predicating on the host header fires over h2 as well. RIFT_DISABLE_HTTP2=1 in the engine’s environment forces HTTP/1.1; prior-knowledge h2c through the tunnel is not supported. undici defaults to HTTP/1.1, so an in-process fetch through interceptDispatcher stays on h1 unless you pass { allowH2: true }.
  • Keep-alive (rift#993): a CONNECT tunnel carries many requests (≤ 0.17.0 set connection: close on every response, defeating the SUT’s connection pool). The 405 for a non-CONNECT request and the 407 for failed proxy auth are answered before a tunnel exists and still close.
  • Request bodies (rift#991): the TLS-terminated tunnel is served by hyper, so a chunked request body reaches rule matching and forwarding (it used to count as bodyless). A body over 1 MiB is refused with 413 Payload Too Large and is neither matched nor forwarded (it used to be truncated at the cap and forwarded as if complete); a body exactly at the cap is served. Request header values that are not UTF-8 are skipped rather than matched as U+FFFD text; response header values relay verbatim.

Per-transport availability (documented, typed):

  • embedded — engine.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, by design: the SDK does not start or reconfigure a TLS-intercepting listener on an engine it did not start (the engine models that as the operator’s decision too — its exposure policy is a flag, not a request field). With no port, engine.intercept() asks GET /intercept (engine ≥ 0.13.0) for the listener’s port and attaches there on the admin hostname; 404 → InterceptUnavailable (“start the server with –intercept-port”). An explicit intercept({ port }) attaches to that port through the GET /intercept/rules probe, which every engine at the 0.12.0 floor answers.

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, { allowH2? }): Promise<ProxyAgent> wired with proxy URL + CA; allowH2: true passes undici’s option through so the in-process client negotiates h2 with the tunnel (engine ≥ 0.18.0), otherwise undici’s HTTP/1.1 default applies.

8. Transports

8.1 Remote / spawn

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

interface SpawnOptions {
  port?: number; host?: string;                   // --host: an IP literal (the engine refuses a hostname);
                                                  // IPv6 (`::1`) needs engine >= 0.18.0 and is bracketed
                                                  // in every URL the SDK builds from it
  loglevel?: 'trace'|'debug'|'info'|'warn'|'warning'|'error'; logfile?: string;
                                                  // case-insensitive; anything else throws InvalidDefinition
                                                  // before the binary is resolved (engine >= 0.18.0 aborts on
                                                  // it). An inherited RUST_LOG supersedes --loglevel
  version?: string; binaryPath?: string; env?: Record<string, string>; mirror?: string;
  startupTimeoutMs?: number; shutdownTimeoutMs?: number;
  stubWarnings?: 'ignore' | 'warn' | 'fail';      // default 'warn'; see §3.3 "Engine warnings"
  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; origin?: string;
  ipWhitelist?: string[];                         // accepted, NOT enforced (engine logs a WARN only):
                                                  // restrict with localOnly / apiKey / a network policy
  datadir?: string; configfile?: string;          // datadir files are <port>.json declaring that port
                                                  // (else skipped at start, reload 500s); configfile
                                                  // imposters and edits to them are never written there
  noParse?: boolean;                               // --no-parse: load configfile verbatim, no EJS
                                                  // (needs configfile; engine >= 0.18.0 fails the
                                                  // spawn on an unevaluated tag, this is the way through)
  defaultTls?: { cert: string; key: string };     // --default-tls-cert/key
  metricsPort?: number;
  upstreamTrust?: UpstreamTrust;                  // --upstream-ca-file | --upstream-tls-skip-verify (engine >= 0.18.0,
                                                  // version-probed); caPem throws — see §5.6
  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, caCertPath, caKeyPath } }) yes — startup configuration of the listener
rift.embedded() then engine.intercept({ auth, caCertPath, caKeyPath }) yes — the listener is started in-process from these options
rift.spawn(...)/rift.connect(...) then engine.intercept({ auth }) or ({ caCertPath, caKeyPath }) no — throws one InterceptUnavailable naming every startup-only option present

That last row is a design decision, not an engine limitation: the engine has accepted these on POST /intercept since v0.13.0 (auth since v0.17.0). On the spawn and remote transports engine.intercept() attaches to a listener the engine’s operator already started, and the SDK does not start or reconfigure a TLS-intercepting listener on an engine it did not start. Rather than accept auth or a CA and silently drop them — handing back a handle to an unauthenticated proxy, or one signed by a CA the caller never supplied — the SDK refuses and points at rift.spawn({ intercept: { … } }) and rift.embedded(). host and port stay legal there: they are attach parameters and are used.

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. Since engine 0.18.0 the process drains on the SIGTERM that close() sends — it stops accepting, gives in-flight connections a bounded grace of about three seconds at most, leaves the datadir alone and exits 0 (rift#1155) — so shutdownTimeoutMs (default 5000) must stay above ~3 s, or close() escalates to SIGKILL a shutdown that was about to finish cleanly.

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 < minEngineVersion → EngineVersionError (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';
  stubWarnings?: 'ignore' | 'warn' | 'fail';  // default 'warn'; see §3.3 "Engine warnings"
  requireFeatures?: string[];
  upstreamTrust?: UpstreamTrust;    // rift_serve_admin upstreamCaFile|upstreamCaPem|upstreamTlsSkipVerify;
                                    // gated on buildInfo().serveOptions; starts the admin plane
                                    // eagerly — see §5.6
  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. A spawn failure rejects create() (#28).

The returned RiftServer is an emitter for the engine child process, typed by RiftServerEvents (#176): server.on(event, listener), once and off accept only these events and listener shapes.

Event Listener arguments When
'error' (error: Error) the engine process errors after startup
'exit' (code: number \| null, signal: NodeJS.Signals \| null) the engine process exits
'stdout' (chunk: string) the engine writes to stdout
'stderr' (chunk: string) the engine writes to stderr

Node’s rule for 'error' applies: emitted with no listener it throws ERR_UNHANDLED_ERROR and takes the host down. Subscribe to 'error' when a post-startup engine failure must not be fatal.

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#494 server-side verification endpoint (full-fidelity §6 verify)