b122de518c
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>
418 lines
16 KiB
Markdown
418 lines
16 KiB
Markdown
# 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` 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`). |
|
||
|
||
```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) |
|