# 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 -- ` · `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` | path params | | `query` | `Record` | query string | | `headers` | `Record` | 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//…` mirroring the normal paths. A request with header `X-Scenario: ` 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 `.` 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 `