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
+19
View File
@@ -0,0 +1,19 @@
# server port (3000 collides with RustyCMS in this workspace -> 4100)
PORT=4100
# default response delay (ms) when a route sets none
REQUEST_DELAY=0
# gzip/deflate responses
COMPRESS=true
# CORS allow-origin ( * or https://app.example.com )
CORS_ORIGIN=*
# override routes directory (default ./routes)
# ROUTES_DIR=./routes
# chaos engineering: fraction of requests that fail (0 = off)
CHAOS_RATE=0
CHAOS_STATUS=500
# proxy unmatched requests to a real upstream ( empty = off )
PROXY_TARGET=
# when proxying, save upstream json responses as mock files
RECORD=false
# persist CRUD mutations back to the *.crud.json seed file (survives restart)
PERSIST=false
+3
View File
@@ -0,0 +1,3 @@
node_modules
.env
*.log
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Peter Meier
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+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) |
+619
View File
@@ -0,0 +1,619 @@
{
"name": "mockly-hono",
"version": "2.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "mockly-hono",
"version": "2.0.0",
"dependencies": {
"@faker-js/faker": "^9.3.0",
"@hono/node-server": "^1.13.7",
"hono": "^4.6.14",
"json5": "^2.2.3",
"yaml": "^2.6.1"
},
"devDependencies": {
"tsx": "^4.19.2",
"typescript": "^5.7.2"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
"integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz",
"integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz",
"integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz",
"integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz",
"integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz",
"integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz",
"integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz",
"integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz",
"integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz",
"integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz",
"integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz",
"integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz",
"integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz",
"integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz",
"integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz",
"integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz",
"integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz",
"integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz",
"integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz",
"integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz",
"integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openharmony-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz",
"integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz",
"integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz",
"integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz",
"integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz",
"integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@faker-js/faker": {
"version": "9.9.0",
"resolved": "https://registry.npmjs.org/@faker-js/faker/-/faker-9.9.0.tgz",
"integrity": "sha512-OEl393iCOoo/z8bMezRlJu+GlRGlsKbUAN7jKB6LhnKoqKve5DXRpalbItIIcwnCjs1k/FOPjFzcA6Qn+H+YbA==",
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/fakerjs"
}
],
"license": "MIT",
"engines": {
"node": ">=18.0.0",
"npm": ">=9.0.0"
}
},
"node_modules/@hono/node-server": {
"version": "1.19.14",
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz",
"integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==",
"license": "MIT",
"engines": {
"node": ">=18.14.1"
},
"peerDependencies": {
"hono": "^4"
}
},
"node_modules/esbuild": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz",
"integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.28.1",
"@esbuild/android-arm": "0.28.1",
"@esbuild/android-arm64": "0.28.1",
"@esbuild/android-x64": "0.28.1",
"@esbuild/darwin-arm64": "0.28.1",
"@esbuild/darwin-x64": "0.28.1",
"@esbuild/freebsd-arm64": "0.28.1",
"@esbuild/freebsd-x64": "0.28.1",
"@esbuild/linux-arm": "0.28.1",
"@esbuild/linux-arm64": "0.28.1",
"@esbuild/linux-ia32": "0.28.1",
"@esbuild/linux-loong64": "0.28.1",
"@esbuild/linux-mips64el": "0.28.1",
"@esbuild/linux-ppc64": "0.28.1",
"@esbuild/linux-riscv64": "0.28.1",
"@esbuild/linux-s390x": "0.28.1",
"@esbuild/linux-x64": "0.28.1",
"@esbuild/netbsd-arm64": "0.28.1",
"@esbuild/netbsd-x64": "0.28.1",
"@esbuild/openbsd-arm64": "0.28.1",
"@esbuild/openbsd-x64": "0.28.1",
"@esbuild/openharmony-arm64": "0.28.1",
"@esbuild/sunos-x64": "0.28.1",
"@esbuild/win32-arm64": "0.28.1",
"@esbuild/win32-ia32": "0.28.1",
"@esbuild/win32-x64": "0.28.1"
}
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/hono": {
"version": "4.12.27",
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.27.tgz",
"integrity": "sha512-1yrb/+w6HWQJrUCLkJ2IF5jNIPvvFkblV5RNOYl6bV+OA6p9GLcMpHFFGTosSvHvcAUibuUukRqhlYI4z32C7Q==",
"license": "MIT",
"engines": {
"node": ">=16.9.0"
}
},
"node_modules/json5": {
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
"integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
"license": "MIT",
"bin": {
"json5": "lib/cli.js"
},
"engines": {
"node": ">=6"
}
},
"node_modules/tsx": {
"version": "4.22.4",
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.4.tgz",
"integrity": "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==",
"dev": true,
"license": "MIT",
"dependencies": {
"esbuild": "~0.28.0"
},
"bin": {
"tsx": "dist/cli.mjs"
},
"engines": {
"node": ">=18.0.0"
},
"optionalDependencies": {
"fsevents": "~2.3.3"
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/yaml": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz",
"integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==",
"license": "ISC",
"bin": {
"yaml": "bin.mjs"
},
"engines": {
"node": ">= 14.6"
},
"funding": {
"url": "https://github.com/sponsors/eemeli"
}
}
}
}
+24
View File
@@ -0,0 +1,24 @@
{
"name": "mockly-hono",
"version": "2.0.0",
"description": "File-based mock API server. Hono edition.",
"type": "module",
"main": "src/index.ts",
"scripts": {
"dev": "tsx watch src/index.ts",
"start": "tsx src/index.ts",
"openapi": "tsx scripts/from-openapi.ts",
"lint": "tsc --noEmit"
},
"dependencies": {
"@hono/node-server": "^1.13.7",
"hono": "^4.6.14",
"@faker-js/faker": "^9.3.0",
"json5": "^2.2.3",
"yaml": "^2.6.1"
},
"devDependencies": {
"tsx": "^4.19.2",
"typescript": "^5.7.2"
}
}
+58
View File
@@ -0,0 +1,58 @@
# __test — example routes
One example per feature. The `__` (double underscore) prefix is **served**; a
single `_` prefix (like `_lib.ts`) is ignored by the scanner.
| Route | File | Feature |
|---|---|---|
| `GET /__test/static` | `static.json` | plain JSON body |
| `POST /__test/created` → 201 | `created.post.201.json` | status + `__headers` + `__delay` + `__body` |
| `GET /__test/slow` | `slow.json` | `__delay: 1500` (fixed) |
| `GET /__test/jitter` | `jitter.json` | `__delay: [200, 1200]` (random per request) |
| `GET /__test/teapot` → 418 | `teapot.418.json` | status from filename |
| `GET /__test/profile` | `profile.json5` | JSON5 + `__variants` (try `?plan=pro`, header `X-Admin: 1`) |
| `GET/POST/… /__test/products[/:id]` | `products.crud.json` | stateful CRUD (6 routes) |
| `GET /__test/users/:id` | `users/[id].get.ts` | dynamic handler + faker + param (try `?verbose=1`) |
| `POST /__test/echo` | `echo.post.ts` | echo request |
| `GET /__test/quote` | `quote.get.ts` | handler importing a `_lib.ts` partial |
| `GET /__test/template` | `template.json` | `{{faker}}` / `{{query}}` / `{{headers}}` templating |
| `GET /__test/tpl/faker` | `tpl/faker.json` | many faker domains, nested |
| `GET /__test/tpl/user/:id` | `tpl/user/[id].json` | `{{params.id}}` + faker, no `.ts` needed |
| `GET /__test/tpl/echo` | `tpl/echo.json` | `{{query}}` + `{{headers}}`, unresolved stays literal |
| `GET /__test/tpl/typed` | `tpl/typed.json` | typed (number/bool) vs string interpolation |
| `GET /__test/tpl/list` | `tpl/list.json` | array — each element resolves independently |
| `GET /__test/flaky` | `flaky.json` | `__sequence` (202, 202, 200 …) |
| `GET /__test/roulette` | `roulette.json` | `__chaos` 50% → 503 |
| `GET /__test/limited` | `limited.json` | `__rateLimit` 3/10s → 429 |
| `GET /__test/secret` | `secret.json` | `__auth` Bearer `s3cret` → 401 |
| `POST /__test/signup` | `signup.post.json` | `__validate` → 422 |
| `GET /__test/proxied` | `proxied.json` | `__proxy` to a real upstream |
| `GET /__test/files/*` | `files/[...path].json` | splat route |
| `GET /__test/ticker` | `ticker.sse.json` | SSE stream |
| — | `_lib.ts` | shared data (ignored as a route) |
Scenario overlay: `routes/_scenarios/broken/__test/static.503.json` — try
`curl -H 'X-Scenario: broken' localhost:4100/__test/static` → 503.
Quick try:
```sh
curl localhost:4100/__test/static
curl -w '\n%{time_total}s\n' localhost:4100/__test/slow
curl "localhost:4100/__test/profile?plan=pro"
curl localhost:4100/__test/products
curl -X POST localhost:4100/__test/products -H 'content-type: application/json' -d '{"name":"New","price":1}'
curl "localhost:4100/__test/users/5?verbose=1"
curl localhost:4100/__test/quote
curl "localhost:4100/__test/template?q=hi"
for i in 1 2 3; do curl -s localhost:4100/__test/flaky; echo; done # sequence
curl -i localhost:4100/__test/secret # 401
curl -i -H 'authorization: Bearer s3cret' localhost:4100/__test/secret
curl -X POST localhost:4100/__test/signup -d '{"name":"x"}' -H 'content-type: application/json' # 422
curl "localhost:4100/__test/products?_sort=price&_order=desc&_limit=2"
curl localhost:4100/__test/files/a/b/c.txt # splat
curl -N localhost:4100/__test/ticker # SSE
curl -H 'X-Scenario: broken' localhost:4100/__test/static # scenario
```
All visible in the visual docs at `/__docs`.
+6
View File
@@ -0,0 +1,6 @@
// single-underscore file = NOT a route (ignored by scanner) — shared data/helpers
export const QUOTES = [
'Mock fast, ship faster.',
'A 404 a day keeps the backend away.',
'It works on my mock.',
]
+5
View File
@@ -0,0 +1,5 @@
{
"__delay": 250,
"__headers": { "Location": "/__test/created/99", "X-Demo": "control-keys" },
"__body": { "id": 99, "created": true }
}
+7
View File
@@ -0,0 +1,7 @@
import type { MockContext } from '../../src/respond.ts'
// feature: echo request — try:
// curl -X POST localhost:4100/__test/echo -H 'content-type: application/json' -d '{"hi":1}'
export default function ({ body, query, headers, method }: MockContext) {
return { method, received: body, query, contentType: headers['content-type'] ?? null }
}
+4
View File
@@ -0,0 +1,4 @@
{
"feature": "splat route — catches any depth",
"captured": "{{params.path}}"
}
+7
View File
@@ -0,0 +1,7 @@
{
"__sequence": [
{ "status": 202, "body": { "state": "pending" } },
{ "status": 202, "body": { "state": "pending" } },
{ "status": 200, "body": { "state": "ready", "result": 42 } }
]
}
+5
View File
@@ -0,0 +1,5 @@
{
"__delay": [200, 1200],
"feature": "random delay jitter",
"note": "each request waits a random 200-1200ms — repeat: curl -w '%{time_total}s' localhost:4100/__test/jitter"
}
+5
View File
@@ -0,0 +1,5 @@
{
"__rateLimit": { "max": 3, "windowMs": 10000 },
"feature": "rate limit (3 per 10s, then 429)",
"ok": true
}
+5
View File
@@ -0,0 +1,5 @@
[
{ "id": 1, "name": "Widget", "price": 9.99, "stock": 12 },
{ "id": 2, "name": "Gadget", "price": 19.99, "stock": 0 },
{ "id": 3, "name": "Gizmo", "price": 4.5, "stock": 99 }
]
+19
View File
@@ -0,0 +1,19 @@
{
// feature: json5 (comments, trailing commas) + __variants
// try: /__test/profile · ?plan=pro · header X-Admin: 1
__variants: [
{
when: { query: { plan: 'pro' } },
delay: 200,
body: { plan: 'pro', seats: 25, features: ['sso', 'audit'] },
},
{
when: { headers: { 'x-admin': '1' } },
body: { plan: 'enterprise', seats: 999, features: ['sso', 'audit', 'scim'] },
},
{
// fallback (no `when`)
body: { plan: 'free', seats: 1, features: [] },
},
],
}
+4
View File
@@ -0,0 +1,4 @@
{
"__proxy": "https://api.energy-charts.info",
"note": "this route forwards to the upstream (same path+query); response tagged X-Mock-Proxy"
}
+7
View File
@@ -0,0 +1,7 @@
import type { MockContext } from '../../src/respond.ts'
import { QUOTES } from './_lib.ts'
// feature: handler importing a _partial (shared data) + random pick
export default function ({ faker }: MockContext) {
return { quote: faker.helpers.arrayElement(QUOTES), total: QUOTES.length }
}
+5
View File
@@ -0,0 +1,5 @@
{
"__chaos": { "rate": 0.5, "status": 503, "body": { "error": "unlucky" } },
"feature": "per-route chaos (50% -> 503)",
"ok": true
}
+5
View File
@@ -0,0 +1,5 @@
{
"__auth": { "token": "s3cret" },
"feature": "auth gate",
"data": "top secret — needs Authorization: Bearer s3cret"
}
+5
View File
@@ -0,0 +1,5 @@
{
"__validate": { "name": "string", "age": "number", "email": "string?" },
"__status": 201,
"__body": { "ok": true, "note": "POST valid body -> 201, else 422 with errors" }
}
+5
View File
@@ -0,0 +1,5 @@
{
"__delay": 1500,
"feature": "delay",
"note": "responds after 1.5s — time it: curl -w '%{time_total}s' localhost:4100/__test/slow"
}
+9
View File
@@ -0,0 +1,9 @@
{
"__doc": {
"summary": "Static JSON example",
"description": "Returns the file body **as-is** with status 200.\n\nThe `__doc` key adds this text to the visual docs and is stripped from the response."
},
"feature": "plain json",
"note": "file body returned as-is, status 200",
"items": [1, 2, 3]
}
+3
View File
@@ -0,0 +1,3 @@
{
"__body": { "feature": "status from filename", "error": "I'm a teapot" }
}
+8
View File
@@ -0,0 +1,8 @@
{
"feature": "templating in plain json",
"name": "{{faker.person.fullName}}",
"email": "{{faker.internet.email}}",
"luckyNumber": "{{faker.number.int}}",
"youSaid": "q = {{query.q}}",
"ua": "{{headers.user-agent}}"
}
+8
View File
@@ -0,0 +1,8 @@
{
"loop": false,
"events": [
{ "event": "tick", "id": 1, "data": { "n": 1 }, "delay": 150 },
{ "event": "tick", "id": 2, "data": { "n": 2 }, "delay": 150 },
{ "event": "done", "id": 3, "data": { "n": 3, "final": true }, "delay": 150 }
]
}
+8
View File
@@ -0,0 +1,8 @@
{
"feature": "request data — query + headers",
"search": "you searched: {{query.q}}",
"page": "{{query.page}}",
"userAgent": "{{headers.user-agent}}",
"tier": "{{headers.x-tier}}",
"unresolved": "{{query.missing}} stays literal if absent"
}
+22
View File
@@ -0,0 +1,22 @@
{
"title": "faker fields (zero-arg methods, fresh each request)",
"person": {
"name": "{{faker.person.fullName}}",
"firstName": "{{faker.person.firstName}}",
"job": "{{faker.person.jobTitle}}"
},
"internet": {
"email": "{{faker.internet.email}}",
"username": "{{faker.internet.username}}",
"url": "{{faker.internet.url}}"
},
"location": {
"city": "{{faker.location.city}}",
"country": "{{faker.location.country}}",
"zip": "{{faker.location.zipCode}}"
},
"ids": {
"uuid": "{{faker.string.uuid}}",
"int": "{{faker.number.int}}"
}
}
+8
View File
@@ -0,0 +1,8 @@
{
"feature": "array of templated objects — each element resolves independently",
"users": [
{ "id": 1, "name": "{{faker.person.fullName}}", "city": "{{faker.location.city}}" },
{ "id": 2, "name": "{{faker.person.fullName}}", "city": "{{faker.location.city}}" },
{ "id": 3, "name": "{{faker.person.fullName}}", "city": "{{faker.location.city}}" }
]
}
+7
View File
@@ -0,0 +1,7 @@
{
"feature": "typed vs string",
"wholePlaceholder_number": "{{faker.number.int}}",
"wholePlaceholder_bool": "{{faker.datatype.boolean}}",
"embedded_becomes_string": "lucky: {{faker.number.int}}",
"mixed": "{{params.id}}-{{faker.string.alpha}}"
}
+7
View File
@@ -0,0 +1,7 @@
{
"feature": "path param + faker in plain json (no .ts handler)",
"id": "{{params.id}}",
"profileUrl": "/users/{{params.id}}",
"name": "{{faker.person.fullName}}",
"avatar": "{{faker.image.avatar}}"
}
+20
View File
@@ -0,0 +1,20 @@
import type { MockContext } from '../../../src/respond.ts'
// shown in the visual docs (/__docs) — handler-level description
export const doc = {
summary: 'Synthetic user',
description: 'Deterministic faker user keyed off `:id`. Add `?verbose=1` for a bio.',
}
// feature: dynamic handler + faker + path param + query
// try: /__test/users/5 · /__test/users/5?verbose=1
export default function ({ params, query, faker }: MockContext) {
faker.seed(Number(params.id) || 1) // deterministic per id
return {
id: params.id,
name: faker.person.fullName(),
email: faker.internet.email(),
avatar: faker.image.avatar(),
bio: query.verbose === '1' ? faker.lorem.paragraph() : undefined,
}
}
@@ -0,0 +1,3 @@
{
"__body": { "error": "service unavailable", "scenario": "broken" }
}
+8
View File
@@ -0,0 +1,8 @@
{
// group (tag) descriptions shown at the top of each section in /__docs
// key = first path segment
__test: 'Worked examples — one route per mockly feature.',
todos: 'Demo CRUD resource (in-memory).',
users: 'User endpoints.',
pages: 'Static page content.',
}
+18
View File
@@ -0,0 +1,18 @@
{
// json5: comments + trailing commas + unquoted keys
// __variants picks the first whose `when` matches the request
__variants: [
{
when: { query: { role: 'admin' } },
body: { role: 'admin', canDelete: true, seats: 999 },
},
{
when: { headers: { 'x-tier': 'pro' } },
body: { role: 'user', canDelete: false, seats: 25 },
},
{
// fallback (no `when`)
body: { role: 'user', canDelete: false, seats: 1 },
},
],
}
+6
View File
@@ -0,0 +1,6 @@
import type { MockContext } from '../src/respond.ts'
// Echo back whatever was POSTed — handy for testing client request shaping.
export default function ({ body, query, headers }: MockContext) {
return { received: body, query, contentType: headers['content-type'] ?? null }
}
+3
View File
@@ -0,0 +1,3 @@
{
"__body": { "error": "not found", "code": "DEMO_404" }
}
+4
View File
@@ -0,0 +1,4 @@
{
"title": "Homepage",
"blocks": ["hero", "features", "footer"]
}
+4
View File
@@ -0,0 +1,4 @@
[
{ "id": 1, "title": "wire up mockly", "done": true },
{ "id": 2, "title": "write tests", "done": false }
]
+5
View File
@@ -0,0 +1,5 @@
{
"__delay": 300,
"__headers": { "Location": "/users/42" },
"__body": { "id": 42, "created": true }
}
+16
View File
@@ -0,0 +1,16 @@
import type { MockContext } from '../../src/respond.ts'
// Dynamic mock: GET /users/:id -> synthetic user keyed off the id.
export default function ({ params, query, faker }: MockContext) {
faker.seed(Number(params.id) || 1)
return {
status: 200,
body: {
id: params.id,
name: faker.person.fullName(),
email: faker.internet.email(),
city: faker.location.city(),
verbose: query.verbose === '1' ? faker.lorem.paragraph() : undefined,
},
}
}
+102
View File
@@ -0,0 +1,102 @@
/**
* Generate mock route files from an OpenAPI 3 spec (json or yaml).
*
* npm run openapi -- path/to/openapi.yaml [outDir]
*
* For each path × method it writes a route file whose body is the response
* example (or a synthesized one from the schema). Non-200 success codes land
* in the filename (e.g. users.post.201.json).
*/
import { readFileSync, mkdirSync, writeFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import YAML from 'yaml'
const __dirname = dirname(fileURLToPath(import.meta.url))
const specPath = process.argv[2]
const outDir = process.argv[3] ?? join(__dirname, '..', 'routes')
if (!specPath) {
console.error('usage: npm run openapi -- <spec.yaml|json> [outDir]')
process.exit(1)
}
const text = readFileSync(specPath, 'utf8')
const spec: any = specPath.endsWith('.json') ? JSON.parse(text) : YAML.parse(text)
function resolveRef(ref: string): any {
// #/components/schemas/Foo
const parts = ref.replace(/^#\//, '').split('/')
return parts.reduce((o, k) => o?.[k], spec)
}
function example(schema: any, depth = 0): any {
if (!schema || depth > 6) return null
if (schema.$ref) return example(resolveRef(schema.$ref), depth + 1)
if (schema.example !== undefined) return schema.example
if (schema.default !== undefined) return schema.default
if (schema.enum) return schema.enum[0]
if (schema.allOf) return Object.assign({}, ...schema.allOf.map((s: any) => example(s, depth + 1)))
if (schema.oneOf || schema.anyOf) return example((schema.oneOf ?? schema.anyOf)[0], depth + 1)
switch (schema.type) {
case 'object': {
const out: Record<string, unknown> = {}
for (const [k, v] of Object.entries(schema.properties ?? {})) out[k] = example(v, depth + 1)
return out
}
case 'array':
return [example(schema.items, depth + 1)]
case 'integer':
case 'number':
return schema.format === 'float' ? 1.5 : 1
case 'boolean':
return true
case 'string':
if (schema.format === 'date-time') return '2026-01-01T00:00:00Z'
if (schema.format === 'email') return 'user@example.com'
if (schema.format === 'uuid') return '00000000-0000-0000-0000-000000000000'
return 'string'
default:
return schema.properties ? example({ ...schema, type: 'object' }, depth) : null
}
}
function pickResponse(op: any): { status: number; body: unknown } | null {
const responses = op.responses ?? {}
const code = Object.keys(responses).find((c) => /^2\d\d$/.test(c)) ?? Object.keys(responses)[0]
if (!code) return null
const r = responses[code]
const json = r?.content?.['application/json']
let body: unknown = {}
if (json?.example !== undefined) body = json.example
else if (json?.examples) body = (Object.values(json.examples)[0] as any)?.value ?? {}
else if (json?.schema) body = example(json.schema)
return { status: Number(code) || 200, body }
}
function fileFor(path: string, method: string, status: number): string {
// /users/{id} -> users/[id]
const segs = path.split('/').filter(Boolean).map((s) => s.replace(/^\{(.+)\}$/, '[$1]'))
const base = segs.length ? segs.join('/') : 'index'
const parts = [base]
if (method !== 'get') parts.push(method)
if (status !== 200 && status !== 0) parts.push(String(status))
return join(outDir, parts.join('.') + '.json')
}
let count = 0
for (const [path, item] of Object.entries<any>(spec.paths ?? {})) {
for (const method of ['get', 'post', 'put', 'patch', 'delete']) {
const op = item[method]
if (!op) continue
const picked = pickResponse(op)
if (!picked) continue
const file = fileFor(path, method, picked.status)
mkdirSync(dirname(file), { recursive: true })
writeFileSync(file, JSON.stringify(picked.body, null, 2) + '\n')
count++
console.log(`+ ${method.toUpperCase().padEnd(6)} ${path} -> ${file.replace(outDir + '/', '')}`)
}
}
console.log(`\n${count} route(s) generated into ${outDir}`)
+162
View File
@@ -0,0 +1,162 @@
import { serve } from '@hono/node-server'
import { Hono } from 'hono'
import { cors } from 'hono/cors'
import { compress } from 'hono/compress'
import { logger } from 'hono/logger'
import { faker } from '@faker-js/faker'
import { fileURLToPath } from 'node:url'
import { dirname, join } from 'node:path'
import { readdirSync, statSync } from 'node:fs'
import { scan, match, watchRoutes, type RouteEntry } from './registry.ts'
import { respond } from './respond.ts'
import { proxyPass } from './proxy.ts'
import { buildOpenApi } from './openapi-gen.ts'
const __dirname = dirname(fileURLToPath(import.meta.url))
const ROUTES_DIR = process.env.ROUTES_DIR
? process.env.ROUTES_DIR
: join(__dirname, '..', 'routes')
const PORT = Number(process.env.PORT ?? 4100)
// REQUEST_DELAY: "500" fixed, or "200-800" random range per request
const DEFAULT_DELAY: number | [number, number] = (() => {
const raw = process.env.REQUEST_DELAY ?? '0'
const m = raw.match(/^(\d+)-(\d+)$/)
return m ? [Number(m[1]), Number(m[2])] : Number(raw) || 0
})()
const COMPRESS = (process.env.COMPRESS ?? 'true') === 'true'
const CORS_ORIGIN = process.env.CORS_ORIGIN ?? '*'
// chaos: inject random failures. CHAOS_RATE=0.1 => 10% of requests fail.
const CHAOS_RATE = Number(process.env.CHAOS_RATE ?? 0)
const CHAOS_STATUS = Number(process.env.CHAOS_STATUS ?? 500)
const PROXY_TARGET = process.env.PROXY_TARGET ?? ''
const RECORD = (process.env.RECORD ?? 'false') === 'true'
const METHOD_COLORS: Record<string, string> = {
GET: '\x1b[32m', POST: '\x1b[33m', PUT: '\x1b[34m', PATCH: '\x1b[35m', DELETE: '\x1b[31m',
}
const DIM = '\x1b[2m', RESET = '\x1b[0m'
function printRoutes(list: RouteEntry[]) {
const sorted = [...list].sort((a, b) => a.template.localeCompare(b.template) || a.method.localeCompare(b.method))
console.log(`routes (${sorted.length}):`)
for (const r of sorted) {
const m = r.method.toUpperCase()
const col = METHOD_COLORS[m] ?? ''
const tag = r.status ? ` ${DIM}${r.status}${RESET}` : ''
const kind = r.kind !== 'json' ? ` ${DIM}[${r.kind}]${RESET}` : ''
console.log(` ${col}${m.padEnd(6)}${RESET} ${r.template}${tag}${kind}`)
}
}
// scenario overlays: routes/_scenarios/<name>/** matched first when X-Scenario header is set
const SCENARIOS_DIR = join(ROUTES_DIR, '_scenarios')
function scanScenarios(): Map<string, RouteEntry[]> {
const map = new Map<string, RouteEntry[]>()
try {
for (const n of readdirSync(SCENARIOS_DIR)) {
const dir = join(SCENARIOS_DIR, n)
if (statSync(dir).isDirectory()) map.set(n, scan(dir))
}
} catch {
/* no _scenarios dir */
}
return map
}
let routes: RouteEntry[] = scan(ROUTES_DIR)
let scenarios = scanScenarios()
const rescan = () => {
routes = scan(ROUTES_DIR)
scenarios = scanScenarios()
console.log(`\n↻ routes reloaded`)
printRoutes(routes)
if (scenarios.size) console.log(`scenarios: ${[...scenarios.keys()].join(', ')}`)
}
watchRoutes(ROUTES_DIR, rescan)
const app = new Hono()
app.use('*', logger())
app.use('*', cors({ origin: CORS_ORIGIN }))
if (COMPRESS) app.use('*', compress())
// introspection: list every mock route
app.get('/__routes', (c) =>
c.json({
count: routes.length,
routes: routes
.map((r) => ({ method: r.method.toUpperCase(), path: r.template, status: r.status, kind: r.kind }))
.sort((a, b) => a.path.localeCompare(b.path)),
}),
)
app.get('/__health', (c) => c.json({ ok: true, routes: routes.length }))
// OpenAPI spec generated live from the route files
app.get('/__openapi.json', async (c) => c.json((await buildOpenApi(routes, ROUTES_DIR)) as any))
// visual API docs (Scalar via CDN, points at the live spec)
app.get('/__docs', (c) =>
c.html(`<!doctype html><html><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>mockly-hono · API docs</title></head><body>
<script id="api-reference" data-url="/__openapi.json"></script>
<script src="https://cdn.jsdelivr.net/npm/@scalar/api-reference"></script>
</body></html>`),
)
app.all('*', async (c) => {
if (CHAOS_RATE > 0 && Math.random() < CHAOS_RATE) {
return c.json({ error: 'chaos', injected: true }, CHAOS_STATUS as any)
}
const pathname = new URL(c.req.url).pathname
const scen = c.req.header('x-scenario')
let m = scen && scenarios.has(scen) ? match(scenarios.get(scen)!, c.req.method, pathname) : null
if (!m) m = match(routes, c.req.method, pathname)
if (!m) {
if (PROXY_TARGET) {
try {
const r = await proxyPass(c, { target: PROXY_TARGET, record: RECORD, routesDir: ROUTES_DIR })
if (RECORD) rescan()
return r
} catch (e) {
return c.json({ error: 'proxy failed', target: PROXY_TARGET, detail: String(e) }, 502)
}
}
return c.json({ error: 'no mock', path: pathname, method: c.req.method }, 404)
}
try {
return await respond(c, m, { defaultDelay: DEFAULT_DELAY, faker, routesDir: ROUTES_DIR })
} catch (e) {
console.error('mock error', e)
return c.json({ error: 'mock handler failed', detail: String(e) }, 500)
}
})
console.log(`
# mockly-hono
routes dir : ${ROUTES_DIR}
routes : ${routes.length}
port : ${PORT}
delay : ${Array.isArray(DEFAULT_DELAY) ? `${DEFAULT_DELAY[0]}-${DEFAULT_DELAY[1]}ms (random)` : `${DEFAULT_DELAY}ms`}
compress : ${COMPRESS}
cors : ${CORS_ORIGIN}
chaos : ${CHAOS_RATE > 0 ? `${CHAOS_RATE * 100}% -> ${CHAOS_STATUS}` : 'off'}
proxy : ${PROXY_TARGET ? `${PROXY_TARGET}${RECORD ? ' (recording)' : ''}` : 'off'}
introspect : GET /__routes
docs : GET /__docs (spec: /__openapi.json)
`)
// OSC 8 terminal hyperlink — cmd/ctrl-clickable in iTerm2, VS Code, modern terminals
const link = (url: string, label = url) => `\x1b]8;;${url}\x1b\\${label}\x1b]8;;\x1b\\`
const BOLD = '\x1b[1m', CYAN = '\x1b[36m'
printRoutes(routes)
const base = `http://localhost:${PORT}`
console.log(`
# LISTENING ${link(base)}
${BOLD}${CYAN}➜ docs ${link(base + '/__docs')}${RESET}
spec ${link(base + '/__openapi.json')}
`)
serve({ fetch: app.fetch, port: PORT })
+127
View File
@@ -0,0 +1,127 @@
import { pathToFileURL } from 'node:url'
import { join } from 'node:path'
import type { RouteEntry } from './registry.ts'
import { parseDataFile } from './parse.ts'
import { store } from './store.ts'
/** Best-effort example body + status for a route, used to fill the OpenAPI doc. */
function exampleFor(entry: RouteEntry): { status: number; body: unknown; dynamic: boolean } {
const status = entry.status ?? (entry.crud?.op === 'create' ? 201 : entry.crud?.op === 'delete' ? 204 : 200)
if (entry.kind === 'crud') {
const c = entry.crud!.collection
const items = store.list(c)
if (entry.crud!.op === 'list') return { status, body: items, dynamic: false }
return { status, body: items[0] ?? { id: 1 }, dynamic: false }
}
if (entry.kind === 'module') return { status, body: undefined, dynamic: true }
try {
const raw = parseDataFile(entry.file) as Record<string, unknown>
if (raw && Array.isArray((raw as any).__variants)) {
const v = (raw as any).__variants[0] ?? {}
return { status: v.status ?? status, body: v.body, dynamic: true }
}
if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
if ('__body' in raw) return { status, body: raw.__body, dynamic: false }
const out: Record<string, unknown> = {}
for (const k of Object.keys(raw)) if (!k.startsWith('__')) out[k] = raw[k]
return { status, body: out, dynamic: false }
}
return { status, body: raw, dynamic: false }
} catch {
return { status, body: null, dynamic: false }
}
}
/** :id -> {id} for OpenAPI path templating. */
function toOpenApiPath(template: string) {
return template.replace(/:([^/]+)/g, '{$1}')
}
type Doc = { summary?: string; description?: string }
const normalizeDoc = (doc: unknown): Doc =>
typeof doc === 'string' ? { description: doc } : doc && typeof doc === 'object' ? (doc as Doc) : {}
/** Read an optional author description: __doc in JSON, or `export const doc` in modules. */
async function readDoc(entry: RouteEntry): Promise<Doc> {
try {
if (entry.kind === 'json') return normalizeDoc((parseDataFile(entry.file) as any)?.__doc)
if (entry.kind === 'module') {
const mod = await import(pathToFileURL(entry.file).href + `?doc=${Date.now()}`)
return normalizeDoc(mod.doc)
}
} catch {
/* ignore */
}
return {}
}
/** Optional tag (group) descriptions from routes/_tags.json5: { "todos": "…" }. */
function readTags(routesDir: string): Record<string, string> {
for (const name of ['_tags.json5', '_tags.json']) {
try {
return parseDataFile(join(routesDir, name)) as Record<string, string>
} catch {
/* try next */
}
}
return {}
}
export async function buildOpenApi(routes: RouteEntry[], routesDir = '', title = 'mockly-hono') {
const paths: Record<string, Record<string, unknown>> = {}
const usedTags = new Set<string>()
for (const entry of routes) {
if (entry.method === 'head' || entry.method === 'options') continue
const path = toOpenApiPath(entry.template)
const params = entry.segments
.filter((s) => s.param)
.map((s) => ({ name: s.param, in: 'path', required: true, schema: { type: 'string' } }))
const { status, body, dynamic } = exampleFor(entry)
const content = body === undefined ? undefined : { 'application/json': { example: body } }
const segs = entry.template.split('/').filter(Boolean)
const tag = segs[0] ?? 'root'
usedTags.add(tag)
// label shown in the sidebar = path relative to its tag group
const rest = segs.slice(1).join('/')
const label = rest ? '/' + rest : entry.template
const kindNote = `${entry.kind}${dynamic ? ' (dynamic)' : ''}${entry.status ? ` · default ${entry.status}` : ''}`
const doc = await readDoc(entry)
const autoDesc = `Mock route \`${entry.method.toUpperCase()} ${entry.template}\` — kind: ${kindNote}`
const op: Record<string, unknown> = {
tags: [tag],
summary: doc.summary ?? label,
operationId: `${entry.method}_${segs.join('_') || 'root'}`,
description: doc.description ? `${doc.description}\n\n${autoDesc}` : autoDesc,
responses: {
[String(status)]: { description: dynamic ? 'example (varies at runtime)' : 'mock response', ...(content ? { content } : {}) },
},
}
if (params.length) op.parameters = params
if (['post', 'put', 'patch'].includes(entry.method)) {
op.requestBody = { required: false, content: { 'application/json': { schema: { type: 'object' } } } }
}
paths[path] ??= {}
paths[path][entry.method] = op
}
const tagDescriptions = readTags(routesDir)
const tags = [...usedTags].sort().map((name) =>
tagDescriptions[name] ? { name, description: tagDescriptions[name] } : { name },
)
return {
openapi: '3.0.3',
info: { title, version: '2.0.0', description: 'Auto-generated from the mockly route files.' },
tags,
paths,
}
}
+7
View File
@@ -0,0 +1,7 @@
import { readFileSync } from 'node:fs'
import JSON5 from 'json5'
/** Parse .json or .json5 — json5 is a superset so we use it for both. */
export function parseDataFile(file: string): unknown {
return JSON5.parse(readFileSync(file, 'utf8'))
}
+54
View File
@@ -0,0 +1,54 @@
import { mkdirSync, writeFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import type { Context } from 'hono'
/**
* Forward unmatched requests to a real upstream (PROXY_TARGET).
* With record=true, persist the upstream JSON response as a mock file so the
* next run serves it locally — record-once, replay-forever.
*/
export async function proxyPass(
c: Context,
opts: { target: string; record: boolean; routesDir: string },
): Promise<Response> {
const reqUrl = new URL(c.req.url)
const upstream = new URL(reqUrl.pathname + reqUrl.search, opts.target)
const init: RequestInit = {
method: c.req.method,
headers: stripHopHeaders(c.req.raw.headers),
}
if (!['GET', 'HEAD'].includes(c.req.method)) init.body = await c.req.raw.arrayBuffer()
const res = await fetch(upstream, init)
const buf = await res.arrayBuffer()
if (opts.record && res.ok && (res.headers.get('content-type') ?? '').includes('json')) {
try {
recordMock(opts.routesDir, reqUrl.pathname, c.req.method, JSON.parse(Buffer.from(buf).toString('utf8')))
} catch {
/* non-json body — skip recording */
}
}
const headers = new Headers(res.headers)
headers.set('X-Mock-Proxy', upstream.href)
if (opts.record) headers.set('X-Mock-Recorded', 'true')
return new Response(buf, { status: res.status, headers })
}
function recordMock(routesDir: string, pathname: string, method: string, body: unknown) {
const segs = pathname.split('/').filter(Boolean)
const base = segs.length ? segs.join('/') : 'index'
const suffix = method.toLowerCase() === 'get' ? '' : `.${method.toLowerCase()}`
const file = join(routesDir, `${base}${suffix}.json`)
mkdirSync(dirname(file), { recursive: true })
writeFileSync(file, JSON.stringify(body, null, 2) + '\n')
console.log(`⤓ recorded ${method} ${pathname} -> ${file}`)
}
function stripHopHeaders(h: Headers): Headers {
const out = new Headers(h)
for (const k of ['host', 'connection', 'content-length', 'accept-encoding']) out.delete(k)
return out
}
+164
View File
@@ -0,0 +1,164 @@
import { readdirSync, statSync, watch } from 'node:fs'
import { join, relative, sep } from 'node:path'
import { store } from './store.ts'
export type HttpMethod = 'get' | 'post' | 'put' | 'patch' | 'delete' | 'options' | 'head'
const METHODS = new Set<HttpMethod>(['get', 'post', 'put', 'patch', 'delete', 'options', 'head'])
export type CrudOp = 'list' | 'create' | 'get' | 'put' | 'patch' | 'delete'
export interface RouteEntry {
file: string
method: HttpMethod
status?: number
template: string
segments: { value: string; param?: string; splat?: boolean }[]
kind: 'json' | 'module' | 'crud' | 'sse'
/** present when kind === 'crud' */
crud?: { collection: string; op: CrudOp }
}
export interface MatchResult {
entry: RouteEntry
params: Record<string, string>
}
const DATA_EXT = /\.(json5?)$/i
const MOD_EXT = /\.(m?js|ts)$/i
const CRUD_RE = /\.crud\.json5?$/i
function parseFilename(name: string) {
const parts = name.split('.')
const ext = parts.pop()!.toLowerCase()
let kind: RouteEntry['kind'] = ext === 'json' || ext === 'json5' ? 'json' : 'module'
let method: HttpMethod = 'get'
let status: number | undefined
// .sse.json -> streaming route
if (kind === 'json' && parts[parts.length - 1]?.toLowerCase() === 'sse') {
kind = 'sse'
parts.pop()
}
while (parts.length > 1) {
const tail = parts[parts.length - 1].toLowerCase()
if (METHODS.has(tail as HttpMethod)) method = parts.pop() as HttpMethod
else if (/^\d{3}$/.test(tail)) status = Number(parts.pop())
else break
}
return { base: parts.join('.'), method, status, kind }
}
function toSegments(relPath: string, base: string): RouteEntry['segments'] {
const dirParts = relPath.split(sep).slice(0, -1)
const all = [...dirParts, base].filter((p) => p && p !== 'index')
return all.map((value) => {
const splat = value.match(/^\[\.\.\.(.+)\]$/)
if (splat) return { value, param: splat[1], splat: true }
const m = value.match(/^\[(.+)\]$/)
return m ? { value, param: m[1] } : { value }
})
}
function segTemplate(segments: RouteEntry['segments']) {
return '/' + segments.map((s) => (s.splat ? `*${s.param}` : s.param ? `:${s.param}` : s.value)).join('/')
}
/** Expand a *.crud.json seed into 6 REST routes backed by the in-memory store. */
function crudEntries(file: string, baseSegments: RouteEntry['segments'], collection: string): RouteEntry[] {
store.seed(collection, file)
if (process.env.PERSIST === 'true') store.enablePersist(collection, file)
const idSeg = { value: '[id]', param: 'id' }
const mk = (method: HttpMethod, withId: boolean, op: CrudOp, status?: number): RouteEntry => {
const segments = withId ? [...baseSegments, idSeg] : baseSegments
return { file, method, status, segments, template: segTemplate(segments), kind: 'crud', crud: { collection, op } }
}
return [
mk('get', false, 'list'),
mk('post', false, 'create', 201),
mk('get', true, 'get'),
mk('put', true, 'put'),
mk('patch', true, 'patch'),
mk('delete', true, 'delete', 204),
]
}
export function scan(routesDir: string): RouteEntry[] {
const entries: RouteEntry[] = []
const walk = (dir: string) => {
let names: string[]
try {
names = readdirSync(dir)
} catch {
return
}
for (const n of names) {
// single leading underscore = private/partial (ignored); double = served (grouping)
if (n.startsWith('.') || (n.startsWith('_') && !n.startsWith('__'))) continue
const abs = join(dir, n)
if (statSync(abs).isDirectory()) {
walk(abs)
continue
}
const rel = relative(routesDir, abs)
if (CRUD_RE.test(n)) {
const base = n.replace(CRUD_RE, '')
const segs = toSegments(rel, base)
entries.push(...crudEntries(abs, segs, segs.map((s) => s.value).join('.') || base))
continue
}
if (!DATA_EXT.test(n) && !MOD_EXT.test(n)) continue
const { base, method, status, kind } = parseFilename(n)
const segments = toSegments(rel, base)
entries.push({ file: abs, method, status, kind, segments, template: segTemplate(segments) })
}
}
walk(routesDir)
const hasSplat = (e: RouteEntry) => (e.segments[e.segments.length - 1]?.splat ? 1 : 0)
entries.sort((a, b) => {
if (hasSplat(a) !== hasSplat(b)) return hasSplat(a) - hasSplat(b) // splat routes last
if (b.segments.length !== a.segments.length) return b.segments.length - a.segments.length
return a.segments.filter((s) => s.param).length - b.segments.filter((s) => s.param).length
})
return entries
}
export function match(entries: RouteEntry[], method: string, pathname: string): MatchResult | null {
const reqMethod = method.toLowerCase()
const reqSegs = pathname.split('/').filter(Boolean)
for (const entry of entries) {
if (entry.method !== reqMethod && !(reqMethod === 'head' && entry.method === 'get')) continue
const last = entry.segments[entry.segments.length - 1]
const hasSplat = last?.splat
if (hasSplat) {
if (reqSegs.length < entry.segments.length - 1) continue
} else if (entry.segments.length !== reqSegs.length) continue
const params: Record<string, string> = {}
let ok = true
for (let i = 0; i < entry.segments.length; i++) {
const seg = entry.segments[i]
if (seg.splat) {
params[seg.param!] = reqSegs.slice(i).map(decodeURIComponent).join('/')
break
}
if (seg.param) params[seg.param] = decodeURIComponent(reqSegs[i])
else if (seg.value !== reqSegs[i]) {
ok = false
break
}
}
if (ok) return { entry, params }
}
return null
}
export function watchRoutes(routesDir: string, onChange: () => void) {
let t: NodeJS.Timeout | null = null
try {
watch(routesDir, { recursive: true }, () => {
if (t) clearTimeout(t)
t = setTimeout(onChange, 120)
})
} catch {
/* recursive watch unsupported — skip */
}
}
+295
View File
@@ -0,0 +1,295 @@
import { pathToFileURL } from 'node:url'
import type { Context } from 'hono'
import { streamSSE } from 'hono/streaming'
import type { MatchResult, CrudOp } from './registry.ts'
import { parseDataFile } from './parse.ts'
import { store } from './store.ts'
import { applyTemplate } from './template.ts'
import { nextSeqIndex, rateCheck } from './runtime.ts'
import { validateBody, type ValidateSchema } from './validate.ts'
import { proxyPass } from './proxy.ts'
export interface MockContext {
params: Record<string, string>
query: Record<string, string>
headers: Record<string, string>
method: string
body: unknown
faker: typeof import('@faker-js/faker').faker
}
export type Delay = number | [number, number]
export interface MockResponse {
status?: number
headers?: Record<string, string>
delay?: Delay
body?: unknown
/** when set, the request is forwarded to this upstream instead */
proxy?: string
}
export function resolveDelay(d: Delay | undefined): number {
if (d === undefined) return 0
if (Array.isArray(d)) {
const lo = Math.min(d[0], d[1])
const hi = Math.max(d[0], d[1])
return Math.round(lo + Math.random() * (hi - lo))
}
return d
}
const CONTROL = new Set([
'__status', '__headers', '__delay', '__delayed', '__body', '__variants',
'__sequence', '__chaos', '__rateLimit', '__auth', '__validate', '__proxy', '__doc',
])
function stripControl(obj: Record<string, unknown>): unknown {
if ('__body' in obj) return obj.__body
const out: Record<string, unknown> = {}
for (const k of Object.keys(obj)) if (!CONTROL.has(k)) out[k] = obj[k]
return out
}
// ── matchers for __variants ────────────────────────────────────────────────
interface Variant {
when?: { query?: Record<string, string>; headers?: Record<string, string>; params?: Record<string, string>; body?: Record<string, unknown> }
status?: number
headers?: Record<string, string>
delay?: Delay
body?: unknown
}
function subset(want: Record<string, unknown> | undefined, got: Record<string, unknown>, ci = false): boolean {
if (!want) return true
const g = ci ? Object.fromEntries(Object.entries(got).map(([k, v]) => [k.toLowerCase(), v])) : got
for (const [k, v] of Object.entries(want)) {
const key = ci ? k.toLowerCase() : k
if (typeof v === 'object' && v !== null && typeof g[key] === 'object' && g[key] !== null) {
if (!subset(v as Record<string, unknown>, g[key] as Record<string, unknown>)) return false
} else if (String(g[key]) !== String(v)) return false
}
return true
}
function matchWhen(w: Variant['when'], ctx: MockContext): boolean {
if (!w) return true
return (
subset(w.query, ctx.query) &&
subset(w.headers, ctx.headers, true) &&
subset(w.params, ctx.params) &&
subset(w.body, (ctx.body as Record<string, unknown>) ?? {})
)
}
// ── auth / chaos ───────────────────────────────────────────────────────────
function checkAuth(spec: unknown, ctx: MockContext): MockResponse | null {
const auth = ctx.headers['authorization'] ?? ''
const token = auth.replace(/^Bearer\s+/i, '')
const want = typeof spec === 'object' && spec ? (spec as any).token : undefined
const ok = want ? token === want : token.length > 0
return ok ? null : { status: 401, headers: { 'WWW-Authenticate': 'Bearer' }, body: { error: 'unauthorized' } }
}
// ── the JSON route engine ──────────────────────────────────────────────────
function fromJson(file: string, ctx: MockContext): MockResponse {
const raw = parseDataFile(file)
if (Array.isArray(raw)) return { body: applyTemplate(raw, ctx) }
if (typeof raw !== 'object' || raw === null) return { body: raw }
const o = raw as Record<string, unknown>
// per-route proxy short-circuit
if (typeof o.__proxy === 'string') return { proxy: o.__proxy }
// auth gate
if (o.__auth) {
const denied = checkAuth(o.__auth, ctx)
if (denied) return denied
}
// rate limit
if (o.__rateLimit) {
const { max = 60, windowMs = 60_000 } = o.__rateLimit as { max?: number; windowMs?: number }
const r = rateCheck(file, max, windowMs, Date.now())
if (!r.allowed) {
return {
status: 429,
headers: { 'Retry-After': String(Math.ceil(r.retryMs / 1000)), 'X-RateLimit-Limit': String(max) },
body: { error: 'rate limited', retryMs: r.retryMs },
}
}
}
// per-route chaos
if (o.__chaos) {
const { rate = 0, status = 500, body } = o.__chaos as { rate?: number; status?: number; body?: unknown }
if (Math.random() < rate) return { status, body: body ?? { error: 'chaos', injected: true } }
}
// body validation (write methods)
if (o.__validate && ['post', 'put', 'patch'].includes(ctx.method.toLowerCase())) {
const errors = validateBody(o.__validate as ValidateSchema, ctx.body)
if (errors.length) return { status: 422, body: { error: 'validation failed', errors } }
}
// sequence: different response each call
if (Array.isArray(o.__sequence)) {
const seq = o.__sequence as any[]
const item = seq[nextSeqIndex(file, seq.length)]
const r: MockResponse =
item && typeof item === 'object' && ('body' in item || 'status' in item)
? { status: item.status, headers: item.headers, delay: item.delay, body: item.body }
: { body: item }
r.body = applyTemplate(r.body, ctx)
return r
}
// variants
if (Array.isArray(o.__variants)) {
const chosen = (o.__variants as Variant[]).find((v) => matchWhen(v.when, ctx))
if (!chosen) return { status: 404, body: { error: 'no matching variant' } }
return { status: chosen.status, headers: chosen.headers, delay: chosen.delay, body: applyTemplate(chosen.body, ctx) }
}
// plain body + control envelope
const rawDelay = o.__delay ?? o.__delayed
return {
status: typeof o.__status === 'number' ? o.__status : undefined,
headers: (o.__headers as Record<string, string>) ?? undefined,
delay: typeof rawDelay === 'number' || Array.isArray(rawDelay) ? (rawDelay as Delay) : undefined,
body: applyTemplate(stripControl(o), ctx),
}
}
async function fromModule(file: string, ctx: MockContext): Promise<MockResponse> {
const url = pathToFileURL(file).href + `?t=${Date.now()}`
const mod = await import(url)
const handler = mod.default ?? mod.handler
if (typeof handler !== 'function') return { status: 500, body: { error: `module ${file} has no default export function` } }
const result = await handler(ctx)
if (result && typeof result === 'object' && ('body' in result || 'status' in result)) return result as MockResponse
return { body: result }
}
// ── CRUD with pagination + sort ────────────────────────────────────────────
function fromCrud(collection: string, op: CrudOp, ctx: MockContext): MockResponse {
const id = ctx.params.id
const q = ctx.query
switch (op) {
case 'list': {
let items = store.list(collection)
for (const [k, v] of Object.entries(q)) {
if (k.startsWith('_')) continue
items = items.filter((it) => String((it as Record<string, unknown>)[k]) === v)
}
if (q._sort) {
const key = q._sort
const dir = q._order === 'desc' ? -1 : 1
items = [...items].sort((a, b) => {
const av = (a as any)[key], bv = (b as any)[key]
return av < bv ? -dir : av > bv ? dir : 0
})
}
const total = items.length
if (q._start || q._end) {
items = items.slice(Number(q._start ?? 0), q._end ? Number(q._end) : undefined)
} else if (q._page || q._limit) {
const limit = Number(q._limit ?? 10)
const page = Number(q._page ?? 1)
items = items.slice((page - 1) * limit, (page - 1) * limit + limit)
} else if (q._limit) {
items = items.slice(0, Number(q._limit))
}
return { body: items, headers: { 'X-Total-Count': String(total), 'Access-Control-Expose-Headers': 'X-Total-Count' } }
}
case 'get': {
const rec = store.get(collection, id)
return rec ? { body: rec } : { status: 404, body: { error: 'not found' } }
}
case 'create':
return { status: 201, body: store.create(collection, (ctx.body as Record<string, unknown>) ?? {}) }
case 'put':
case 'patch': {
const rec = store.update(collection, id, (ctx.body as Record<string, unknown>) ?? {}, op === 'patch')
return rec ? { body: rec } : { status: 404, body: { error: 'not found' } }
}
case 'delete':
return store.remove(collection, id) ? { status: 204, body: null } : { status: 404, body: { error: 'not found' } }
}
}
// ── SSE streaming ──────────────────────────────────────────────────────────
function fromSse(c: Context, file: string): Response {
let cfg: any
try {
cfg = parseDataFile(file)
} catch {
return c.json({ error: 'invalid sse file' }, 500)
}
const events: any[] = Array.isArray(cfg) ? cfg : cfg.events ?? []
const loop = !Array.isArray(cfg) && !!cfg.loop
return streamSSE(c, async (stream) => {
let round = 0
do {
for (const ev of events) {
if (stream.aborted) return
if (ev.delay) await stream.sleep(ev.delay)
await stream.writeSSE({
data: typeof ev.data === 'string' ? ev.data : JSON.stringify(ev.data ?? ev),
event: ev.event,
id: ev.id != null ? String(ev.id) : undefined,
})
}
round++
} while (loop && !stream.aborted && round < 10_000)
})
}
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms))
export async function respond(
c: Context,
m: MatchResult,
opts: { defaultDelay: Delay; faker: MockContext['faker']; routesDir: string },
): Promise<Response> {
const { entry, params } = m
let parsedBody: unknown
if (['post', 'put', 'patch', 'delete'].includes(entry.method)) {
try {
parsedBody = await c.req.json()
} catch {
parsedBody = undefined
}
}
const ctx: MockContext = {
params,
query: c.req.query(),
headers: Object.fromEntries(c.req.raw.headers),
method: c.req.method,
body: parsedBody,
faker: opts.faker,
}
if (entry.kind === 'sse') return fromSse(c, entry.file)
const res =
entry.kind === 'crud'
? fromCrud(entry.crud!.collection, entry.crud!.op, ctx)
: entry.kind === 'json'
? fromJson(entry.file, ctx)
: await fromModule(entry.file, ctx)
if (res.proxy) return proxyPass(c, { target: res.proxy, record: false, routesDir: opts.routesDir })
const status = res.status ?? entry.status ?? 200
const delay = resolveDelay(res.delay ?? opts.defaultDelay)
if (delay > 0) await sleep(delay)
if (res.headers) for (const [k, v] of Object.entries(res.headers)) c.header(k, v)
c.header('X-Mock-File', entry.file)
if (status === 204 || res.body === null || res.body === undefined) return c.body(null, status as any)
return c.json(res.body as any, status as any)
}
+26
View File
@@ -0,0 +1,26 @@
// Per-route runtime state: sequence cursors + rate-limit windows.
// Keyed by route file; reset on server restart.
const seqCursor = new Map<string, number>()
/** Return the next index in a sequence of length n, advancing the cursor. */
export function nextSeqIndex(key: string, n: number): number {
const i = seqCursor.get(key) ?? 0
seqCursor.set(key, i + 1)
return i % n
}
const rlHits = new Map<string, number[]>()
/** Sliding-window rate limit. Returns remaining + whether this call is allowed. */
export function rateCheck(key: string, max: number, windowMs: number, now: number) {
const hits = (rlHits.get(key) ?? []).filter((t) => now - t < windowMs)
if (hits.length >= max) {
rlHits.set(key, hits)
const retryMs = windowMs - (now - hits[0])
return { allowed: false, remaining: 0, retryMs }
}
hits.push(now)
rlHits.set(key, hits)
return { allowed: true, remaining: max - hits.length, retryMs: 0 }
}
+93
View File
@@ -0,0 +1,93 @@
import { writeFileSync } from 'node:fs'
import { parseDataFile } from './parse.ts'
type Record_ = Record<string, unknown>
/** In-memory collections for stateful CRUD mocks. Seeded from .crud.json files. */
class Store {
private cols = new Map<string, Map<string, Record_>>()
private counters = new Map<string, number>()
private seeded = new Set<string>()
private persistFile = new Map<string, string>()
private saveTimers = new Map<string, NodeJS.Timeout>()
/** Enable disk write-back for a collection (PERSIST=true). */
enablePersist(collection: string, file: string) {
this.persistFile.set(collection, file)
}
private save(c: string) {
const file = this.persistFile.get(c)
if (!file) return
const prev = this.saveTimers.get(c)
if (prev) clearTimeout(prev)
this.saveTimers.set(
c,
setTimeout(() => {
try {
writeFileSync(file, JSON.stringify(this.list(c), null, 2) + '\n')
} catch (e) {
console.error(`persist failed for ${c}:`, e)
}
}, 150),
)
}
/** Seed a collection once from its source file (idempotent across hot reloads). */
seed(collection: string, file: string) {
if (this.seeded.has(collection)) return
this.seeded.add(collection)
const col = new Map<string, Record_>()
let max = 0
try {
const data = parseDataFile(file)
const arr = Array.isArray(data) ? data : []
for (const item of arr as Record_[]) {
const id = String(item.id ?? ++max)
if (Number(id) > max) max = Number(id)
col.set(id, { ...item, id: coerceId(item.id ?? id) })
}
} catch {
/* empty / invalid seed → empty collection */
}
this.cols.set(collection, col)
this.counters.set(collection, max)
}
list(c: string) {
return [...(this.cols.get(c)?.values() ?? [])]
}
get(c: string, id: string) {
return this.cols.get(c)?.get(id) ?? null
}
create(c: string, body: Record_) {
const col = this.cols.get(c)!
const next = (this.counters.get(c) ?? 0) + 1
this.counters.set(c, next)
const id = body.id != null ? coerceId(body.id) : next
const rec = { ...body, id }
col.set(String(id), rec)
this.save(c)
return rec
}
update(c: string, id: string, body: Record_, merge: boolean) {
const col = this.cols.get(c)
if (!col?.has(id)) return null
const rec = merge ? { ...col.get(id), ...body, id: coerceId(id) } : { ...body, id: coerceId(id) }
col.set(id, rec)
this.save(c)
return rec
}
remove(c: string, id: string) {
const ok = this.cols.get(c)?.delete(id) ?? false
if (ok) this.save(c)
return ok
}
}
function coerceId(v: unknown) {
const n = Number(v)
return Number.isInteger(n) && String(n) === String(v) ? n : v
}
export const store = new Store()
+58
View File
@@ -0,0 +1,58 @@
import type { MockContext } from './respond.ts'
/**
* Replace `{{ path }}` placeholders inside a JSON value.
* {{params.id}} {{query.q}} {{headers.x-foo}}
* {{faker.person.fullName}} {{faker.number.int}} (zero-arg faker methods)
* If a string is exactly one placeholder, the resolved (typed) value is used —
* so {{faker.number.int}} yields a number, not "123".
*/
const RE = /\{\{\s*([\w.$-]+)\s*\}\}/g
function resolve(path: string, ctx: MockContext): unknown {
const parts = path.split('.')
let cur: any =
parts[0] === 'faker'
? ctx.faker
: parts[0] === 'params'
? ctx.params
: parts[0] === 'query'
? ctx.query
: parts[0] === 'headers'
? ctx.headers
: undefined
if (cur === undefined) return undefined
for (let i = 1; i < parts.length; i++) {
cur = cur?.[parts[i]]
if (cur === undefined) return undefined
}
return typeof cur === 'function' ? cur.call(parts[0] === 'faker' ? undefined : cur) : cur
}
export function hasTemplate(v: unknown): boolean {
if (typeof v === 'string') return RE.test(v)
if (Array.isArray(v)) return v.some(hasTemplate)
if (v && typeof v === 'object') return Object.values(v).some(hasTemplate)
return false
}
export function applyTemplate(value: unknown, ctx: MockContext): unknown {
if (typeof value === 'string') {
const whole = value.match(/^\{\{\s*([\w.$-]+)\s*\}\}$/)
if (whole) {
const r = resolve(whole[1], ctx)
return r === undefined ? value : r
}
return value.replace(RE, (m, p) => {
const r = resolve(p, ctx)
return r === undefined ? m : String(r)
})
}
if (Array.isArray(value)) return value.map((v) => applyTemplate(v, ctx))
if (value && typeof value === 'object') {
const out: Record<string, unknown> = {}
for (const [k, v] of Object.entries(value)) out[k] = applyTemplate(v, ctx)
return out
}
return value
}
+38
View File
@@ -0,0 +1,38 @@
/**
* Tiny body validator. Schema maps field -> type spec:
* { name: 'string', age: 'number', email: 'string?', tags: 'array', meta: 'object' }
* A trailing `?` makes the field optional. Returns a list of error messages
* (empty = valid).
*/
export type ValidateSchema = Record<string, string>
const checkType = (v: unknown, t: string): boolean => {
switch (t) {
case 'string': return typeof v === 'string'
case 'number': return typeof v === 'number'
case 'boolean': return typeof v === 'boolean'
case 'array': return Array.isArray(v)
case 'object': return v !== null && typeof v === 'object' && !Array.isArray(v)
case 'any': return true
default: return true
}
}
export function validateBody(schema: ValidateSchema, body: unknown): string[] {
const errors: string[] = []
const obj = (body ?? {}) as Record<string, unknown>
if (body === null || typeof body !== 'object' || Array.isArray(body)) {
return ['body must be a JSON object']
}
for (const [field, specRaw] of Object.entries(schema)) {
const optional = specRaw.endsWith('?')
const type = optional ? specRaw.slice(0, -1) : specRaw
const present = field in obj && obj[field] !== undefined && obj[field] !== null
if (!present) {
if (!optional) errors.push(`${field} is required`)
continue
}
if (!checkType(obj[field], type)) errors.push(`${field} must be ${type}`)
}
return errors
}
+15
View File
@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "bundler",
"strict": true,
"skipLibCheck": true,
"esModuleInterop": true,
"verbatimModuleSyntax": false,
"types": ["node"],
"allowImportingTsExtensions": true,
"noEmit": true
},
"include": ["src/**/*.ts", "routes/**/*.ts"]
}