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

418 lines
16 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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) |