Most mock servers are frameworks you wire up. databehave-kit is a single
JSONC file you point at an OpenAPI document — declared routes win, missing
paths fall back to the spec, and a header flips any route to a 4xx/5xx
without touching code.
Bundles databehave so the same
file can declare the route and the deterministic body it returns. The HTTP
framework underneath is hidden: handlers see a stable
(req) => { json | text | html | raw | empty } signature.
Opt-in extras (all default-off, additive): an admin panel + REST API for
sticky error overrides at /databehave, per-request x-mock-* injection
headers, file-backed scenarios, and a one-line-per-request access log.
npm install databehave-kit
# or: pnpm add databehave-kit
# or: yarn add databehave-kitRequires Node.js ≥ 18.17. JSON OpenAPI documents only — convert
openapi.yaml to openapi.json in your build step.
Maintained by @ochairo. Breaking changes follow semver and are noted in CHANGELOG.md. Issues and PRs welcome.
One minimal snippet per feature. They compose freely inside a single
databehave.config.jsonc.
1. Declarative routes. Route keys are JSON, not app.get(...) calls.
2. OpenAPI fallback. Paths absent from endpoints are served from your
OAS document. Declared paths always win.
{ "openapi": "./openapi.json", "basePath": "/api/v1", "endpoints": { /* overrides only */ } }3. Mock-mode middleware. Force any route to a status by config — bodies
come from the OAS response schema. Opt in to header-driven overrides
(x-mock-status) for ad-hoc curl-based testing.
{
"mockMode": {
"enabled": true,
"allowHeaderOverride": true, // optional, default false
"pathOverrides": { "GET /api/v1/products": 500 }
}
}# With allowHeaderOverride: true the header forces the status per request.
curl -H 'x-mock-status: 500' http://localhost:8000/api/v1/products4. Env interpolation in JSONC. ${VAR} and ${VAR:default} work on any
string value. Comments and trailing commas are accepted.
{ "server": { "host": "${MOCK_HOST:127.0.0.1}", "port": "${MOCK_PORT:8000}" } }5. Programmatic API. Same config object, no JSONC file required.
import { createDataBehaveKitServer, defineDataBehaveKit } from 'databehave-kit'
const server = createDataBehaveKitServer(defineDataBehaveKit({
routes: {
'GET /api/v1/ping': () => ({ json: { ok: true } }),
'POST /api/v1/echo': async ({ json }) => ({ json: await json() }),
},
}))5b. Handler-overrides for vitest/jest. Pass pre-resolved handlers
through loadDataBehaveKitJsonConfig to skip dynamic import() — essential
for running JSONC configs whose handlers are .ts source under plain
Node. Keys tolerate ./ prefix and file-extension differences.
import { loadDataBehaveKitJsonConfig, createDataBehaveKitServer } from 'databehave-kit'
const modules = import.meta.glob('./src/routes/**/*.ts', { eager: true })
const handlers = Object.fromEntries(
Object.entries(modules).map(([p, m]) => [p, (m as { default: unknown }).default]),
) as Record<string, never>
const { config } = await loadDataBehaveKitJsonConfig('./databehave.config.jsonc', { handlers })
const server = createDataBehaveKitServer(config)6. Web-standard I/O. server.fetch(Request) → Promise<Response> so tests
run in-process without sockets.
const res = await server.fetch(new Request('http://localhost/api/v1/ping'))
// → 200 { "ok": true }Putting it together. Start from the CLI:
npx databehave-kit databehave.config.jsonc
# → [databehave-kit] listening on http://127.0.0.1:8000Optional flags (all opt-in):
# After the server is listening, open the admin UI in the default
# browser. No-op when adminMode is absent or disabled.
npx databehave-kit --open databehave.config.jsoncThe JSONC config's openapi value can point at either a JSON file
or a YAML file (.yaml / .yml) — YAML inputs are transcoded to
JSON on load, so a separate sync-openapi build step is not required.
yaml is an optional peer dependency: if your spec is YAML, also
npm install yaml alongside databehave-kit. The kit declares
exactly one runtime dependency (databehave) — everything else is
opt-in.
The CLI is a thin wrapper around the same run() entry library
consumers can import directly. This is the recommended integration
shape — your project owns its tsx (or other TS loader) invocation,
and the kit stays a normal library import.
// server.ts
import { run } from 'databehave-kit'
await run({
config: './databehave.config.jsonc',
open: true,
})// package.json — use `tsx watch` for dev auto-restart
{ "scripts": { "start": "tsx watch server.ts" } }run() resolves AFTER the server has emitted listening, so the
--open log line still appears in deterministic order. The returned
RunHandle is:
interface RunOptions {
/** Path to the JSONC config. Resolved relative to `process.cwd()`. */
config: string
/** After listening, open the admin URL in the default browser. */
open?: boolean
}
interface RunHandle {
/** Bound HTTP URL, e.g. "http://127.0.0.1:8000". */
url: string
/** Idempotent. Tears down the listener. */
close(): Promise<void>
}The same log lines you see from the CLI (listening on …,
--open: …) come through console.info / console.warn — the kit
deliberately ships no logger abstraction.
The kit does not ship a file-system watcher. Use
tsx watch server.ts(or any process supervisor) in dev — the supervisor respawns the entry file on change, which re-invokesrun()from scratch.
The handler files referenced from databehave.config.jsonc may be
written in TypeScript. The kit ships zero loader machinery; pick a
TS loader (tsx, ts-node --esm, @swc-node/register, …) and
launch your entry file through it.
The recommended pattern is the programmatic API
above — a one-line server.ts invoked through tsx server.ts. The
same tsx invocation transparently resolves both the entry file and
the dynamically-imported .ts route handlers.
If you'd rather not introduce an entry file, the binary also works under a TS loader:
node --import tsx node_modules/.bin/databehave-kit ./databehave.config.jsoncAdd tsx to your project's devDependencies (yarn add -D tsx).
The kit does not list tsx as a runtime dep on purpose.
Default-off. Flipping adminMode.enabled: true mounts an error-injection
panel at ${path} (default /databehave) and starts honouring x-mock-*
request headers. Existing consumers that do not set adminMode see
no behaviour change — admin routes return 404 and x-mock-* headers are
ignored.
The UI is shipped as a static dist/admin/{ui.html, ui.js, ui.css} bundle
built from native Web Components (no runtime UI framework, no CDN
dependencies). It groups operations by OpenAPI tag, lets you inject errors
per route with auto-pre-fill when an override already exists, exposes a
Try-it-out that hits the real endpoint so the injected override applies, and
saves / loads named scenarios to adminMode.scenariosDir. English-only.
A dedicated Global override button in the top bar opens a modal that
owns global-scope overrides (the per-endpoint inject panel only offers
Exact and Path scopes). The right-side active-overrides side panel
flags global rows with a distinct GLOBAL pill and floats them above
path / exact rows; a bottom-right floating pill shows the active
override count. Keyboard shortcuts: /, Cmd/Ctrl+K, Esc, ?.
{
"adminMode": { "enabled": true }
}import { createDataBehaveKitServer } from 'databehave-kit'
const server = createDataBehaveKitServer({
routes: { 'GET /ping': () => ({ json: { ok: true } }) },
adminMode: { enabled: true },
})
await server.listen({ port: 8000, host: '127.0.0.1' })
// stdout: [databehave-kit] admin panel ready at http://127.0.0.1:8000/databehave (dev mock — disable in production)| Field | Default | Notes |
|---|---|---|
enabled |
false |
Opt-in. No surface change unless flipped. |
path |
/databehave |
Mount point. Must start with /. |
ui |
true |
Serve the admin HTML page at ${path}. |
headers |
true |
Honour x-mock-* request headers. |
bind |
loopback-only |
Refuse listen() on non-loopback hosts. Set any to opt out. |
cors |
auto |
* when bind is loopback, same-origin when bind is any. |
allowDestroy |
true |
Honour x-mock-destroy: 1 socket drops. |
openapiBody |
populated by CLI | Raw OAS JSON used by the UI route picker. |
scenariosDir |
${cwd}/mock-scenarios |
Directory used by the file-backed scenarios endpoints. |
| Header | Effect |
|---|---|
x-mock-status: 500 |
{kind:'http-status', status:500} |
x-mock-business-failure: <ASCII msg> |
{kind:'business-failure', message} |
x-mock-business-failure-b64: <base64-utf8 msg> |
unicode-safe variant |
x-mock-business-failure-extra: <base64 JSON> |
merges into extra |
x-mock-body: <base64 JSON> |
{kind:'custom-body', body} |
x-mock-body-status: 422 |
sets the response status |
x-mock-body-content-type: text/plain |
sets the content-type |
x-mock-empty: 1 (+ x-mock-empty-status) |
{kind:'empty-body'} |
x-mock-malformed: 1 (+ x-mock-malformed-status) |
{kind:'malformed-json'} |
x-mock-delay: 2000 |
wraps any other header mode OR delays passthrough |
x-mock-hang: 1 |
never resolve — client timeout / hang detection |
x-mock-destroy: 1 |
drop the socket (gated by allowDestroy) |
Base64 is standard (not URL-safe). Non-ASCII must use the -b64 variant —
HTTP headers reject byte values > 255. Conflicting terminal headers or
invalid base64 / JSON return HTTP 400 with an explicit
{error: "invalid …: <reason>"} body — the typo is loud, not silent.
curl -sS -H 'x-mock-status: 500' http://127.0.0.1:8000/api/ping
# → HTTP 500 {"error":true,"status":500,"injected":"http-status"}Stickies persist in-memory across requests until removed or process restart. Resolution priority: header → sticky exact (METHOD+path) → sticky path → sticky global → passthrough.
| Method | Path | Body | Notes |
|---|---|---|---|
GET |
${path} |
— | admin UI HTML |
GET |
${path}/ |
— | admin UI HTML (trailing-slash variant) |
GET |
${path}/ui.js |
— | admin UI script bundle |
GET |
${path}/ui.css |
— | admin UI stylesheet |
GET |
${path}/openapi-routes |
— | {routes, discoveredAt} for the UI |
GET |
${path}/overrides |
— | {overrides: [...]} |
POST |
${path}/overrides |
{matcher, mode, description?} |
201 with {id, override} |
DELETE |
${path}/overrides/:id |
— | 200 or 404 |
DELETE |
${path}/overrides |
— | clears all; returns {cleared:N} |
GET |
${path}/scenarios |
— | {scenarios: [{name,count,created}]} |
POST |
${path}/scenarios |
{name, overrides[]} |
201 — saves to disk |
GET |
${path}/scenarios/:name |
— | {scenario: {...}} or 404 |
DELETE |
${path}/scenarios/:name |
— | 200 or 404 |
POST |
${path}/scenarios/:name/load |
— | clears + re-adds; {loaded:N} or 404 |
Matchers:
{ "kind": "exact", "method": "POST", "path": "/api/v1/example" }
{ "kind": "path", "path": "/api/v1/example" } // any method
{ "kind": "global" } // every routeA scenario is a named snapshot of the current sticky overrides, persisted
to disk under adminMode.scenariosDir (default ${cwd}/mock-scenarios).
Names are restricted to [A-Za-z0-9_-]{1,64}. Writes are atomic
(tmp + rename); a missing directory is treated as "no scenarios" rather
than an error.
The REST shape is part of the table above (GET/POST/DELETE …/scenarios,
GET/DELETE …/scenarios/:name, POST …/scenarios/:name/load). load
clears the active overrides and re-adds the snapshot atomically.
Admin-mode is a developer tool — not a hardened endpoint. The defaults are
defensive but do not expose adminMode to untrusted networks:
- Startup notice. Every enabled boot logs
admin panel ready at http://…/databehave (dev mock — disable in production)to stdout so misconfiguration is visible. - Loopback-only by default.
bind: 'loopback-only'refusesserver.listen({ host: '0.0.0.0' | LAN-IP })with a clear error. Override withbind: 'any'when binding to LAN is intentional. - CORS auto-policy.
cors: 'auto'returnsAccess-Control-Allow-Origin: *on loopback (so a localhost browser bookmarklet works) and switches tosame-origin(no CORS headers) whenbind: 'any'. Explicitcors: 'any'/'same-origin'/{ origin }override the auto policy. allowDestroy: true. Honoursx-mock-destroysocket drops. Setfalseto reject those requests with503instead.
Two-line opt-in for an existing consumer — add the field and rebuild:
{ /* existing config */, "adminMode": { "enabled": true } }Nothing else changes. Remove the field (or set enabled: false) to return
to the pre-admin behaviour exactly.
Opt-in per-request log line. Off by default — no stdout output and zero per-request overhead until you enable it.
// shorthand: pretty one-liner, auto colours, admin traffic hidden
{ "log": true }Or as an object:
| Key | Type | Default | Notes |
|---|---|---|---|
access |
boolean |
true |
One line per request when true. |
includeAdmin |
boolean |
false |
Include hits to adminMode.path/*. |
colors |
'auto' | 'always' | 'never' |
'auto' |
'auto' honours process.stdout.isTTY. |
format |
'pretty' | 'json' |
'pretty' |
'json' emits one newline-terminated object. |
Pretty sample (colours stripped):
GET /api/v1/health → 200 4 ms · 56
POST /api/v1/widgets/copy → 500 2 ms · 78 [override:http-status]JSON sample:
{"t":"2026-05-28T10:00:00.000Z","method":"GET","path":"/api/v1/health","status":200,"ms":4,"bytes":"56"}Lines go to stdout. The admin enable banner also writes to stdout
(console.info); stderr carries only runtime warnings and errors.
Sticky / header-driven overrides are flagged with
[override:<mode.kind>] (pretty) or "override":"<kind>" (json).
- Design — architecture, dispatch pipeline, why the HTTP framework is hidden.
- Config — full
databehave.config.jsoncreference:endpoints,basePath, env interpolation, CORS,mockMode,adminMode,log. - Admin mode — admin UI, REST endpoints, matchers,
ErrorMode reference,
x-mock-*headers, scenarios, security notes. - OpenAPI — OAS fallback walker, empty-schema and walk-error reporting, deterministic seeding.
- Recipes — error injection, static responses,
programmatic hooks, in-process tests with
fetch(). - CHANGELOG — release notes.
- CONTRIBUTING — local development workflow.
- SECURITY — how to report a vulnerability.
MIT © 2026-present ochairo. See LICENSE.
{ "endpoints": { "GET /health": { "response": { "status": 200, "json": { "ok": true } } }, "GET /products/list": "./src/routes/products/list.js", "POST /products/bulk-update": "./src/routes/products/bulk-update.js" } }