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
when(request) → respond(responses…)— the RFC-003 shared mental model. Builders are synchronous and pure;awaitappears at exactly two places: engine acquisition andengine.create(...)(the Testcontainers pattern).- 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.
- Wire model is the escape hatch, never the fight —
fromJson/.raw()accept raw JSON verbatim (explicit ports respected); every builderbuild()s to plain wire types. - 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). - 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 usingall target modern Node; both testkit targets are ESM-native. CJS consumers use dynamicimport(). - Two packages, one repo (npm workspaces, #39 — realizing the original design): core
@rift-vs/riftlives inpackages/rift-coreand has no dependencies at all; the embedded transport is the separate@rift-vs/rift-embeddedpackage (packages/rift-embedded, versioned in lockstep) carryingkoffias a real dependency. Core reaches it only through the dynamicimport('@rift-vs/rift-embedded')insiderift.embedded()—connect/spawnusers 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./internalsubpath (no semver guarantees — it exists for same-version embedded backends), keeping the public root surface curated. The former./embeddedsubpath is retired; its surface is the embedded package’s root export. package.jsonexports as shipped (typesfirst 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/ServerInfofromsrc/types.ts) left the root — root names belong to the DSL + wire model. All butServerInfowere later deleted outright (#155): theiris.statusCode: numbercontradicted 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: websockethandshake 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). Onlywebsockettakes this path;h2cand any otherUpgradevalue are unchanged. The origin leg uses the process-wide outbound trust (upstreamTrust, §5.6). - HTTP/2 (rift#996): ALPN offers
h2, http/1.1and the tunnel serves whichever is negotiated (≤ 0.17.0 pinnedhttp/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 thehostheader fires over h2 as well.RIFT_DISABLE_HTTP2=1in 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-processfetchthroughinterceptDispatcherstays on h1 unless you pass{ allowH2: true }. - Keep-alive (rift#993): a
CONNECTtunnel carries many requests (≤ 0.17.0 setconnection: closeon every response, defeating the SUT’s connection pool). The405for a non-CONNECTrequest and the407for 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 Largeand 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)callsrift_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 throwsInterceptUnavailablewith 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()asksGET /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 explicitintercept({ port })attaches to that port through theGET /intercept/rulesprobe, 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>/cmdlineand is captured bypsand 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 ambientRIFT_INTERCEPT_AUTH, mirroring howapiKeybeatsMB_APIKEY. - A spawn passing
authprobes the resolved binary’s--versionbefore starting it and throwsEngineVersionErrorbelow 0.17.0, or when the binary reports no recognizable version. Older engines have no--intercept-authflag, 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 insiderift.embedded(); koffi is a real dependency of that package, not of core. - One dedicated
worker_threadsWorker 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 withrift_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/savedProxyResponsesclear, enable/disable) routes through a lazily started in-process admin plane (rift_serve_adminon127.0.0.1:0, randomapiKey), started at most once. Imposter creation always goes through FFI — this bypasses the admin plane’sallowInjection: falsedefault, so script/inject stubs work embedded with no flag.list()/get()are served from a local registry (port → submitted config) merged withrift_recordedcounts. Upstream: rift#491 (FFI admin long-tail symbols — retires the bridge) and rift#492 (allowInjectionoption onrift_serve_admin). - Preflight:
rift_build_infomissing symbol →NativeLibraryError(“ABI v1 library, need v2”); version <minEngineVersion→EngineVersionError(orconsole.warnwithversionCheck: 'warn').requireFeatures: ['javascript']asserts compiled-in features. - CI: the embedded conformance lane runs against the built
dist/(the worker resolves./worker.jsrelative to the compiled module;jest.embedded.config.jsremapssrc/imports todist/, #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 }>;
engineis 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 sharedconnectengine remains a documented pattern, not automated.- The test-scoped
enginethe test receives is a proxy that records everycreate()andreplaceAll(); 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) |