File path = route. JSON/JSON5, dynamic .ts handlers + faker, templating, stateful CRUD (pagination/sort/persist), __variants, sequence, chaos, rate-limit, auth, validation, per-route + global proxy/record, splat routes, X-Scenario overlays, SSE. Visual docs (Scalar) at /__docs from a live OpenAPI 3 spec. OpenAPI import script. Zero-build via tsx. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
mockly-hono
File-based mock API server. Drop files in routes/, the file path becomes the
URL. Hono core → runs on Node / Bun / Deno / edge. Zero build
(runs .ts directly via tsx).
Successor to the original Express mockly. Same file-based idea, much more power.
Contents
- Quick start
- Routing — path, method, status from the filename
- JSON / JSON5 mocks — control keys
- Templating —
{{faker.x}}/{{params.x}}in plain JSON - Behavior control keys — sequence · chaos · rate-limit · auth · validate · proxy
- Dynamic mocks — JS/TS handlers + faker
- Stateful CRUD — incl. pagination & sort
- Conditional responses
- Splat routes
- Scenarios
- SSE streaming
- Proxy & record
- OpenAPI import
- Visual docs
- Built-in endpoints
- Config
- Workspace alias
- Project layout
- What changed vs original mockly
Quick start
cp .env.example .env
npm install
npm run dev # watch + hot reload (tsx watch)
# or: source alias.sh && web-mock dev
curl localhost:4100/__routes # list every mock
curl localhost:4100/pages/homepage # serve routes/pages/homepage.json
Scripts: npm run dev (watch) · npm run start · npm run openapi -- <spec> ·
npm run lint (tsc typecheck).
Routing
Directory path + filename = URL. The filename encodes method and status
via dotted tokens. No method token = GET (back-compat with original mockly).
| File | Route |
|---|---|
routes/pages/homepage.json |
GET /pages/homepage |
routes/index.json |
GET / |
routes/users.post.json |
POST /users |
routes/user.404.json |
GET /user → 404 |
routes/users.post.201.json |
POST /users → 201 |
routes/users/[id].get.ts |
GET /users/:id (path param id) |
- Method token:
get post put patch delete options head. - Status token: any 3-digit number → default status for that route.
- Path params: a segment named
[id](dir or file) captures intoparams.id. - Concrete routes beat param routes;
headfalls back to the matchingget. - A single leading
_(e.g._partials/,_lib.ts) or a.= ignored — use for shared data/helpers you import from handlers. A double__(e.g.__test/) is served — handy for grouping. Seeroutes/__test/for one worked example per feature.
Supported file kinds: .json, .json5, .js, .mjs, .ts, and *.crud.json.
JSON / JSON5 mocks
Plain JSON/JSON5 → returned as-is with status 200. Optional __ control keys
shape the response and are stripped from the body (original mockly leaked
__delayed into the payload — fixed here):
{
__status: 201,
__delay: 300, // ms; __delayed is an alias
__headers: { Location: "/users/42" },
__body: { id: 42, created: true } // explicit body
}
__delay may be a [min, max] range for random jitter (a fresh value per
request): { __delay: [200, 1200] }. Works in __variants and .ts handlers
too, and the global REQUEST_DELAY accepts a range string (REQUEST_DELAY=200-800).
Without __body, every non-__ key is the body:
{ "title": "Homepage", "blocks": ["hero", "features"] }
.json5 allows comments, trailing commas, unquoted keys; .json is parsed
leniently too (JSON5 is a superset).
Templating
{{ ... }} placeholders inside any JSON body are resolved per request — no
.ts handler needed:
{
"name": "{{faker.person.fullName}}",
"lucky": "{{faker.number.int}}",
"echo": "you sent q={{query.q}}",
"id": "{{params.id}}"
}
Sources: faker.* (zero-arg methods), params.*, query.*, headers.*. If a
string is exactly one placeholder, the typed value is kept ({{faker.number.int}}
→ a number, not a string).
Behavior control keys
Drop any of these into a JSON route to simulate real-world API behavior. All are stripped from the body.
| Key | Effect |
|---|---|
__sequence: [r1, r2, …] |
a different response each call, cycling. Items are raw bodies or {status,headers,delay,body}. Great for poll-then-ready / fail-then-succeed. |
__chaos: { rate, status, body? } |
fail this route randomly (rate 0–1). Per-route version of CHAOS_RATE. |
__rateLimit: { max, windowMs } |
after max requests per sliding window → 429 with Retry-After. |
__auth: "bearer" or { token } |
require Authorization: Bearer … (any token, or an exact one) → 401 otherwise. |
__validate: { field: "type" } |
on POST/PUT/PATCH, check the body → 422 { errors }. Types: string number boolean array object any, trailing ? = optional. |
__proxy: "https://api.real.com" |
forward this one route to a real upstream (same path+query), tagged X-Mock-Proxy. Partial mocking. |
__doc: "text" or { summary, description } |
sidebar label + markdown description shown in the visual docs (/__docs). |
// poll endpoint that becomes ready on the 3rd call
{ __sequence: [
{ status: 202, body: { state: 'pending' } },
{ status: 202, body: { state: 'pending' } },
{ status: 200, body: { state: 'ready' } },
] }
Dynamic mocks (.ts / .js)
Export a default function for request-aware responses. Return a raw body, or a
{ status, headers, delay, body } envelope.
import type { MockContext } from '../../src/respond.ts'
// GET /users/:id -> deterministic synthetic user
export default function ({ params, query, faker }: MockContext) {
faker.seed(Number(params.id) || 1)
return {
id: params.id,
name: faker.person.fullName(),
verbose: query.verbose === '1' ? faker.lorem.paragraph() : undefined,
}
}
MockContext:
| Field | Type | Notes |
|---|---|---|
params |
Record<string,string> |
path params |
query |
Record<string,string> |
query string |
headers |
Record<string,string> |
request headers |
method |
string |
HTTP method |
body |
unknown |
parsed JSON body (write methods) |
faker |
faker instance | @faker-js/faker |
Handlers hot-reload on save (cache-busted import) — no restart.
curl "localhost:4100/users/7?verbose=1"
# {"id":"7","name":"Patty Reilly IV","verbose":"Vicinus acidus ..."}
Stateful CRUD (*.crud.json)
A seed file (a JSON/JSON5 array) expands into a full REST resource backed by
an in-memory store. routes/todos.crud.json →
| Method | Route | Op |
|---|---|---|
| GET | /todos |
list — filters ?field=value, sort ?_sort=price&_order=desc, paginate ?_page=2&_limit=10 (or ?_start&_end), X-Total-Count header |
| POST | /todos |
create (auto-increment id) → 201 |
| GET | /todos/:id |
read (404 if missing) |
| PUT | /todos/:id |
replace |
| PATCH | /todos/:id |
merge |
| DELETE | /todos/:id |
delete → 204 |
State lives in memory and survives hot reloads; a server restart resets it to the
seed — unless PERSIST=true, which writes mutations back to the *.crud.json
file so they survive restarts.
curl -X POST localhost:4100/todos -H 'content-type: application/json' \
-d '{"title":"new","done":false}' # -> {"title":"new","done":false,"id":3}
curl "localhost:4100/todos?done=true" # filtered list
curl -X PATCH localhost:4100/todos/3 -d '{"done":true}' -H 'content-type: application/json'
curl -X DELETE localhost:4100/todos/1 -i # -> 204
Conditional responses (__variants)
Pick a response by matching the request. The first variant whose when matches
wins; a variant without when is the fallback. Matchers are subset matches
over query / headers (case-insensitive) / params / body.
{ __variants: [
{ when: { query: { role: 'admin' } }, body: { seats: 999 } },
{ when: { headers: { 'x-tier': 'pro' } }, body: { seats: 25 } },
{ body: { seats: 1 } }, // fallback
] }
curl localhost:4100/account # {"seats":1}
curl "localhost:4100/account?role=admin" # {"seats":999}
curl localhost:4100/account -H 'x-tier: pro' # {"seats":25}
A variant may also set status, headers, delay. If nothing matches and there
is no fallback → 404.
Splat routes ([...rest])
A [...rest] segment catches any remaining path depth into one param:
routes/files/[...path].json → GET /files/a/b/c.txt (params.path = "a/b/c.txt")
Splat routes are matched last, so concrete and [id] routes always win first.
Combine with templating: { "path": "{{params.path}}" }.
Scenarios (X-Scenario)
Overlay a whole alternate route set without touching the base. Put files under
routes/_scenarios/<name>/… mirroring the normal paths. A request with header
X-Scenario: <name> is matched against that overlay first, then falls back to the
base routes.
routes/_scenarios/broken/users.500.json # X-Scenario: broken → GET /users = 500
routes/_scenarios/empty/users.json # X-Scenario: empty → GET /users = []
Flip your frontend between happy / empty / error states with one header. Active scenario names are printed at startup.
SSE streaming (*.sse.json)
A *.sse.json file streams Server-Sent Events (text/event-stream):
{
loop: false, // repeat the sequence?
events: [
{ event: 'tick', id: 1, data: { n: 1 }, delay: 150 },
{ event: 'done', id: 2, data: { n: 2 }, delay: 150 },
],
}
delay waits before each event; data is JSON-encoded automatically. The stream
ends after the events (or loops until the client disconnects when loop: true).
Consume with new EventSource('/ticker') or curl -N.
Proxy & record
Set PROXY_TARGET and any unmatched request forwards to a real upstream
(response tagged X-Mock-Proxy). With RECORD=true, JSON responses are saved as
mock files — record once, replay forever; the recorded route is served locally on
the next request.
PROXY_TARGET=https://api.example.com RECORD=true npm run dev
# first GET /v1/users -> proxied to upstream, saved as routes/v1/users.json
# second GET /v1/users -> served locally (X-Mock-File header)
Only successful JSON responses are recorded; non-GET methods get a .<method>
filename suffix.
OpenAPI import
Generate route files from an OpenAPI 3 spec (json or yaml):
npm run openapi -- path/to/openapi.yaml [outDir]
# or: web-mock openapi path/to/openapi.yaml
Each path × method becomes a file. The body is the response example, the first
of examples, or a value synthesized from the schema ($ref, allOf, oneOf,
formats like date-time/email/uuid handled). Non-200 success codes go in the
filename (e.g. pets.post.201.json). Defaults to ./routes.
Visual docs (/__docs)
Open http://localhost:4100/__docs for a live, interactive API reference
(Scalar, loaded from CDN). It renders every
route — methods, path params, example responses — with a built-in "try it"
client. No build step; the page reads the live spec.
Add your own per-route docs:
- JSON routes — a
__dockey (string = description, or{ summary, description }; markdown, stripped from the response):{ __doc: { summary: 'Create user', description: 'Returns **201**.' }, __status: 201, __body: { id: 1 } } .ts/.jshandlers —export const doc = 'text'or{ summary, description }alongside the default handler.- Group descriptions — a
routes/_tags.json5mapping the first path segment to text, shown atop each sidebar section:{ todos: 'Demo CRUD resource.', users: 'User endpoints.' }
The underlying spec is generated from the route files on every request at
GET /__openapi.json (OpenAPI 3.0.3). Examples come from the actual mock
bodies: JSON files use their payload, CRUD routes show the seed data, __variants
use the first variant, dynamic .ts handlers are marked (dynamic).
Prefer Swagger UI instead of Scalar? Point it at /__openapi.json, or swap the
two <script> lines in the /__docs handler (src/index.ts) for the Swagger UI
CDN bundle — same spec URL.
Built-in endpoints
| Endpoint | Returns |
|---|---|
GET /__docs |
interactive API reference (Scalar UI) |
GET /__openapi.json |
OpenAPI 3 spec generated live from the route files |
GET /__routes |
every registered mock: { count, routes: [{ method, path, status, kind }] } |
GET /__health |
{ ok: true, routes } |
Every mock response carries an X-Mock-File header pointing at the source file.
Config (.env)
| Var | Default | Meaning |
|---|---|---|
PORT |
3000 |
listen port |
REQUEST_DELAY |
0 |
default delay when a route sets none — ms (500) or random range (200-800) |
COMPRESS |
true |
gzip/deflate responses |
CORS_ORIGIN |
* |
CORS allow-origin |
ROUTES_DIR |
./routes |
route source directory |
CHAOS_RATE |
0 |
fraction of requests to fail randomly (0.1 = 10%) |
CHAOS_STATUS |
500 |
status returned for chaos failures |
PROXY_TARGET |
(empty) | upstream base URL for unmatched requests (off when empty) |
RECORD |
false |
when proxying, save JSON responses as mock files |
PERSIST |
false |
write CRUD mutations back to the *.crud.json file (survives restart) |
Workspace alias
source alias.sh
web-mock dev # watch + hot reload
web-mock start # run once (no watch)
web-mock openapi spec.yaml # generate routes from a spec
web-mock lint # tsc typecheck
Project layout
mockly-hono/
├─ src/
│ ├─ index.ts server bootstrap, middleware, catch-all, proxy wiring
│ ├─ registry.ts scan routes, parse filenames, match requests, crud expansion, watcher
│ ├─ respond.ts build response: json / module / crud / variants
│ ├─ store.ts in-memory CRUD collections (seed-once) + persistence
│ ├─ template.ts {{...}} placeholder resolution
│ ├─ runtime.ts per-route state: sequence cursors, rate-limit windows
│ ├─ validate.ts tiny body validator
│ ├─ proxy.ts upstream passthrough + record-as-mock
│ ├─ openapi-gen.ts routes -> live OpenAPI spec for /__docs
│ └─ parse.ts json5 reader
├─ scripts/
│ └─ from-openapi.ts OpenAPI 3 spec -> route files
├─ routes/ your mocks (examples included)
├─ .env.example
└─ package.json
What changed vs original mockly
| original | mockly-hono | |
|---|---|---|
| Core | Express | Hono (Node/Bun/Deno/edge) |
| Methods | GET only | all HTTP methods (filename) |
| Status | always 200 | per-route via filename / envelope |
| Path params | ✗ | [id] → :id |
| Custom headers | ✗ | __headers / handler |
| Dynamic responses | ✗ | .ts/.js handlers + faker |
| Templating | ✗ | {{faker.x}} / {{params.x}} in plain JSON |
| Sequence / chaos / rate-limit / auth / validate | ✗ | per-route control keys |
| Stateful CRUD | ✗ | *.crud.json → 6 routes, pagination + sort, optional persist |
| Conditional responses | ✗ | __variants |
| Splat routes | ✗ | [...rest] |
| Scenarios | ✗ | X-Scenario overlay |
| SSE streaming | ✗ | *.sse.json |
| Per-route proxy | ✗ | __proxy |
| JSON5 | ✗ | ✓ |
| Proxy / record | ✗ | PROXY_TARGET + RECORD |
| OpenAPI import | ✗ | npm run openapi |
| Hot reload | nodemon full restart | request-time resolver, no restart |
| Introspection | ✗ | /__routes, /__health |
| Visual docs | ✗ | /__docs (Scalar) + live /__openapi.json |
| Chaos injection | ✗ | CHAOS_RATE |
__delayed body leak |
bug (leaked to client) | fixed (control keys stripped) |