@killki/mock-fast
v0.5.4
Published
Zero-config HTTP mock server with a small, scalable JSON DSL — nested routes, inherited auth, error injection, variable latency, per-user rate limiting, Faker templating, hot reload, and one-command Docker deploy. A lightweight json-server / Mockoon alter
Maintainers
Readme
mock-fast
Declarative mock server with a small, scalable JSON DSL. One command to start, nested-route inheritance, inherited authentication, random errors, variable latency, per-user rate limiting, Faker-powered templating, hot reload.
mock-fast doesn't reinvent the engine: it builds on @mocks-server/main and adds a more concise DSL, route inheritance, and an extension pipeline you grow with a single file.
📦 Ships with an AI skill. After installing, you'll find an Agent Skill under your install folder (
node_modules/@killki/mock-fast/dist/mock-fast/) — the DSL reference — so an assistant like Claude Code can write your mocks. Details below.
Philosophy
- One line to start. Sensible hard-coded defaults — port, host, CORS, hot reload, auth header. If you need something non-standard it goes in the JSON, not in flags.
- The JSON is the source of truth. Edit one file, the server reloads.
- Nesting with inheritance. Declare
requireAuthonce on a protected root and every child inherits it. URLs concatenate. - Scalable. Adding a new extension = one file in
src/extensions/. The pipeline chains them.
Install
npm install --save-dev @killki/mock-fastSource: github.com/MiguelRoot/mock-fast.
Running the CLI.
mock-fastis a local command, not a global one — so run it withnpx mock-fast …(npx finds it inside your project'snode_modules). Plainmock-fast …will fail with "'mock-fast' is not recognized…". Alternatively, add it to yourpackage.jsonscripts (e.g."mock": "mock-fast watch") and runnpm run mock; inside npm scripts the bare name works. All examples below usenpx.
Hello world
Create a mock-fast.json file at the project root:
{
"routes": [
{
"id": "health",
"url": "/health",
"response": { "status": 200, "body": { "ok": true } }
}
]
}Start:
npx mock-fast start[mock-fast] listening on http://127.0.0.1:3001
[mock-fast] admin API on http://127.0.0.1:3110
[mock-fast] DSL: /abs/path/mock-fast.jsonGET http://127.0.0.1:3001/health → {"ok":true}.
Defaults
| Concept | Default |
|---|---|
| DSL file | searches cwd for: mock-fast.json, mocks.json, mock.json |
| HTTP port | 3001 |
| HTTP host | 127.0.0.1 |
| CORS | open |
| Admin API | http://127.0.0.1:3110 (provided by mocks-server) |
| Hot reload | on (the server picks up changes when you save the JSON) |
| Auth header | Authorization with pattern ^Bearer [A-Za-z0-9._-]+$ |
To change any of these, put it in the JSON:
{
"server": { "port": 4000, "cors": false },
"auth": { "headerName": "X-Token", "pattern": "^sk-.+" },
"routes": [ ... ]
}CLI flags are only for one-off overrides:
npx mock-fast start --file ./fixtures/mock.json --port 4000 --no-watchDSL
Every node under routes can be a route, a group, or both.
Flat route
{
"id": "users-list",
"method": "get",
"url": "/api/users",
"response": { "status": 200, "body": { "users": [] } }
}Nested routes
Each node's url is concatenated with its parent's. Children inherit headers and extensions by default. They can override.
{
"url": "/api/protected",
"extensions": { "requireAuth": true },
"response": { "status": 200, "body": { "data": "root" } },
"routes": [
{
"id": "users-list",
"url": "/users",
"response": { "status": 200, "body": { "users": ["a", "b"] } }
},
{
"id": "users-byId",
"url": "/users/:id",
"response": { "status": 200, "body": { "id": "{{params.id}}" } }
}
]
}Final URLs: /api/protected, /api/protected/users, /api/protected/users/:id. All three require Authorization: Bearer ....
Pure groups (namespace)
A node without response only groups — it registers no endpoint. Useful to reuse inheritance:
{
"url": "/api",
"routes": [
{ "id": "login", "url": "/login", "method": "post", "response": { ... } },
{ "id": "logout", "url": "/logout", "method": "post", "response": { ... } }
]
}Inheritance rules
| Field | Inherits | How it combines |
|---|---|---|
| url | yes | concatenation with / |
| extensions | yes | shallow merge (child overrides per key) |
| headers | yes | merge (child wins on collision) |
| method | no | each route declares its own (default get) |
| response / responses | no | when both are absent, the node is a pure group and registers no endpoint |
Conditional responses
A route can declare a single response (the normal case) or a responses[] array with rules that pick which one to serve. Rules, in order:
responses[]is walked top to bottom.- The first entry whose
whenmatches the request is returned. - A response without
whenalways matches — use it as a fallback at the end. - If every entry has a
whenand none matches, mock-fast responds 404 with{ "error": "No response matched", "route": "...", "method": "...", "url": "..." }.
The keys in when follow the same variables as templating: body.X, query.X, headers.X, params.X, token.X. Arbitrary dotted paths are supported (body.user.address.city, body.items.0.id).
Comparison: equality with string coercion. body.dni: 12345673 (a number in the request) matches against "12345673" (a string in the rule). If the request value is an array, it matches when any element equals the expected primitive (handy for tags, roles, etc.). null in the rule matches null or missing values.
Across keys of a single when, the operator is implicit AND. For OR, declare two separate responses.
response (single) and responses (array) cannot coexist on the same node — the schema rejects it.
{
"id": "verify",
"url": "/identity/verify",
"method": "post",
"responses": [
{
"when": { "body.dni": "12345673" },
"status": 200,
"body": { "kind": "success", "name": "Renato" }
},
{
"when": { "body.dni": "12345674" },
"status": 200,
"body": { "kind": "success", "name": "Juan" }
},
{
"when": { "body.dni": "00000000" },
"status": 422,
"body": { "kind": "invalid", "reason": "blacklisted" }
},
{
"status": 200,
"body": { "kind": "failure", "dni": "{{body.dni}}" }
}
]
}Headers are case-insensitive: Express normalizes them to lowercase, use
headers.x-api-key, notheaders.X-Api-Key.No templating in
when: matcher values are literal. Templating still runs over thebodyandheadersof the chosen response.Operators (
gt,regex,in, ...) are not in v1. The DSL shape (when: { "path": value }) is stable; operators will arrive as object-typed values ({ "body.amount": { "gt": 100 } }) without breaking this syntax.
Filtering a list (search)
Sometimes the request carries a search term and the endpoint must return only the matching items — a real filter over a dataset, not a fixed response. Add an optional filter to the route. Your response body stays exactly as you wrote it; filter just trims the chosen array before sending.
{
"url": "/anexos",
"method": "post",
"filter": { "in": "data", "fields": ["titulo", "descripcion"], "by": "body.filtro" },
"response": {
"status": 200,
"body": {
"code": 200,
"status": "success",
"data": [
{ "codigo": "11", "titulo": "Anexo Museo", "descripcion": "11 - ANEXO MUSEO DE LA INQUISICION" },
{ "codigo": "12", "titulo": "Sede Central", "descripcion": "12 - SEDE CENTRAL LIMA" }
]
}
}
}A request body { "filtro": "nex" } returns only the items whose titulo or descripcion contain nex (case-insensitive). { "filtro": "" } or no term → the full list (no filtering).
| Field | Meaning |
|---|---|
| in | Dotted path to the array in the body to filter (e.g. data, result.items). |
| fields | Item fields to search; an item matches if any of them matches. |
| by | Dotted path to the search term in the request (body.filtro, query.q, …). |
| op | contains (default), equals, or startsWith. |
| caseSensitive | false by default. |
Notes:
filteris opt-in — routes without it behave exactly as before.- It runs after templating, so the array can contain
{{...}}values. - If
indoesn't point to an array, the body is sent untouched.
Several filters (AND) + pagination
A real listing usually combines several optional filters (sede AND oficina AND estado) and pagination. Use filters (an array, AND between them) and paginate:
{
"url": "/ubicaciones",
"method": "post",
"filters": [
{ "in": "data", "fields": ["codigoSede"], "by": "body.codigoSede", "op": "equals" },
{ "in": "data", "fields": ["codigoOficina"], "by": "body.codigoOficina", "op": "equals" },
{ "in": "data", "fields": ["codigoEstado"], "by": "body.codigoEstado", "op": "equals" }
],
"paginate": { "of": "data", "page": "body.page", "size": "body.pageSize", "total": "totalRegistros" },
"response": { "status": 200, "body": { "code": 200, "data": [ /* full list */ ], "totalRegistros": 0 } }
}Each filter whose term is empty or missing is skipped — so optional params just don't filter. The order applied is: filter → filters (AND) → paginate.
paginate fields:
| Field | Meaning |
|---|---|
| of | Dotted path to the array to page. |
| page | Dotted path to the 1-based page number (body.page). Missing/invalid → 1. |
| size | Dotted path to the page size (body.pageSize). |
| defaultSize | Used when size is missing (default 20). |
| total | Optional body path where the total count before paging is written (e.g. totalRegistros). |
So with { "codigoSede": "11", "codigoOficina": "", "page": "2", "pageSize": "10" }: filters by sede only (oficina is empty → skipped), then returns page 2 of 10, and writes the matched total into totalRegistros.
Extensions
Each extension activates when it appears in a route's extensions (or a parent's, via inheritance). They run in this order:
requireAuthrateLimiterrorRatedelayRange
Any extension that short-circuits (returns 401, 429, 500) skips the rest and the normal response.
requireAuth: boolean
Checks that the auth header (Authorization by default) matches auth.pattern. If not, 401.
"extensions": { "requireAuth": true }To turn it off on a child that inherited true:
"extensions": { "requireAuth": false }errorRate: number (between 0 and 1)
Rolls a die per request. With probability errorRate, returns 500 without reaching the normal body.
"extensions": { "errorRate": 0.10 }10% failure rate. Useful for testing client retries.
delayRange: [min, max]
Random latency in milliseconds before responding.
"extensions": { "delayRange": [200, 1500] }Each request waits a uniform value in [min, max]. Composes with everything else.
rateLimit
Counts requests per identifier and cuts off when exceeded. Covers two cases with the same shape: uniform limit and per-user override.
"extensions": {
"rateLimit": {
"identifier": "{{token.sub}}",
"window": "1m",
"max": 100,
"perUser": {
"user-vip": 1000,
"user-locked": 0
},
"onLimit": {
"status": 429,
"body": { "error": "Too many requests" }
}
}
}| Field | Default | Accepts |
|---|---|---|
| identifier | client IP (req.ip) | Handlebars expression: {{body.x}}, {{headers.x}}, {{token.sub}} |
| window | "1m" | "1m", "5m", "1h", "session", "on-success" |
| max | required | number of requests allowed in the window |
| perUser | {} | map identifier → max (overrides the default) |
| onLimit | { status: 429 } | full response when exceeded |
Windows explained:
"1m","5m","1h": sliding time window."session": never expires until the server restarts."on-success": the counter resets when this same route returns2xx. Simulates login lockout: after N failed attempts, blocked; when one finally succeeds, it's released.
Templating
Every string in response.body, response.headers, and top-level headers passes through Handlebars before being sent. Variables in scope:
| Variable | Source |
|---|---|
| {{params.X}} | path params (from /users/:id) |
| {{query.X}} | querystring |
| {{body.X}} | parsed request body |
| {{headers.X}} | request headers (lowercase) |
| {{token.X}} | JWT payload from the auth header, decoded without verification |
Helpers:
{{faker 'category.method' arg1 arg2}}— any @faker-js/faker method. E.g.{{faker 'person.firstName'}},{{faker 'number.int' min=1 max=100}}.{{randomInt min max}}— random integer in range.{{uuid}}— UUID v4.{{now}}— ISO timestamp.
Examples:
"response": {
"body": {
"id": "{{params.id}}",
"name": "{{faker 'person.fullName'}}",
"email": "{{faker 'internet.email'}}",
"tokenSub": "{{token.sub}}",
"createdAt": "{{now}}"
}
}JWT decoding
If the route has requireAuth and the header carries Bearer <jwt>, mock-fast decodes the payload (base64url, signature not verified — it's a mock) and exposes it as {{token.*}}. Useful for personalizing responses per user without asking the client to send another header.
Programmatic usage
import { createMockFast } from "@killki/mock-fast";
const server = await createMockFast({
file: "./fixtures/mock.json",
port: 3001,
silent: true,
});
await server.start();
// ... tests ...
await server.stop();API:
interface MockFastInstance {
start(): Promise<void>;
stop(): Promise<void>;
reload(): Promise<void>; // re-reads the DSL and applies it
url(): string; // http://host:port
adminUrl(): string; // http://host:adminPort
}Hot reload
By default, mock-fast watches the DSL file for changes. When you save, it reloads routes/collections without restarting the process or closing the port. rateLimit counters reset on reload — that's the natural behavior in dev.
To turn it off: --no-watch or { watch: false } in the programmatic API.
Admin API
Inherited from mocks-server, at http://127.0.0.1:3110 by default. Useful endpoints:
GET /api/mock/routes— list of routes.GET /api/mock/collections— collections.- Swagger UI at
/docs.
Deploy with Docker
To run the mock outside your machine, mock-fast deploy generates a self-contained, Docker-ready bundle from your DSL:
npx mock-fast deploy # writes ./mock-deploy/It validates the DSL first, then writes a mock-deploy/ folder with a Dockerfile (pins the current mock-fast version, binds 0.0.0.0, disables hot reload), a copy of your DSL as mock-fast.json, a .dockerignore, and a README.md. Then:
cd mock-deploy
docker build -t my-mock .
docker run --rm -p 3001:3001 my-mockOptions: --out <dir>, --port <n> (baked into the image), --compose (also emits a docker-compose.yml).
Cautions, because it stays a mock:
- No real security —
requireAuthonly checks the header pattern, JWTs are not verified. Don't expose real data. - The admin API (
3110) also starts and can mutate routes at runtime; the Dockerfile does not expose it — keep it that way. rateLimitcounters are in-memory: they reset on restart and aren't shared across replicas. Run a single instance if that matters.- No HTTPS — put it behind a reverse proxy or your platform's TLS.
Skill (AI-assisted authoring)
mock-fast ships an Agent Skill so an AI assistant (e.g. Claude Code) can write your mocks for you. It's published under dist/ and lives in the repo at mock-fast/SKILL.md:
mock-fast— the DSL reference. Teaches the assistant the full syntax (route tree,when, extensions, templating) so it can writemock-fast.jsonfor you.
Complete example
{
"auth": {
"headerName": "Authorization",
"pattern": "^Bearer [A-Za-z0-9._-]+$"
},
"routes": [
{
"id": "health",
"url": "/health",
"response": { "status": 200, "body": { "ok": true, "ts": "{{now}}" } }
},
{
"url": "/api",
"routes": [
{
"id": "login",
"url": "/login",
"method": "post",
"extensions": {
"rateLimit": {
"identifier": "{{body.username}}",
"window": "on-success",
"max": 3,
"onLimit": { "status": 423, "body": { "error": "locked" } }
}
},
"response": {
"status": 200,
"body": {
"token": "Bearer mock.{{uuid}}",
"user": "{{body.username}}"
}
}
},
{
"url": "/protected",
"extensions": {
"requireAuth": true,
"errorRate": 0.10,
"delayRange": [100, 500]
},
"response": { "status": 200, "body": { "data": "root" } },
"routes": [
{
"id": "users-list",
"url": "/users",
"response": {
"status": 200,
"body": {
"users": [
{ "id": 1, "name": "{{faker 'person.firstName'}}" }
]
}
}
}
]
}
]
}
]
}Roadmap
Planned next extensions (the architecture supports them with a single new file in src/extensions/):
- Variants / scenarios: switch responses in bulk via the admin API.
- Matching operators in
when:{ "body.amount": { "gt": 100 } },regex,in, etc. — the current shorthand (direct equality) keeps working. - Automatic CRUD: declare
"crud": trueand get GET / POST / PUT / DELETE over an in-memory collection. - Proxy fallback: undefined routes fall through to a real backend.
- Request schema validation: 400 when the body doesn't fit a JSON schema.
- Advanced rate limit: window distributions, metrics.
License
MIT
