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:
@@ -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
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
node_modules
|
||||||
|
.env
|
||||||
|
*.log
|
||||||
@@ -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.
|
||||||
@@ -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` 0–1). Per-route version of `CHAOS_RATE`. |
|
||||||
|
| `__rateLimit: { max, windowMs }` | after `max` requests per sliding window → `429` with `Retry-After`. |
|
||||||
|
| `__auth: "bearer"` or `{ token }` | require `Authorization: Bearer …` (any token, or an exact one) → `401` otherwise. |
|
||||||
|
| `__validate: { field: "type" }` | on POST/PUT/PATCH, check the body → `422 { errors }`. Types: `string number boolean array object any`, trailing `?` = optional. |
|
||||||
|
| `__proxy: "https://api.real.com"` | forward this one route to a real upstream (same path+query), tagged `X-Mock-Proxy`. Partial mocking. |
|
||||||
|
| `__doc: "text"` or `{ summary, description }` | sidebar label + markdown description shown in the visual docs (`/__docs`). |
|
||||||
|
|
||||||
|
```json5
|
||||||
|
// poll endpoint that becomes ready on the 3rd call
|
||||||
|
{ __sequence: [
|
||||||
|
{ status: 202, body: { state: 'pending' } },
|
||||||
|
{ status: 202, body: { state: 'pending' } },
|
||||||
|
{ status: 200, body: { state: 'ready' } },
|
||||||
|
] }
|
||||||
|
```
|
||||||
|
|
||||||
|
## Dynamic mocks (`.ts` / `.js`)
|
||||||
|
|
||||||
|
Export a default function for request-aware responses. Return a raw body, or a
|
||||||
|
`{ status, headers, delay, body }` envelope.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import type { MockContext } from '../../src/respond.ts'
|
||||||
|
|
||||||
|
// GET /users/:id -> deterministic synthetic user
|
||||||
|
export default function ({ params, query, faker }: MockContext) {
|
||||||
|
faker.seed(Number(params.id) || 1)
|
||||||
|
return {
|
||||||
|
id: params.id,
|
||||||
|
name: faker.person.fullName(),
|
||||||
|
verbose: query.verbose === '1' ? faker.lorem.paragraph() : undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`MockContext`:
|
||||||
|
|
||||||
|
| Field | Type | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `params` | `Record<string,string>` | path params |
|
||||||
|
| `query` | `Record<string,string>` | query string |
|
||||||
|
| `headers` | `Record<string,string>` | request headers |
|
||||||
|
| `method` | `string` | HTTP method |
|
||||||
|
| `body` | `unknown` | parsed JSON body (write methods) |
|
||||||
|
| `faker` | faker instance | [`@faker-js/faker`](https://fakerjs.dev) |
|
||||||
|
|
||||||
|
Handlers hot-reload on save (cache-busted import) — no restart.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
curl "localhost:4100/users/7?verbose=1"
|
||||||
|
# {"id":"7","name":"Patty Reilly IV","verbose":"Vicinus acidus ..."}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Stateful CRUD (`*.crud.json`)
|
||||||
|
|
||||||
|
A seed file (a JSON/JSON5 **array**) expands into a full REST resource backed by
|
||||||
|
an in-memory store. `routes/todos.crud.json` →
|
||||||
|
|
||||||
|
| Method | Route | Op |
|
||||||
|
|---|---|---|
|
||||||
|
| GET | `/todos` | list — filters `?field=value`, sort `?_sort=price&_order=desc`, paginate `?_page=2&_limit=10` (or `?_start&_end`), `X-Total-Count` header |
|
||||||
|
| POST | `/todos` | create (auto-increment `id`) → 201 |
|
||||||
|
| GET | `/todos/:id` | read (404 if missing) |
|
||||||
|
| PUT | `/todos/:id` | replace |
|
||||||
|
| PATCH | `/todos/:id` | merge |
|
||||||
|
| DELETE | `/todos/:id` | delete → 204 |
|
||||||
|
|
||||||
|
State lives in memory and survives hot reloads; a server restart resets it to the
|
||||||
|
seed — unless `PERSIST=true`, which writes mutations back to the `*.crud.json`
|
||||||
|
file so they survive restarts.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
curl -X POST localhost:4100/todos -H 'content-type: application/json' \
|
||||||
|
-d '{"title":"new","done":false}' # -> {"title":"new","done":false,"id":3}
|
||||||
|
curl "localhost:4100/todos?done=true" # filtered list
|
||||||
|
curl -X PATCH localhost:4100/todos/3 -d '{"done":true}' -H 'content-type: application/json'
|
||||||
|
curl -X DELETE localhost:4100/todos/1 -i # -> 204
|
||||||
|
```
|
||||||
|
|
||||||
|
## Conditional responses (`__variants`)
|
||||||
|
|
||||||
|
Pick a response by matching the request. The first variant whose `when` matches
|
||||||
|
wins; a variant without `when` is the fallback. Matchers are **subset** matches
|
||||||
|
over `query` / `headers` (case-insensitive) / `params` / `body`.
|
||||||
|
|
||||||
|
```json5
|
||||||
|
{ __variants: [
|
||||||
|
{ when: { query: { role: 'admin' } }, body: { seats: 999 } },
|
||||||
|
{ when: { headers: { 'x-tier': 'pro' } }, body: { seats: 25 } },
|
||||||
|
{ body: { seats: 1 } }, // fallback
|
||||||
|
] }
|
||||||
|
```
|
||||||
|
|
||||||
|
```sh
|
||||||
|
curl localhost:4100/account # {"seats":1}
|
||||||
|
curl "localhost:4100/account?role=admin" # {"seats":999}
|
||||||
|
curl localhost:4100/account -H 'x-tier: pro' # {"seats":25}
|
||||||
|
```
|
||||||
|
|
||||||
|
A variant may also set `status`, `headers`, `delay`. If nothing matches and there
|
||||||
|
is no fallback → 404.
|
||||||
|
|
||||||
|
## Splat routes (`[...rest]`)
|
||||||
|
|
||||||
|
A `[...rest]` segment catches any remaining path depth into one param:
|
||||||
|
|
||||||
|
```
|
||||||
|
routes/files/[...path].json → GET /files/a/b/c.txt (params.path = "a/b/c.txt")
|
||||||
|
```
|
||||||
|
|
||||||
|
Splat routes are matched **last**, so concrete and `[id]` routes always win first.
|
||||||
|
Combine with templating: `{ "path": "{{params.path}}" }`.
|
||||||
|
|
||||||
|
## Scenarios (`X-Scenario`)
|
||||||
|
|
||||||
|
Overlay a whole alternate route set without touching the base. Put files under
|
||||||
|
`routes/_scenarios/<name>/…` mirroring the normal paths. A request with header
|
||||||
|
`X-Scenario: <name>` is matched against that overlay first, then falls back to the
|
||||||
|
base routes.
|
||||||
|
|
||||||
|
```
|
||||||
|
routes/_scenarios/broken/users.500.json # X-Scenario: broken → GET /users = 500
|
||||||
|
routes/_scenarios/empty/users.json # X-Scenario: empty → GET /users = []
|
||||||
|
```
|
||||||
|
|
||||||
|
Flip your frontend between happy / empty / error states with one header. Active
|
||||||
|
scenario names are printed at startup.
|
||||||
|
|
||||||
|
## SSE streaming (`*.sse.json`)
|
||||||
|
|
||||||
|
A `*.sse.json` file streams Server-Sent Events (`text/event-stream`):
|
||||||
|
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
loop: false, // repeat the sequence?
|
||||||
|
events: [
|
||||||
|
{ event: 'tick', id: 1, data: { n: 1 }, delay: 150 },
|
||||||
|
{ event: 'done', id: 2, data: { n: 2 }, delay: 150 },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`delay` waits before each event; `data` is JSON-encoded automatically. The stream
|
||||||
|
ends after the events (or loops until the client disconnects when `loop: true`).
|
||||||
|
Consume with `new EventSource('/ticker')` or `curl -N`.
|
||||||
|
|
||||||
|
## Proxy & record
|
||||||
|
|
||||||
|
Set `PROXY_TARGET` and any **unmatched** request forwards to a real upstream
|
||||||
|
(response tagged `X-Mock-Proxy`). With `RECORD=true`, JSON responses are saved as
|
||||||
|
mock files — record once, replay forever; the recorded route is served locally on
|
||||||
|
the next request.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
PROXY_TARGET=https://api.example.com RECORD=true npm run dev
|
||||||
|
# first GET /v1/users -> proxied to upstream, saved as routes/v1/users.json
|
||||||
|
# second GET /v1/users -> served locally (X-Mock-File header)
|
||||||
|
```
|
||||||
|
|
||||||
|
Only successful JSON responses are recorded; non-GET methods get a `.<method>`
|
||||||
|
filename suffix.
|
||||||
|
|
||||||
|
## OpenAPI import
|
||||||
|
|
||||||
|
Generate route files from an OpenAPI 3 spec (json or yaml):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm run openapi -- path/to/openapi.yaml [outDir]
|
||||||
|
# or: web-mock openapi path/to/openapi.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
Each path × method becomes a file. The body is the response `example`, the first
|
||||||
|
of `examples`, or a value synthesized from the schema (`$ref`, `allOf`, `oneOf`,
|
||||||
|
formats like `date-time`/`email`/`uuid` handled). Non-200 success codes go in the
|
||||||
|
filename (e.g. `pets.post.201.json`). Defaults to `./routes`.
|
||||||
|
|
||||||
|
## Visual docs (`/__docs`)
|
||||||
|
|
||||||
|
Open **`http://localhost:4100/__docs`** for a live, interactive API reference
|
||||||
|
([Scalar](https://github.com/scalar/scalar), loaded from CDN). It renders every
|
||||||
|
route — methods, path params, example responses — with a built-in "try it"
|
||||||
|
client. No build step; the page reads the live spec.
|
||||||
|
|
||||||
|
Add your own per-route docs:
|
||||||
|
|
||||||
|
- **JSON routes** — a `__doc` key (string = description, or `{ summary, description }`;
|
||||||
|
markdown, stripped from the response):
|
||||||
|
```json5
|
||||||
|
{ __doc: { summary: 'Create user', description: 'Returns **201**.' }, __status: 201, __body: { id: 1 } }
|
||||||
|
```
|
||||||
|
- **`.ts`/`.js` handlers** — `export const doc = 'text'` or `{ summary, description }`
|
||||||
|
alongside the default handler.
|
||||||
|
- **Group descriptions** — a `routes/_tags.json5` mapping the first path segment to
|
||||||
|
text, shown atop each sidebar section:
|
||||||
|
```json5
|
||||||
|
{ todos: 'Demo CRUD resource.', users: 'User endpoints.' }
|
||||||
|
```
|
||||||
|
|
||||||
|
The underlying spec is generated from the route files on every request at
|
||||||
|
**`GET /__openapi.json`** (OpenAPI 3.0.3). Examples come from the actual mock
|
||||||
|
bodies: JSON files use their payload, CRUD routes show the seed data, `__variants`
|
||||||
|
use the first variant, dynamic `.ts` handlers are marked `(dynamic)`.
|
||||||
|
|
||||||
|
Prefer Swagger UI instead of Scalar? Point it at `/__openapi.json`, or swap the
|
||||||
|
two `<script>` lines in the `/__docs` handler (`src/index.ts`) for the Swagger UI
|
||||||
|
CDN bundle — same spec URL.
|
||||||
|
|
||||||
|
## Built-in endpoints
|
||||||
|
|
||||||
|
| Endpoint | Returns |
|
||||||
|
|---|---|
|
||||||
|
| `GET /__docs` | interactive API reference (Scalar UI) |
|
||||||
|
| `GET /__openapi.json` | OpenAPI 3 spec generated live from the route files |
|
||||||
|
| `GET /__routes` | every registered mock: `{ count, routes: [{ method, path, status, kind }] }` |
|
||||||
|
| `GET /__health` | `{ ok: true, routes }` |
|
||||||
|
|
||||||
|
Every mock response carries an `X-Mock-File` header pointing at the source file.
|
||||||
|
|
||||||
|
## Config (`.env`)
|
||||||
|
|
||||||
|
| Var | Default | Meaning |
|
||||||
|
|---|---|---|
|
||||||
|
| `PORT` | `3000` | listen port |
|
||||||
|
| `REQUEST_DELAY` | `0` | default delay when a route sets none — ms (`500`) or random range (`200-800`) |
|
||||||
|
| `COMPRESS` | `true` | gzip/deflate responses |
|
||||||
|
| `CORS_ORIGIN` | `*` | CORS allow-origin |
|
||||||
|
| `ROUTES_DIR` | `./routes` | route source directory |
|
||||||
|
| `CHAOS_RATE` | `0` | fraction of requests to fail randomly (`0.1` = 10%) |
|
||||||
|
| `CHAOS_STATUS` | `500` | status returned for chaos failures |
|
||||||
|
| `PROXY_TARGET` | _(empty)_ | upstream base URL for unmatched requests (off when empty) |
|
||||||
|
| `RECORD` | `false` | when proxying, save JSON responses as mock files |
|
||||||
|
| `PERSIST` | `false` | write CRUD mutations back to the `*.crud.json` file (survives restart) |
|
||||||
|
|
||||||
|
## Workspace alias
|
||||||
|
|
||||||
|
```sh
|
||||||
|
source alias.sh
|
||||||
|
web-mock dev # watch + hot reload
|
||||||
|
web-mock start # run once (no watch)
|
||||||
|
web-mock openapi spec.yaml # generate routes from a spec
|
||||||
|
web-mock lint # tsc typecheck
|
||||||
|
```
|
||||||
|
|
||||||
|
## Project layout
|
||||||
|
|
||||||
|
```
|
||||||
|
mockly-hono/
|
||||||
|
├─ src/
|
||||||
|
│ ├─ index.ts server bootstrap, middleware, catch-all, proxy wiring
|
||||||
|
│ ├─ registry.ts scan routes, parse filenames, match requests, crud expansion, watcher
|
||||||
|
│ ├─ respond.ts build response: json / module / crud / variants
|
||||||
|
│ ├─ store.ts in-memory CRUD collections (seed-once) + persistence
|
||||||
|
│ ├─ template.ts {{...}} placeholder resolution
|
||||||
|
│ ├─ runtime.ts per-route state: sequence cursors, rate-limit windows
|
||||||
|
│ ├─ validate.ts tiny body validator
|
||||||
|
│ ├─ proxy.ts upstream passthrough + record-as-mock
|
||||||
|
│ ├─ openapi-gen.ts routes -> live OpenAPI spec for /__docs
|
||||||
|
│ └─ parse.ts json5 reader
|
||||||
|
├─ scripts/
|
||||||
|
│ └─ from-openapi.ts OpenAPI 3 spec -> route files
|
||||||
|
├─ routes/ your mocks (examples included)
|
||||||
|
├─ .env.example
|
||||||
|
└─ package.json
|
||||||
|
```
|
||||||
|
|
||||||
|
## What changed vs original mockly
|
||||||
|
|
||||||
|
| | original | mockly-hono |
|
||||||
|
|---|---|---|
|
||||||
|
| Core | Express | Hono (Node/Bun/Deno/edge) |
|
||||||
|
| Methods | GET only | all HTTP methods (filename) |
|
||||||
|
| Status | always 200 | per-route via filename / envelope |
|
||||||
|
| Path params | ✗ | `[id]` → `:id` |
|
||||||
|
| Custom headers | ✗ | `__headers` / handler |
|
||||||
|
| Dynamic responses | ✗ | `.ts`/`.js` handlers + faker |
|
||||||
|
| Templating | ✗ | `{{faker.x}}` / `{{params.x}}` in plain JSON |
|
||||||
|
| Sequence / chaos / rate-limit / auth / validate | ✗ | per-route control keys |
|
||||||
|
| Stateful CRUD | ✗ | `*.crud.json` → 6 routes, pagination + sort, optional persist |
|
||||||
|
| Conditional responses | ✗ | `__variants` |
|
||||||
|
| Splat routes | ✗ | `[...rest]` |
|
||||||
|
| Scenarios | ✗ | `X-Scenario` overlay |
|
||||||
|
| SSE streaming | ✗ | `*.sse.json` |
|
||||||
|
| Per-route proxy | ✗ | `__proxy` |
|
||||||
|
| JSON5 | ✗ | ✓ |
|
||||||
|
| Proxy / record | ✗ | `PROXY_TARGET` + `RECORD` |
|
||||||
|
| OpenAPI import | ✗ | `npm run openapi` |
|
||||||
|
| Hot reload | nodemon full restart | request-time resolver, no restart |
|
||||||
|
| Introspection | ✗ | `/__routes`, `/__health` |
|
||||||
|
| Visual docs | ✗ | `/__docs` (Scalar) + live `/__openapi.json` |
|
||||||
|
| Chaos injection | ✗ | `CHAOS_RATE` |
|
||||||
|
| `__delayed` body leak | bug (leaked to client) | fixed (control keys stripped) |
|
||||||
Generated
+619
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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`.
|
||||||
@@ -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.',
|
||||||
|
]
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"__delay": 250,
|
||||||
|
"__headers": { "Location": "/__test/created/99", "X-Demo": "control-keys" },
|
||||||
|
"__body": { "id": 99, "created": true }
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"feature": "splat route — catches any depth",
|
||||||
|
"captured": "{{params.path}}"
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"__sequence": [
|
||||||
|
{ "status": 202, "body": { "state": "pending" } },
|
||||||
|
{ "status": 202, "body": { "state": "pending" } },
|
||||||
|
{ "status": 200, "body": { "state": "ready", "result": 42 } }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"__rateLimit": { "max": 3, "windowMs": 10000 },
|
||||||
|
"feature": "rate limit (3 per 10s, then 429)",
|
||||||
|
"ok": true
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
]
|
||||||
@@ -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: [] },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"__chaos": { "rate": 0.5, "status": 503, "body": { "error": "unlucky" } },
|
||||||
|
"feature": "per-route chaos (50% -> 503)",
|
||||||
|
"ok": true
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"__auth": { "token": "s3cret" },
|
||||||
|
"feature": "auth gate",
|
||||||
|
"data": "top secret — needs Authorization: Bearer s3cret"
|
||||||
|
}
|
||||||
@@ -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" }
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"__delay": 1500,
|
||||||
|
"feature": "delay",
|
||||||
|
"note": "responds after 1.5s — time it: curl -w '%{time_total}s' localhost:4100/__test/slow"
|
||||||
|
}
|
||||||
@@ -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]
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"__body": { "feature": "status from filename", "error": "I'm a teapot" }
|
||||||
|
}
|
||||||
@@ -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}}"
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -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}}"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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}}" }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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}}"
|
||||||
|
}
|
||||||
@@ -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}}"
|
||||||
|
}
|
||||||
@@ -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" }
|
||||||
|
}
|
||||||
@@ -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.',
|
||||||
|
}
|
||||||
@@ -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 },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"__body": { "error": "not found", "code": "DEMO_404" }
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"title": "Homepage",
|
||||||
|
"blocks": ["hero", "features", "footer"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
[
|
||||||
|
{ "id": 1, "title": "wire up mockly", "done": true },
|
||||||
|
{ "id": 2, "title": "write tests", "done": false }
|
||||||
|
]
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"__delay": 300,
|
||||||
|
"__headers": { "Location": "/users/42" },
|
||||||
|
"__body": { "id": 42, "created": true }
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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 })
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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'))
|
||||||
|
}
|
||||||
@@ -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
@@ -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
@@ -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)
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user