Files
Peter Meier b122de518c feat: mockly-hono — file-based mock API server (Hono)
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>
2026-06-23 12:44:31 +02:00

16 KiB
Raw Permalink Blame History

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

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 into params.id.
  • Concrete routes beat param routes; head falls back to the matching get.
  • 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. See routes/__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 01). 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 __doc key (string = description, or { summary, description }; markdown, stripped from the response):
    { __doc: { summary: 'Create user', description: 'Returns **201**.' }, __status: 201, __body: { id: 1 } }
    
  • .ts/.js handlersexport const doc = 'text' or { summary, description } alongside the default handler.
  • Group descriptions — a routes/_tags.json5 mapping 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)