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>
This commit is contained in:
Peter Meier
2026-06-23 12:44:31 +02:00
commit b122de518c
53 changed files with 2563 additions and 0 deletions
+417
View File
@@ -0,0 +1,417 @@
# mockly-hono
File-based mock API server. Drop files in `routes/`, the file path becomes the
URL. [Hono](https://hono.dev) 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](#quick-start)
- [Routing](#routing) — path, method, status from the filename
- [JSON / JSON5 mocks](#json--json5-mocks) — control keys
- [Templating](#templating) — `{{faker.x}}` / `{{params.x}}` in plain JSON
- [Behavior control keys](#behavior-control-keys) — sequence · chaos · rate-limit · auth · validate · proxy
- [Dynamic mocks](#dynamic-mocks-ts--js) — JS/TS handlers + faker
- [Stateful CRUD](#stateful-crud-crudjson) — incl. pagination & sort
- [Conditional responses](#conditional-responses-__variants)
- [Splat routes](#splat-routes-rest)
- [Scenarios](#scenarios-x-scenario)
- [SSE streaming](#sse-streaming-ssejson)
- [Proxy & record](#proxy--record)
- [OpenAPI import](#openapi-import)
- [Visual docs](#visual-docs-__docs)
- [Built-in endpoints](#built-in-endpoints)
- [Config](#config-env)
- [Workspace alias](#workspace-alias)
- [Project layout](#project-layout)
- [What changed vs original mockly](#what-changed-vs-original-mockly)
## Quick start
```sh
cp .env.example .env
npm install
npm run dev # watch + hot reload (tsx watch)
# or: source alias.sh && web-mock dev
```
```sh
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):
```json5
{
__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:
```json
{ "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:
```json
{
"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`). |
```json5
// 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.
```ts
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`](https://fakerjs.dev) |
Handlers hot-reload on save (cache-busted import) — no restart.
```sh
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.
```sh
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`.
```json5
{ __variants: [
{ when: { query: { role: 'admin' } }, body: { seats: 999 } },
{ when: { headers: { 'x-tier': 'pro' } }, body: { seats: 25 } },
{ body: { seats: 1 } }, // fallback
] }
```
```sh
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`):
```json5
{
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.
```sh
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):
```sh
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](https://github.com/scalar/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):
```json5
{ __doc: { summary: 'Create user', description: 'Returns **201**.' }, __status: 201, __body: { id: 1 } }
```
- **`.ts`/`.js` handlers** — `export 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:
```json5
{ 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
```sh
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) |