feat: mockly-hono — file-based mock API server (Hono)

File path = route. JSON/JSON5, dynamic .ts handlers + faker, templating,
stateful CRUD (pagination/sort/persist), __variants, sequence, chaos,
rate-limit, auth, validation, per-route + global proxy/record, splat routes,
X-Scenario overlays, SSE. Visual docs (Scalar) at /__docs from a live
OpenAPI 3 spec. OpenAPI import script. Zero-build via tsx.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Peter Meier
2026-06-23 12:44:31 +02:00
commit b122de518c
53 changed files with 2563 additions and 0 deletions
+162
View File
@@ -0,0 +1,162 @@
import { serve } from '@hono/node-server'
import { Hono } from 'hono'
import { cors } from 'hono/cors'
import { compress } from 'hono/compress'
import { logger } from 'hono/logger'
import { faker } from '@faker-js/faker'
import { fileURLToPath } from 'node:url'
import { dirname, join } from 'node:path'
import { readdirSync, statSync } from 'node:fs'
import { scan, match, watchRoutes, type RouteEntry } from './registry.ts'
import { respond } from './respond.ts'
import { proxyPass } from './proxy.ts'
import { buildOpenApi } from './openapi-gen.ts'
const __dirname = dirname(fileURLToPath(import.meta.url))
const ROUTES_DIR = process.env.ROUTES_DIR
? process.env.ROUTES_DIR
: join(__dirname, '..', 'routes')
const PORT = Number(process.env.PORT ?? 4100)
// REQUEST_DELAY: "500" fixed, or "200-800" random range per request
const DEFAULT_DELAY: number | [number, number] = (() => {
const raw = process.env.REQUEST_DELAY ?? '0'
const m = raw.match(/^(\d+)-(\d+)$/)
return m ? [Number(m[1]), Number(m[2])] : Number(raw) || 0
})()
const COMPRESS = (process.env.COMPRESS ?? 'true') === 'true'
const CORS_ORIGIN = process.env.CORS_ORIGIN ?? '*'
// chaos: inject random failures. CHAOS_RATE=0.1 => 10% of requests fail.
const CHAOS_RATE = Number(process.env.CHAOS_RATE ?? 0)
const CHAOS_STATUS = Number(process.env.CHAOS_STATUS ?? 500)
const PROXY_TARGET = process.env.PROXY_TARGET ?? ''
const RECORD = (process.env.RECORD ?? 'false') === 'true'
const METHOD_COLORS: Record<string, string> = {
GET: '\x1b[32m', POST: '\x1b[33m', PUT: '\x1b[34m', PATCH: '\x1b[35m', DELETE: '\x1b[31m',
}
const DIM = '\x1b[2m', RESET = '\x1b[0m'
function printRoutes(list: RouteEntry[]) {
const sorted = [...list].sort((a, b) => a.template.localeCompare(b.template) || a.method.localeCompare(b.method))
console.log(`routes (${sorted.length}):`)
for (const r of sorted) {
const m = r.method.toUpperCase()
const col = METHOD_COLORS[m] ?? ''
const tag = r.status ? ` ${DIM}${r.status}${RESET}` : ''
const kind = r.kind !== 'json' ? ` ${DIM}[${r.kind}]${RESET}` : ''
console.log(` ${col}${m.padEnd(6)}${RESET} ${r.template}${tag}${kind}`)
}
}
// scenario overlays: routes/_scenarios/<name>/** matched first when X-Scenario header is set
const SCENARIOS_DIR = join(ROUTES_DIR, '_scenarios')
function scanScenarios(): Map<string, RouteEntry[]> {
const map = new Map<string, RouteEntry[]>()
try {
for (const n of readdirSync(SCENARIOS_DIR)) {
const dir = join(SCENARIOS_DIR, n)
if (statSync(dir).isDirectory()) map.set(n, scan(dir))
}
} catch {
/* no _scenarios dir */
}
return map
}
let routes: RouteEntry[] = scan(ROUTES_DIR)
let scenarios = scanScenarios()
const rescan = () => {
routes = scan(ROUTES_DIR)
scenarios = scanScenarios()
console.log(`\n↻ routes reloaded`)
printRoutes(routes)
if (scenarios.size) console.log(`scenarios: ${[...scenarios.keys()].join(', ')}`)
}
watchRoutes(ROUTES_DIR, rescan)
const app = new Hono()
app.use('*', logger())
app.use('*', cors({ origin: CORS_ORIGIN }))
if (COMPRESS) app.use('*', compress())
// introspection: list every mock route
app.get('/__routes', (c) =>
c.json({
count: routes.length,
routes: routes
.map((r) => ({ method: r.method.toUpperCase(), path: r.template, status: r.status, kind: r.kind }))
.sort((a, b) => a.path.localeCompare(b.path)),
}),
)
app.get('/__health', (c) => c.json({ ok: true, routes: routes.length }))
// OpenAPI spec generated live from the route files
app.get('/__openapi.json', async (c) => c.json((await buildOpenApi(routes, ROUTES_DIR)) as any))
// visual API docs (Scalar via CDN, points at the live spec)
app.get('/__docs', (c) =>
c.html(`<!doctype html><html><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>mockly-hono · API docs</title></head><body>
<script id="api-reference" data-url="/__openapi.json"></script>
<script src="https://cdn.jsdelivr.net/npm/@scalar/api-reference"></script>
</body></html>`),
)
app.all('*', async (c) => {
if (CHAOS_RATE > 0 && Math.random() < CHAOS_RATE) {
return c.json({ error: 'chaos', injected: true }, CHAOS_STATUS as any)
}
const pathname = new URL(c.req.url).pathname
const scen = c.req.header('x-scenario')
let m = scen && scenarios.has(scen) ? match(scenarios.get(scen)!, c.req.method, pathname) : null
if (!m) m = match(routes, c.req.method, pathname)
if (!m) {
if (PROXY_TARGET) {
try {
const r = await proxyPass(c, { target: PROXY_TARGET, record: RECORD, routesDir: ROUTES_DIR })
if (RECORD) rescan()
return r
} catch (e) {
return c.json({ error: 'proxy failed', target: PROXY_TARGET, detail: String(e) }, 502)
}
}
return c.json({ error: 'no mock', path: pathname, method: c.req.method }, 404)
}
try {
return await respond(c, m, { defaultDelay: DEFAULT_DELAY, faker, routesDir: ROUTES_DIR })
} catch (e) {
console.error('mock error', e)
return c.json({ error: 'mock handler failed', detail: String(e) }, 500)
}
})
console.log(`
# mockly-hono
routes dir : ${ROUTES_DIR}
routes : ${routes.length}
port : ${PORT}
delay : ${Array.isArray(DEFAULT_DELAY) ? `${DEFAULT_DELAY[0]}-${DEFAULT_DELAY[1]}ms (random)` : `${DEFAULT_DELAY}ms`}
compress : ${COMPRESS}
cors : ${CORS_ORIGIN}
chaos : ${CHAOS_RATE > 0 ? `${CHAOS_RATE * 100}% -> ${CHAOS_STATUS}` : 'off'}
proxy : ${PROXY_TARGET ? `${PROXY_TARGET}${RECORD ? ' (recording)' : ''}` : 'off'}
introspect : GET /__routes
docs : GET /__docs (spec: /__openapi.json)
`)
// OSC 8 terminal hyperlink — cmd/ctrl-clickable in iTerm2, VS Code, modern terminals
const link = (url: string, label = url) => `\x1b]8;;${url}\x1b\\${label}\x1b]8;;\x1b\\`
const BOLD = '\x1b[1m', CYAN = '\x1b[36m'
printRoutes(routes)
const base = `http://localhost:${PORT}`
console.log(`
# LISTENING ${link(base)}
${BOLD}${CYAN}➜ docs ${link(base + '/__docs')}${RESET}
spec ${link(base + '/__openapi.json')}
`)
serve({ fetch: app.fetch, port: PORT })
+127
View File
@@ -0,0 +1,127 @@
import { pathToFileURL } from 'node:url'
import { join } from 'node:path'
import type { RouteEntry } from './registry.ts'
import { parseDataFile } from './parse.ts'
import { store } from './store.ts'
/** Best-effort example body + status for a route, used to fill the OpenAPI doc. */
function exampleFor(entry: RouteEntry): { status: number; body: unknown; dynamic: boolean } {
const status = entry.status ?? (entry.crud?.op === 'create' ? 201 : entry.crud?.op === 'delete' ? 204 : 200)
if (entry.kind === 'crud') {
const c = entry.crud!.collection
const items = store.list(c)
if (entry.crud!.op === 'list') return { status, body: items, dynamic: false }
return { status, body: items[0] ?? { id: 1 }, dynamic: false }
}
if (entry.kind === 'module') return { status, body: undefined, dynamic: true }
try {
const raw = parseDataFile(entry.file) as Record<string, unknown>
if (raw && Array.isArray((raw as any).__variants)) {
const v = (raw as any).__variants[0] ?? {}
return { status: v.status ?? status, body: v.body, dynamic: true }
}
if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
if ('__body' in raw) return { status, body: raw.__body, dynamic: false }
const out: Record<string, unknown> = {}
for (const k of Object.keys(raw)) if (!k.startsWith('__')) out[k] = raw[k]
return { status, body: out, dynamic: false }
}
return { status, body: raw, dynamic: false }
} catch {
return { status, body: null, dynamic: false }
}
}
/** :id -> {id} for OpenAPI path templating. */
function toOpenApiPath(template: string) {
return template.replace(/:([^/]+)/g, '{$1}')
}
type Doc = { summary?: string; description?: string }
const normalizeDoc = (doc: unknown): Doc =>
typeof doc === 'string' ? { description: doc } : doc && typeof doc === 'object' ? (doc as Doc) : {}
/** Read an optional author description: __doc in JSON, or `export const doc` in modules. */
async function readDoc(entry: RouteEntry): Promise<Doc> {
try {
if (entry.kind === 'json') return normalizeDoc((parseDataFile(entry.file) as any)?.__doc)
if (entry.kind === 'module') {
const mod = await import(pathToFileURL(entry.file).href + `?doc=${Date.now()}`)
return normalizeDoc(mod.doc)
}
} catch {
/* ignore */
}
return {}
}
/** Optional tag (group) descriptions from routes/_tags.json5: { "todos": "…" }. */
function readTags(routesDir: string): Record<string, string> {
for (const name of ['_tags.json5', '_tags.json']) {
try {
return parseDataFile(join(routesDir, name)) as Record<string, string>
} catch {
/* try next */
}
}
return {}
}
export async function buildOpenApi(routes: RouteEntry[], routesDir = '', title = 'mockly-hono') {
const paths: Record<string, Record<string, unknown>> = {}
const usedTags = new Set<string>()
for (const entry of routes) {
if (entry.method === 'head' || entry.method === 'options') continue
const path = toOpenApiPath(entry.template)
const params = entry.segments
.filter((s) => s.param)
.map((s) => ({ name: s.param, in: 'path', required: true, schema: { type: 'string' } }))
const { status, body, dynamic } = exampleFor(entry)
const content = body === undefined ? undefined : { 'application/json': { example: body } }
const segs = entry.template.split('/').filter(Boolean)
const tag = segs[0] ?? 'root'
usedTags.add(tag)
// label shown in the sidebar = path relative to its tag group
const rest = segs.slice(1).join('/')
const label = rest ? '/' + rest : entry.template
const kindNote = `${entry.kind}${dynamic ? ' (dynamic)' : ''}${entry.status ? ` · default ${entry.status}` : ''}`
const doc = await readDoc(entry)
const autoDesc = `Mock route \`${entry.method.toUpperCase()} ${entry.template}\` — kind: ${kindNote}`
const op: Record<string, unknown> = {
tags: [tag],
summary: doc.summary ?? label,
operationId: `${entry.method}_${segs.join('_') || 'root'}`,
description: doc.description ? `${doc.description}\n\n${autoDesc}` : autoDesc,
responses: {
[String(status)]: { description: dynamic ? 'example (varies at runtime)' : 'mock response', ...(content ? { content } : {}) },
},
}
if (params.length) op.parameters = params
if (['post', 'put', 'patch'].includes(entry.method)) {
op.requestBody = { required: false, content: { 'application/json': { schema: { type: 'object' } } } }
}
paths[path] ??= {}
paths[path][entry.method] = op
}
const tagDescriptions = readTags(routesDir)
const tags = [...usedTags].sort().map((name) =>
tagDescriptions[name] ? { name, description: tagDescriptions[name] } : { name },
)
return {
openapi: '3.0.3',
info: { title, version: '2.0.0', description: 'Auto-generated from the mockly route files.' },
tags,
paths,
}
}
+7
View File
@@ -0,0 +1,7 @@
import { readFileSync } from 'node:fs'
import JSON5 from 'json5'
/** Parse .json or .json5 — json5 is a superset so we use it for both. */
export function parseDataFile(file: string): unknown {
return JSON5.parse(readFileSync(file, 'utf8'))
}
+54
View File
@@ -0,0 +1,54 @@
import { mkdirSync, writeFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import type { Context } from 'hono'
/**
* Forward unmatched requests to a real upstream (PROXY_TARGET).
* With record=true, persist the upstream JSON response as a mock file so the
* next run serves it locally — record-once, replay-forever.
*/
export async function proxyPass(
c: Context,
opts: { target: string; record: boolean; routesDir: string },
): Promise<Response> {
const reqUrl = new URL(c.req.url)
const upstream = new URL(reqUrl.pathname + reqUrl.search, opts.target)
const init: RequestInit = {
method: c.req.method,
headers: stripHopHeaders(c.req.raw.headers),
}
if (!['GET', 'HEAD'].includes(c.req.method)) init.body = await c.req.raw.arrayBuffer()
const res = await fetch(upstream, init)
const buf = await res.arrayBuffer()
if (opts.record && res.ok && (res.headers.get('content-type') ?? '').includes('json')) {
try {
recordMock(opts.routesDir, reqUrl.pathname, c.req.method, JSON.parse(Buffer.from(buf).toString('utf8')))
} catch {
/* non-json body — skip recording */
}
}
const headers = new Headers(res.headers)
headers.set('X-Mock-Proxy', upstream.href)
if (opts.record) headers.set('X-Mock-Recorded', 'true')
return new Response(buf, { status: res.status, headers })
}
function recordMock(routesDir: string, pathname: string, method: string, body: unknown) {
const segs = pathname.split('/').filter(Boolean)
const base = segs.length ? segs.join('/') : 'index'
const suffix = method.toLowerCase() === 'get' ? '' : `.${method.toLowerCase()}`
const file = join(routesDir, `${base}${suffix}.json`)
mkdirSync(dirname(file), { recursive: true })
writeFileSync(file, JSON.stringify(body, null, 2) + '\n')
console.log(`⤓ recorded ${method} ${pathname} -> ${file}`)
}
function stripHopHeaders(h: Headers): Headers {
const out = new Headers(h)
for (const k of ['host', 'connection', 'content-length', 'accept-encoding']) out.delete(k)
return out
}
+164
View File
@@ -0,0 +1,164 @@
import { readdirSync, statSync, watch } from 'node:fs'
import { join, relative, sep } from 'node:path'
import { store } from './store.ts'
export type HttpMethod = 'get' | 'post' | 'put' | 'patch' | 'delete' | 'options' | 'head'
const METHODS = new Set<HttpMethod>(['get', 'post', 'put', 'patch', 'delete', 'options', 'head'])
export type CrudOp = 'list' | 'create' | 'get' | 'put' | 'patch' | 'delete'
export interface RouteEntry {
file: string
method: HttpMethod
status?: number
template: string
segments: { value: string; param?: string; splat?: boolean }[]
kind: 'json' | 'module' | 'crud' | 'sse'
/** present when kind === 'crud' */
crud?: { collection: string; op: CrudOp }
}
export interface MatchResult {
entry: RouteEntry
params: Record<string, string>
}
const DATA_EXT = /\.(json5?)$/i
const MOD_EXT = /\.(m?js|ts)$/i
const CRUD_RE = /\.crud\.json5?$/i
function parseFilename(name: string) {
const parts = name.split('.')
const ext = parts.pop()!.toLowerCase()
let kind: RouteEntry['kind'] = ext === 'json' || ext === 'json5' ? 'json' : 'module'
let method: HttpMethod = 'get'
let status: number | undefined
// .sse.json -> streaming route
if (kind === 'json' && parts[parts.length - 1]?.toLowerCase() === 'sse') {
kind = 'sse'
parts.pop()
}
while (parts.length > 1) {
const tail = parts[parts.length - 1].toLowerCase()
if (METHODS.has(tail as HttpMethod)) method = parts.pop() as HttpMethod
else if (/^\d{3}$/.test(tail)) status = Number(parts.pop())
else break
}
return { base: parts.join('.'), method, status, kind }
}
function toSegments(relPath: string, base: string): RouteEntry['segments'] {
const dirParts = relPath.split(sep).slice(0, -1)
const all = [...dirParts, base].filter((p) => p && p !== 'index')
return all.map((value) => {
const splat = value.match(/^\[\.\.\.(.+)\]$/)
if (splat) return { value, param: splat[1], splat: true }
const m = value.match(/^\[(.+)\]$/)
return m ? { value, param: m[1] } : { value }
})
}
function segTemplate(segments: RouteEntry['segments']) {
return '/' + segments.map((s) => (s.splat ? `*${s.param}` : s.param ? `:${s.param}` : s.value)).join('/')
}
/** Expand a *.crud.json seed into 6 REST routes backed by the in-memory store. */
function crudEntries(file: string, baseSegments: RouteEntry['segments'], collection: string): RouteEntry[] {
store.seed(collection, file)
if (process.env.PERSIST === 'true') store.enablePersist(collection, file)
const idSeg = { value: '[id]', param: 'id' }
const mk = (method: HttpMethod, withId: boolean, op: CrudOp, status?: number): RouteEntry => {
const segments = withId ? [...baseSegments, idSeg] : baseSegments
return { file, method, status, segments, template: segTemplate(segments), kind: 'crud', crud: { collection, op } }
}
return [
mk('get', false, 'list'),
mk('post', false, 'create', 201),
mk('get', true, 'get'),
mk('put', true, 'put'),
mk('patch', true, 'patch'),
mk('delete', true, 'delete', 204),
]
}
export function scan(routesDir: string): RouteEntry[] {
const entries: RouteEntry[] = []
const walk = (dir: string) => {
let names: string[]
try {
names = readdirSync(dir)
} catch {
return
}
for (const n of names) {
// single leading underscore = private/partial (ignored); double = served (grouping)
if (n.startsWith('.') || (n.startsWith('_') && !n.startsWith('__'))) continue
const abs = join(dir, n)
if (statSync(abs).isDirectory()) {
walk(abs)
continue
}
const rel = relative(routesDir, abs)
if (CRUD_RE.test(n)) {
const base = n.replace(CRUD_RE, '')
const segs = toSegments(rel, base)
entries.push(...crudEntries(abs, segs, segs.map((s) => s.value).join('.') || base))
continue
}
if (!DATA_EXT.test(n) && !MOD_EXT.test(n)) continue
const { base, method, status, kind } = parseFilename(n)
const segments = toSegments(rel, base)
entries.push({ file: abs, method, status, kind, segments, template: segTemplate(segments) })
}
}
walk(routesDir)
const hasSplat = (e: RouteEntry) => (e.segments[e.segments.length - 1]?.splat ? 1 : 0)
entries.sort((a, b) => {
if (hasSplat(a) !== hasSplat(b)) return hasSplat(a) - hasSplat(b) // splat routes last
if (b.segments.length !== a.segments.length) return b.segments.length - a.segments.length
return a.segments.filter((s) => s.param).length - b.segments.filter((s) => s.param).length
})
return entries
}
export function match(entries: RouteEntry[], method: string, pathname: string): MatchResult | null {
const reqMethod = method.toLowerCase()
const reqSegs = pathname.split('/').filter(Boolean)
for (const entry of entries) {
if (entry.method !== reqMethod && !(reqMethod === 'head' && entry.method === 'get')) continue
const last = entry.segments[entry.segments.length - 1]
const hasSplat = last?.splat
if (hasSplat) {
if (reqSegs.length < entry.segments.length - 1) continue
} else if (entry.segments.length !== reqSegs.length) continue
const params: Record<string, string> = {}
let ok = true
for (let i = 0; i < entry.segments.length; i++) {
const seg = entry.segments[i]
if (seg.splat) {
params[seg.param!] = reqSegs.slice(i).map(decodeURIComponent).join('/')
break
}
if (seg.param) params[seg.param] = decodeURIComponent(reqSegs[i])
else if (seg.value !== reqSegs[i]) {
ok = false
break
}
}
if (ok) return { entry, params }
}
return null
}
export function watchRoutes(routesDir: string, onChange: () => void) {
let t: NodeJS.Timeout | null = null
try {
watch(routesDir, { recursive: true }, () => {
if (t) clearTimeout(t)
t = setTimeout(onChange, 120)
})
} catch {
/* recursive watch unsupported — skip */
}
}
+295
View File
@@ -0,0 +1,295 @@
import { pathToFileURL } from 'node:url'
import type { Context } from 'hono'
import { streamSSE } from 'hono/streaming'
import type { MatchResult, CrudOp } from './registry.ts'
import { parseDataFile } from './parse.ts'
import { store } from './store.ts'
import { applyTemplate } from './template.ts'
import { nextSeqIndex, rateCheck } from './runtime.ts'
import { validateBody, type ValidateSchema } from './validate.ts'
import { proxyPass } from './proxy.ts'
export interface MockContext {
params: Record<string, string>
query: Record<string, string>
headers: Record<string, string>
method: string
body: unknown
faker: typeof import('@faker-js/faker').faker
}
export type Delay = number | [number, number]
export interface MockResponse {
status?: number
headers?: Record<string, string>
delay?: Delay
body?: unknown
/** when set, the request is forwarded to this upstream instead */
proxy?: string
}
export function resolveDelay(d: Delay | undefined): number {
if (d === undefined) return 0
if (Array.isArray(d)) {
const lo = Math.min(d[0], d[1])
const hi = Math.max(d[0], d[1])
return Math.round(lo + Math.random() * (hi - lo))
}
return d
}
const CONTROL = new Set([
'__status', '__headers', '__delay', '__delayed', '__body', '__variants',
'__sequence', '__chaos', '__rateLimit', '__auth', '__validate', '__proxy', '__doc',
])
function stripControl(obj: Record<string, unknown>): unknown {
if ('__body' in obj) return obj.__body
const out: Record<string, unknown> = {}
for (const k of Object.keys(obj)) if (!CONTROL.has(k)) out[k] = obj[k]
return out
}
// ── matchers for __variants ────────────────────────────────────────────────
interface Variant {
when?: { query?: Record<string, string>; headers?: Record<string, string>; params?: Record<string, string>; body?: Record<string, unknown> }
status?: number
headers?: Record<string, string>
delay?: Delay
body?: unknown
}
function subset(want: Record<string, unknown> | undefined, got: Record<string, unknown>, ci = false): boolean {
if (!want) return true
const g = ci ? Object.fromEntries(Object.entries(got).map(([k, v]) => [k.toLowerCase(), v])) : got
for (const [k, v] of Object.entries(want)) {
const key = ci ? k.toLowerCase() : k
if (typeof v === 'object' && v !== null && typeof g[key] === 'object' && g[key] !== null) {
if (!subset(v as Record<string, unknown>, g[key] as Record<string, unknown>)) return false
} else if (String(g[key]) !== String(v)) return false
}
return true
}
function matchWhen(w: Variant['when'], ctx: MockContext): boolean {
if (!w) return true
return (
subset(w.query, ctx.query) &&
subset(w.headers, ctx.headers, true) &&
subset(w.params, ctx.params) &&
subset(w.body, (ctx.body as Record<string, unknown>) ?? {})
)
}
// ── auth / chaos ───────────────────────────────────────────────────────────
function checkAuth(spec: unknown, ctx: MockContext): MockResponse | null {
const auth = ctx.headers['authorization'] ?? ''
const token = auth.replace(/^Bearer\s+/i, '')
const want = typeof spec === 'object' && spec ? (spec as any).token : undefined
const ok = want ? token === want : token.length > 0
return ok ? null : { status: 401, headers: { 'WWW-Authenticate': 'Bearer' }, body: { error: 'unauthorized' } }
}
// ── the JSON route engine ──────────────────────────────────────────────────
function fromJson(file: string, ctx: MockContext): MockResponse {
const raw = parseDataFile(file)
if (Array.isArray(raw)) return { body: applyTemplate(raw, ctx) }
if (typeof raw !== 'object' || raw === null) return { body: raw }
const o = raw as Record<string, unknown>
// per-route proxy short-circuit
if (typeof o.__proxy === 'string') return { proxy: o.__proxy }
// auth gate
if (o.__auth) {
const denied = checkAuth(o.__auth, ctx)
if (denied) return denied
}
// rate limit
if (o.__rateLimit) {
const { max = 60, windowMs = 60_000 } = o.__rateLimit as { max?: number; windowMs?: number }
const r = rateCheck(file, max, windowMs, Date.now())
if (!r.allowed) {
return {
status: 429,
headers: { 'Retry-After': String(Math.ceil(r.retryMs / 1000)), 'X-RateLimit-Limit': String(max) },
body: { error: 'rate limited', retryMs: r.retryMs },
}
}
}
// per-route chaos
if (o.__chaos) {
const { rate = 0, status = 500, body } = o.__chaos as { rate?: number; status?: number; body?: unknown }
if (Math.random() < rate) return { status, body: body ?? { error: 'chaos', injected: true } }
}
// body validation (write methods)
if (o.__validate && ['post', 'put', 'patch'].includes(ctx.method.toLowerCase())) {
const errors = validateBody(o.__validate as ValidateSchema, ctx.body)
if (errors.length) return { status: 422, body: { error: 'validation failed', errors } }
}
// sequence: different response each call
if (Array.isArray(o.__sequence)) {
const seq = o.__sequence as any[]
const item = seq[nextSeqIndex(file, seq.length)]
const r: MockResponse =
item && typeof item === 'object' && ('body' in item || 'status' in item)
? { status: item.status, headers: item.headers, delay: item.delay, body: item.body }
: { body: item }
r.body = applyTemplate(r.body, ctx)
return r
}
// variants
if (Array.isArray(o.__variants)) {
const chosen = (o.__variants as Variant[]).find((v) => matchWhen(v.when, ctx))
if (!chosen) return { status: 404, body: { error: 'no matching variant' } }
return { status: chosen.status, headers: chosen.headers, delay: chosen.delay, body: applyTemplate(chosen.body, ctx) }
}
// plain body + control envelope
const rawDelay = o.__delay ?? o.__delayed
return {
status: typeof o.__status === 'number' ? o.__status : undefined,
headers: (o.__headers as Record<string, string>) ?? undefined,
delay: typeof rawDelay === 'number' || Array.isArray(rawDelay) ? (rawDelay as Delay) : undefined,
body: applyTemplate(stripControl(o), ctx),
}
}
async function fromModule(file: string, ctx: MockContext): Promise<MockResponse> {
const url = pathToFileURL(file).href + `?t=${Date.now()}`
const mod = await import(url)
const handler = mod.default ?? mod.handler
if (typeof handler !== 'function') return { status: 500, body: { error: `module ${file} has no default export function` } }
const result = await handler(ctx)
if (result && typeof result === 'object' && ('body' in result || 'status' in result)) return result as MockResponse
return { body: result }
}
// ── CRUD with pagination + sort ────────────────────────────────────────────
function fromCrud(collection: string, op: CrudOp, ctx: MockContext): MockResponse {
const id = ctx.params.id
const q = ctx.query
switch (op) {
case 'list': {
let items = store.list(collection)
for (const [k, v] of Object.entries(q)) {
if (k.startsWith('_')) continue
items = items.filter((it) => String((it as Record<string, unknown>)[k]) === v)
}
if (q._sort) {
const key = q._sort
const dir = q._order === 'desc' ? -1 : 1
items = [...items].sort((a, b) => {
const av = (a as any)[key], bv = (b as any)[key]
return av < bv ? -dir : av > bv ? dir : 0
})
}
const total = items.length
if (q._start || q._end) {
items = items.slice(Number(q._start ?? 0), q._end ? Number(q._end) : undefined)
} else if (q._page || q._limit) {
const limit = Number(q._limit ?? 10)
const page = Number(q._page ?? 1)
items = items.slice((page - 1) * limit, (page - 1) * limit + limit)
} else if (q._limit) {
items = items.slice(0, Number(q._limit))
}
return { body: items, headers: { 'X-Total-Count': String(total), 'Access-Control-Expose-Headers': 'X-Total-Count' } }
}
case 'get': {
const rec = store.get(collection, id)
return rec ? { body: rec } : { status: 404, body: { error: 'not found' } }
}
case 'create':
return { status: 201, body: store.create(collection, (ctx.body as Record<string, unknown>) ?? {}) }
case 'put':
case 'patch': {
const rec = store.update(collection, id, (ctx.body as Record<string, unknown>) ?? {}, op === 'patch')
return rec ? { body: rec } : { status: 404, body: { error: 'not found' } }
}
case 'delete':
return store.remove(collection, id) ? { status: 204, body: null } : { status: 404, body: { error: 'not found' } }
}
}
// ── SSE streaming ──────────────────────────────────────────────────────────
function fromSse(c: Context, file: string): Response {
let cfg: any
try {
cfg = parseDataFile(file)
} catch {
return c.json({ error: 'invalid sse file' }, 500)
}
const events: any[] = Array.isArray(cfg) ? cfg : cfg.events ?? []
const loop = !Array.isArray(cfg) && !!cfg.loop
return streamSSE(c, async (stream) => {
let round = 0
do {
for (const ev of events) {
if (stream.aborted) return
if (ev.delay) await stream.sleep(ev.delay)
await stream.writeSSE({
data: typeof ev.data === 'string' ? ev.data : JSON.stringify(ev.data ?? ev),
event: ev.event,
id: ev.id != null ? String(ev.id) : undefined,
})
}
round++
} while (loop && !stream.aborted && round < 10_000)
})
}
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms))
export async function respond(
c: Context,
m: MatchResult,
opts: { defaultDelay: Delay; faker: MockContext['faker']; routesDir: string },
): Promise<Response> {
const { entry, params } = m
let parsedBody: unknown
if (['post', 'put', 'patch', 'delete'].includes(entry.method)) {
try {
parsedBody = await c.req.json()
} catch {
parsedBody = undefined
}
}
const ctx: MockContext = {
params,
query: c.req.query(),
headers: Object.fromEntries(c.req.raw.headers),
method: c.req.method,
body: parsedBody,
faker: opts.faker,
}
if (entry.kind === 'sse') return fromSse(c, entry.file)
const res =
entry.kind === 'crud'
? fromCrud(entry.crud!.collection, entry.crud!.op, ctx)
: entry.kind === 'json'
? fromJson(entry.file, ctx)
: await fromModule(entry.file, ctx)
if (res.proxy) return proxyPass(c, { target: res.proxy, record: false, routesDir: opts.routesDir })
const status = res.status ?? entry.status ?? 200
const delay = resolveDelay(res.delay ?? opts.defaultDelay)
if (delay > 0) await sleep(delay)
if (res.headers) for (const [k, v] of Object.entries(res.headers)) c.header(k, v)
c.header('X-Mock-File', entry.file)
if (status === 204 || res.body === null || res.body === undefined) return c.body(null, status as any)
return c.json(res.body as any, status as any)
}
+26
View File
@@ -0,0 +1,26 @@
// Per-route runtime state: sequence cursors + rate-limit windows.
// Keyed by route file; reset on server restart.
const seqCursor = new Map<string, number>()
/** Return the next index in a sequence of length n, advancing the cursor. */
export function nextSeqIndex(key: string, n: number): number {
const i = seqCursor.get(key) ?? 0
seqCursor.set(key, i + 1)
return i % n
}
const rlHits = new Map<string, number[]>()
/** Sliding-window rate limit. Returns remaining + whether this call is allowed. */
export function rateCheck(key: string, max: number, windowMs: number, now: number) {
const hits = (rlHits.get(key) ?? []).filter((t) => now - t < windowMs)
if (hits.length >= max) {
rlHits.set(key, hits)
const retryMs = windowMs - (now - hits[0])
return { allowed: false, remaining: 0, retryMs }
}
hits.push(now)
rlHits.set(key, hits)
return { allowed: true, remaining: max - hits.length, retryMs: 0 }
}
+93
View File
@@ -0,0 +1,93 @@
import { writeFileSync } from 'node:fs'
import { parseDataFile } from './parse.ts'
type Record_ = Record<string, unknown>
/** In-memory collections for stateful CRUD mocks. Seeded from .crud.json files. */
class Store {
private cols = new Map<string, Map<string, Record_>>()
private counters = new Map<string, number>()
private seeded = new Set<string>()
private persistFile = new Map<string, string>()
private saveTimers = new Map<string, NodeJS.Timeout>()
/** Enable disk write-back for a collection (PERSIST=true). */
enablePersist(collection: string, file: string) {
this.persistFile.set(collection, file)
}
private save(c: string) {
const file = this.persistFile.get(c)
if (!file) return
const prev = this.saveTimers.get(c)
if (prev) clearTimeout(prev)
this.saveTimers.set(
c,
setTimeout(() => {
try {
writeFileSync(file, JSON.stringify(this.list(c), null, 2) + '\n')
} catch (e) {
console.error(`persist failed for ${c}:`, e)
}
}, 150),
)
}
/** Seed a collection once from its source file (idempotent across hot reloads). */
seed(collection: string, file: string) {
if (this.seeded.has(collection)) return
this.seeded.add(collection)
const col = new Map<string, Record_>()
let max = 0
try {
const data = parseDataFile(file)
const arr = Array.isArray(data) ? data : []
for (const item of arr as Record_[]) {
const id = String(item.id ?? ++max)
if (Number(id) > max) max = Number(id)
col.set(id, { ...item, id: coerceId(item.id ?? id) })
}
} catch {
/* empty / invalid seed → empty collection */
}
this.cols.set(collection, col)
this.counters.set(collection, max)
}
list(c: string) {
return [...(this.cols.get(c)?.values() ?? [])]
}
get(c: string, id: string) {
return this.cols.get(c)?.get(id) ?? null
}
create(c: string, body: Record_) {
const col = this.cols.get(c)!
const next = (this.counters.get(c) ?? 0) + 1
this.counters.set(c, next)
const id = body.id != null ? coerceId(body.id) : next
const rec = { ...body, id }
col.set(String(id), rec)
this.save(c)
return rec
}
update(c: string, id: string, body: Record_, merge: boolean) {
const col = this.cols.get(c)
if (!col?.has(id)) return null
const rec = merge ? { ...col.get(id), ...body, id: coerceId(id) } : { ...body, id: coerceId(id) }
col.set(id, rec)
this.save(c)
return rec
}
remove(c: string, id: string) {
const ok = this.cols.get(c)?.delete(id) ?? false
if (ok) this.save(c)
return ok
}
}
function coerceId(v: unknown) {
const n = Number(v)
return Number.isInteger(n) && String(n) === String(v) ? n : v
}
export const store = new Store()
+58
View File
@@ -0,0 +1,58 @@
import type { MockContext } from './respond.ts'
/**
* Replace `{{ path }}` placeholders inside a JSON value.
* {{params.id}} {{query.q}} {{headers.x-foo}}
* {{faker.person.fullName}} {{faker.number.int}} (zero-arg faker methods)
* If a string is exactly one placeholder, the resolved (typed) value is used —
* so {{faker.number.int}} yields a number, not "123".
*/
const RE = /\{\{\s*([\w.$-]+)\s*\}\}/g
function resolve(path: string, ctx: MockContext): unknown {
const parts = path.split('.')
let cur: any =
parts[0] === 'faker'
? ctx.faker
: parts[0] === 'params'
? ctx.params
: parts[0] === 'query'
? ctx.query
: parts[0] === 'headers'
? ctx.headers
: undefined
if (cur === undefined) return undefined
for (let i = 1; i < parts.length; i++) {
cur = cur?.[parts[i]]
if (cur === undefined) return undefined
}
return typeof cur === 'function' ? cur.call(parts[0] === 'faker' ? undefined : cur) : cur
}
export function hasTemplate(v: unknown): boolean {
if (typeof v === 'string') return RE.test(v)
if (Array.isArray(v)) return v.some(hasTemplate)
if (v && typeof v === 'object') return Object.values(v).some(hasTemplate)
return false
}
export function applyTemplate(value: unknown, ctx: MockContext): unknown {
if (typeof value === 'string') {
const whole = value.match(/^\{\{\s*([\w.$-]+)\s*\}\}$/)
if (whole) {
const r = resolve(whole[1], ctx)
return r === undefined ? value : r
}
return value.replace(RE, (m, p) => {
const r = resolve(p, ctx)
return r === undefined ? m : String(r)
})
}
if (Array.isArray(value)) return value.map((v) => applyTemplate(v, ctx))
if (value && typeof value === 'object') {
const out: Record<string, unknown> = {}
for (const [k, v] of Object.entries(value)) out[k] = applyTemplate(v, ctx)
return out
}
return value
}
+38
View File
@@ -0,0 +1,38 @@
/**
* Tiny body validator. Schema maps field -> type spec:
* { name: 'string', age: 'number', email: 'string?', tags: 'array', meta: 'object' }
* A trailing `?` makes the field optional. Returns a list of error messages
* (empty = valid).
*/
export type ValidateSchema = Record<string, string>
const checkType = (v: unknown, t: string): boolean => {
switch (t) {
case 'string': return typeof v === 'string'
case 'number': return typeof v === 'number'
case 'boolean': return typeof v === 'boolean'
case 'array': return Array.isArray(v)
case 'object': return v !== null && typeof v === 'object' && !Array.isArray(v)
case 'any': return true
default: return true
}
}
export function validateBody(schema: ValidateSchema, body: unknown): string[] {
const errors: string[] = []
const obj = (body ?? {}) as Record<string, unknown>
if (body === null || typeof body !== 'object' || Array.isArray(body)) {
return ['body must be a JSON object']
}
for (const [field, specRaw] of Object.entries(schema)) {
const optional = specRaw.endsWith('?')
const type = optional ? specRaw.slice(0, -1) : specRaw
const present = field in obj && obj[field] !== undefined && obj[field] !== null
if (!present) {
if (!optional) errors.push(`${field} is required`)
continue
}
if (!checkType(obj[field], type)) errors.push(`${field} must be ${type}`)
}
return errors
}