Migrating from Mountebank
@rift-vs/rift ships two coexisting APIs in the same process, permanently:
- Mountebank compat —
create()(also at@rift-vs/rift/compat) spawns the engine and speaks the exact Mountebank wire protocol: raw JSON imposters overPOST /imposters, thembCLI, existing REST tooling. Nothing about this contract is deprecated or scheduled for removal. - Typed DSL —
imposter()/stub()/onGet()/… builders,rift.embedded()/rift.spawn()/rift.connect(), andimposter.verify(...).
You do not have to migrate. create() and the typed DSL can be adopted incrementally, stub by
stub, in the same codebase — a raw Mountebank imposter JSON round-trips through fromJson and can
be mixed with DSL-built imposters on the same engine. This document is a reference for translating
concepts when (and only as fast as) you want to.
Every operator, behavior, fault, and response type below maps to a real, exported DSL symbol —
this table is checked against docs/design/sdk-api.md §5 (the full grammar
reference) for completeness.
Imposter creation
| Mountebank (raw JSON / REST) | Typed DSL |
|---|---|
POST /imposters with { port, protocol: 'http', stubs: [...] } |
engine.create(imposter().port(n).stub(...)) |
mb.create({ port, ... }) (or @rift-vs/rift’s old create()) |
create({ port, ... }) — unchanged, see the per-transport quick starts |
{ protocol: 'https', cert, key, mutualAuth: true } |
imposter().https({ cert, key, mutualAuth: true }) |
{ recordRequests: true } |
imposter().record() |
{ recordMatches: true } |
imposter().recordMatches() |
{ allowCORS: true } |
imposter().allowCORS() |
{ defaultResponse: { statusCode: 404 } } |
imposter().defaultResponse(status(404)) |
{ name: 'users' } |
imposter('users') |
| Raw imposter JSON you already have | fromJson(json) — see Escape hatches |
Predicates
Every Mountebank predicate operator, params, and selector is covered:
| Mountebank JSON | Typed DSL |
|---|---|
{ equals: { path: '/x' } } |
req.path(equals('/x')), or bare req.path('/x') (a bare value means equals) |
{ deepEquals: { body: {...} } } |
req.body(deepEquals({...})) |
{ contains: { headers: { Accept: 'json' } } } |
req.header('Accept', contains('json')) |
{ startsWith: { path: '/api' } } |
req.path(startsWith('/api')) |
{ endsWith: { path: '.json' } } |
req.path(endsWith('.json')) |
{ matches: { path: '^/users/\\d+$' } } |
req.path(matches('^/users/\\d+$')) (or a RegExp) |
{ exists: { query: { debug: true } } } |
req.query('debug', exists()) |
{ exists: { query: { debug: false } } } |
req.query('debug', notExists()) |
{ and: [p1, p2] } |
and(p1, p2) |
{ or: [p1, p2] } |
or(p1, p2) |
{ not: p } |
not(p) |
{ inject: 'function(config){...}' } |
injectPredicate('function(config){...}') |
{ ..., jsonpath: { selector: '$.a.b' } } |
req.body(equals(v).jsonpath('$.a.b')) |
{ ..., xpath: { selector: '//a', ns: {...} } } |
req.body(equals(v).xpath('//a', ns)) |
{ ..., caseSensitive: true } |
contains(v).caseSensitive() |
{ ..., keyCaseSensitive: true } |
contains(v).keyCaseSensitive() |
{ ..., except: '\\s' } |
contains(v).except('\\s') |
{ equals: { method: 'GET', path: '/x' } } |
onGet('/x') — combined method+path openers exist for every verb (onPost/onPut/onDelete/onPatch/onHead/onOptions/onAny/on(method, path)) |
Params (:id) needing extraction |
onGet('/users/:id') seeds both an anchored regex path predicate AND route_pattern for template/script extraction |
req.method/req.path/req.body/req.header(name, ...)/req.query(name, ...) bind a matcher to
a field; stub().withMethod(...)/.withPath(...)/.withBody(...)/.withHeader(...)/
.withQuery(...) do the same, ANDed onto the stub in call order. stub().when(predicate) accepts a
raw wire.Predicate as an escape hatch.
Responses
| Mountebank JSON | Typed DSL |
|---|---|
{ is: { statusCode: 200, body: {...} } } |
okJson({...}) (sets Content-Type: application/json too), or ok({...})/status(200, {...}) |
{ is: { statusCode: 201 } } |
created() |
{ is: { statusCode: 204 } } |
noContent() |
{ is: { statusCode: 400, body } } |
badRequest(body) |
{ is: { statusCode: 404, body } } |
notFound(body) |
{ is: { headers: { 'Set-Cookie': ['a', 'b'] } } } |
.header('Set-Cookie', ['a', 'b']) (multi-value) |
{ is: { body: '<base64>', _mode: 'binary' } } |
.binaryBody(uint8ArrayOrBase64String) |
{ is: {...}, _rift: { templated: true } } |
.templated() |
stubs[0].responses with N entries (Mountebank cycles them per matching call) |
.willReturn(r1, r2, r3) — repeated .willReturn() calls append to the same cycle |
{ inject: 'function(config){...}' } |
inject('function(config){...}') |
Behaviors
| Mountebank JSON | Typed DSL |
|---|---|
_behaviors: { wait: 500 } |
.latency(500) |
_behaviors: { wait: 'function(){ return 500; }' } |
.latency('function(){ return 500; }') — never emit { inject: ... } here; the engine’s WaitBehavior parser only accepts the fn-string form |
| Rift extension: latency range | .latency({ min: 100, max: 500 }) |
_behaviors: { repeat: 3 } |
.repeat(3) |
_behaviors: { decorate: 'function(req,res){...}' } |
.decorate('function(req,res){...}') |
_behaviors: { shellTransform: ['cmd1', 'cmd2'] } |
.shellTransform('cmd1', 'cmd2') |
_behaviors: { copy: [{ from, into, using }] } |
.copy({ from, into, using }) (or an array) |
_behaviors: { lookup: [{ key, fromDataSource, into }] } |
.lookup({ key, fromDataSource, into }) (or an array) |
Any other _behaviors key |
.behavior({ ...raw }) — shallow-merge escape hatch |
Execution order in-engine: copy → lookup → decorate → wait.
Faults
Mountebank’s native TCP fault kinds, plus Rift’s probabilistic _rift.fault extension:
| Mountebank JSON | Typed DSL |
|---|---|
{ fault: 'CONNECTION_RESET_BY_PEER' } |
fault(Fault.CONNECTION_RESET) |
{ fault: 'EMPTY_RESPONSE' } |
fault(Fault.EMPTY_RESPONSE) |
{ fault: 'RANDOM_DATA_THEN_CLOSE' } |
fault(Fault.RANDOM_DATA) |
{ fault: 'MALFORMED_RESPONSE_CHUNK' } |
fault(Fault.MALFORMED_CHUNK) |
Rift extension: _rift.fault.latency (probabilistic) |
ok(body).withFault(Fault.latency(ms, { probability: 0.3 })) |
Rift extension: _rift.fault.error (probabilistic) |
ok(body).withFault(Fault.error({ status: 500 }, { probability: 0.1 })) |
Rift extension: _rift.fault.tcp (a native kind composed onto an is/proxy/inject response, instead of replacing it) |
ok(body).withFault(Fault.tcp(Fault.CONNECTION_RESET)) |
fault() is a bare top-level response (no is block, mutually exclusive with one); .withFault()
composes on top of any is/proxy/inject response and merges by kind (latency + error + tcp can
coexist; a second fault of the same kind throws rather than silently overwriting).
Proxy
| Mountebank JSON | Typed DSL |
|---|---|
{ proxy: { to: 'http://upstream', mode: 'proxyOnce' } } |
proxyTo('http://upstream').proxyOnce() |
{ proxy: { mode: 'proxyAlways' } } |
.proxyAlways() |
Rift extension: proxyTransparent |
.proxyTransparent() |
{ proxy: { predicateGenerators: [...] } } |
.generatePredicates({ matches: { path: true, method: true } }) |
{ proxy: { addWaitBehavior: true } } |
.addWaitBehavior() |
{ proxy: { addDecorateBehavior: 'fn' } } |
.addDecorateBehavior('fn') |
{ proxy: { injectHeaders: { 'X-Foo': 'bar' } } } |
.injectHeader('X-Foo', 'bar') (accumulates) |
Rift extension: { proxy: { pathRewrite: { from, to } } } |
.rewritePath(from, to) |
{ proxy: { key, cert } } (mTLS to upstream) |
.clientCert({ key, cert }) |
proxyTo(...).latency(500) |
Same call — behaviors are legal on a proxy response (the pre-DSL bridge silently dropped these; the current builder doesn’t) |
Scripts (inject → _rift.script)
| Mountebank JSON | Typed DSL |
|---|---|
{ inject: 'function(config){ return {statusCode:202}; }' } |
inject('function(config){ return {statusCode:202}; }') (unchanged — still Mountebank inject) |
Rift extension: _rift.script: { engine: 'rhai', code } |
script(Script.rhai(code)) |
Rift extension: _rift.script: { engine: 'js', code } |
script(Script.js(code)) |
Rift extension: _rift.script: { file } (rhai) |
script(Script.rhaiFile(path)) |
Rift extension: _rift.script: { file } (js) |
script(Script.jsFile(path)) |
Rift extension: _rift.script: { ref: 'name' } + _rift.scripts.name |
script(Script.ref('name')) + imposter().registerScript('name', Script.rhai(code)) |
Scenarios
| Mountebank JSON | Typed DSL |
|---|---|
A stub with scenarioName, required_scenario_state, new_scenario_state, predicates, responses |
One .when(state, stub).respond(...).goTo(next) step |
| Several such stubs forming a state machine | scenario('checkout').startingAt('Started').when('Started', onPost('/cart')).respond(ok()).goTo('InCart')... |
GET /imposters/{port}/scenarios |
imposter.scenarios(flowId?) |
PUT .../scenarios/{name} |
imposter.setScenarioState(name, state, flowId?) |
DELETE .../scenarios (reset) |
imposter.resetScenarios(flowId?) |
scenario().build() flattens to a wire.Stub[]; imposter().scenario(builder) appends those stubs
in call order (interleaved with plain .stub() calls). .when() snapshots the stub immediately —
mutating the passed builder afterward never rewrites an already-committed step.
Verification
Mountebank has no first-class “did this happen N times” assertion — you dig through
GET /imposters/{port} (or ?replayable) and inspect requests/numberOfRequests yourself. The
typed DSL turns that into one call:
| Mountebank (manual) | Typed DSL |
|---|---|
GET /imposters/{port} → filter requests yourself |
imposter.recorded(filter?) — mapped, ergonomic RecordedRequest[] |
| Same, then count-check by hand | imposter.verify(match, times(n)) — throws VerificationError with a WireMock-style near-miss diff on failure |
| — (no equivalent) | atLeast(n) / atMost(n) / between(min,max) / never() count matchers |
DELETE /imposters/{port}/savedRequests |
imposter.clearRecorded() |
| — (no equivalent) | imposter.requests({ signal, match }) — an AsyncIterableIterator polling the journal (default 250ms) |
verify/recorded evaluate predicates client-side (no server-side verify endpoint exists yet
upstream): equals/deepEquals/contains/startsWith/endsWith/matches/exists/and/or/
not, caseSensitive/keyCaseSensitive/except, and a jsonpath subset (dot + bracket + numeric
index) are supported; xpath and inject predicates throw UnsupportedPredicateError naming the
operator, rather than silently matching nothing.
Escape hatches
You never have to fight the typed layer:
fromJson(json)— parse and validate a raw Mountebank/Rift imposter JSON (a single imposter or a{ imposters: [...] }envelope) with no rewriting: no key renaming, no field injection, no dropped unknown keys, and an explicitportis respected exactly.engine.create(fromJson(json))drops straight in..raw(patch)— every builder (imposter(),stub(), response builders) has a.raw(patch)that shallow-merges a plain object patch at the top level, last-wins, after every other call — for wire fields with no dedicated method yet.wire.*types —import { wire } from '@rift-vs/rift'gives you the exact wire grammar (wire.Imposter,wire.Stub,wire.Predicate,wire.StubResponse, …) for hand-building anything the DSL doesn’t cover, or for typing afromJsonresult.
Persistence & distributed state
| Mountebank | Rift |
|---|---|
mb --datadir <dir> / mb.create({ datadir }) |
create({ datadir }) — or rift.spawn({ datadir }) on the native transport |
datadir has full parity: imposters created or mutated through the admin API are persisted as
{port}.json under the directory and reloaded when a server starts against the same datadir, so
imposter state survives a restart.
Custom impostersRepository (+ its redis bag) has no direct equivalent. Mountebank loads a
Node module in-process to back its imposter store; Rift’s engine is a native binary and cannot load
one, so create({ impostersRepository }) / create({ redis }) throws
UnsupportedCreateOptionError rather than silently degrading to an in-memory, single-process
server. Migrate a repository-backed deployment as follows:
- Restart persistence (imposters survive a bounce) →
datadir, as above. - Distributed scenario / flow state (shared across instances) → per-imposter
_rift.flowState.backend: "redis"with aredis: { url }block — via the DSL’sflowState({ backend: 'redis', redis: { url } }). This is imposter-scoped state, not an imposter store. - Multi-instance without shared state → run independent Rift nodes behind a sticky/affinity
load balancer keyed on the flow id (
flowState({ flowIdSource: 'header:X-Flow-Id' }), or theflowIdFromHeader('X-Flow-Id')sugar). When each logical flow — a test run, a session, a tenant — pins to one node, scenario state, response cycling, and request verification are all already correct per-node, with no shared infrastructure and no cluster code. This, rather than rebuilding a Redis imposter repository, is the recommended replacement for a Redis-synced Mountebank fleet; it stops working only when a single flow genuinely sprays across nodes. - Multi-instance imposter-CRUD sync (one process’s
POST /impostersbecoming visible on another — the pub/sub half of a custom Redis repository) is a non-goal: Rift provides no cross-instance imposter replication today. A deployment that needs it keeps that coordination layer above the admin API (it is plain REST and works unchanged). Engine-side config-sync is a possible future direction, not a commitment — don’t plan a migration around it.
Things Rift rejects that Mountebank allowed
- Non-HTTP(S) protocols. Rift serves HTTP/HTTPS (and h2c); Mountebank’s
tcp/smtpprotocol imposters have no Rift equivalent. - Custom
impostersRepository/redisoncreate(). No in-process repository module; see Persistence & distributed state for the migration path. - Numeric and boolean header values — engines ≤ v0.14.0 only. Mountebank accepts
"Content-Length": 124(a JSON number); engines up to v0.14.0 reject the entire imposter POST with a 400 (rift#754). Fixed in engine v0.15.0 (rift#757) — so unlike the two entries above, this one is version-scoped rather than a standing restriction, and this SDK’s default pin (DEFAULT_ENGINE_VERSION) already points at v0.15.0. You still hit it whenever binary resolution lands on an older engine: one installed on PATH, an explicitbinaryPath/RIFT_BINARY_PATH, or a pinnedversion:— none of which are version-checked (the PATH probe confirms a candidate is Rift, not which version it is). Note the SDK does not stringify these values for you: the same 400 occurs throughcreate()and the typed DSL, and equally when you POST raw imposter JSON straight to an older engine’s admin port. On an affected engine, stringify non-string header values ("124") before POSTing; otherwise upgrade the engine.
See also
docs/design/sdk-api.md— the full API design reference (types, transport internals, the issue map this SDK was built from)- README.md — quick starts, feature tour, env var reference
docs/monorepo-migration.md— moving fromrift/packages/rift-node