@avelor/mesh 0.1.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -25,11 +25,14 @@ Requires Node.js 18+. For HTTPS, install [mkcert](https://github.com/FiloSottile
25
25
  ## Usage
26
26
 
27
27
  ```bash
28
- mesh init # create mesh.yml in current directory
29
- sudo mesh route # start proxy (writes /etc/hosts)
28
+ mesh init # create mesh.yml in current directory
29
+ sudo mesh start # start proxy in background
30
+ mesh status # show running services
31
+ sudo mesh stop # stop the proxy
32
+ sudo mesh route # start in foreground (useful for debugging)
30
33
  ```
31
34
 
32
- `mesh route` starts on `:80` and `:443` (if mkcert is available), writes the hostname entries to `/etc/hosts`, and cleans them up on exit.
35
+ `mesh start` runs on `:80` and `:443` (if mkcert is available), writes the hostname entries to `/etc/hosts`, and cleans them up automatically on stop.
33
36
 
34
37
  ---
35
38
 
@@ -56,7 +59,11 @@ services:
56
59
  rules:
57
60
  api:
58
61
  - path: /payments
62
+ method: POST
59
63
  status: 503
64
+ body:
65
+ error: Payment service unavailable
66
+ retryAfter: 30
60
67
  rate: 30
61
68
  - path: /auth/login
62
69
  status: 401
@@ -70,14 +77,16 @@ rules:
70
77
  rate: 25
71
78
  ```
72
79
 
73
- | Field | Description |
74
- |----------|--------------------------------------------------|
75
- | `path` | Request path prefix to match |
76
- | `status` | HTTP status code to return (400, 401, 500, etc.) |
77
- | `delay` | Milliseconds to wait before responding |
78
- | `rate` | Percentage of matching requests to affect (1–100)|
80
+ | Field | Description |
81
+ |----------|---------------------------------------------------------------|
82
+ | `path` | Request path prefix to match |
83
+ | `method` | HTTP method to match (GET, POST, PUT, PATCH, DELETE…) |
84
+ | `status` | HTTP status code to return (400, 401, 500, etc.) |
85
+ | `body` | Response body object (sent as JSON) or string (plain text) |
86
+ | `delay` | Milliseconds to wait before responding |
87
+ | `rate` | Percentage of matching requests to affect (1–100) |
79
88
 
80
- `status` and `delay` can be combined: wait N ms, then fail with that status.
89
+ `status` and `delay` can be combined: wait N ms, then fail with that status. Without `body`, the default is `{ "error": "<status text>", "injected": true }`.
81
90
 
82
91
  ### Subdomains
83
92
 
package/bin/mesh.js CHANGED
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env node
2
- import { watch, existsSync, readFileSync } from 'fs'
2
+ import { watch, existsSync, readFileSync, writeFileSync, unlinkSync } from 'fs'
3
+ import { spawn } from 'child_process'
3
4
  import { dirname, resolve } from 'path'
4
5
  import { createRequire } from 'module'
5
6
  import { loadConfig } from '../src/config.js'
@@ -8,6 +9,14 @@ import { startProxy } from '../src/proxy.js'
8
9
  import { ensureCerts } from '../src/certs.js'
9
10
  import { init } from '../src/init.js'
10
11
 
12
+ const STATE_FILE = '/tmp/.mesh.json'
13
+
14
+ const RESET = '\x1b[0m'
15
+ const DIM = '\x1b[2m'
16
+ const GREEN = '\x1b[32m'
17
+ const YELLOW = '\x1b[33m'
18
+ const CYAN = '\x1b[36m'
19
+
11
20
  // ── Argument parsing ──────────────────────────────────────────────────────────
12
21
 
13
22
  const args = process.argv.slice(2)
@@ -27,11 +36,141 @@ if (cmd === 'init') {
27
36
  process.exit(0)
28
37
  }
29
38
 
39
+ if (cmd === 'start') {
40
+ try {
41
+ const state = JSON.parse(readFileSync(STATE_FILE, 'utf8'))
42
+ let alive = false
43
+ try { process.kill(state.pid, 0); alive = true } catch (e) { if (e.code === 'EPERM') alive = true }
44
+ if (alive) {
45
+ console.error(`mesh: already running (pid ${state.pid}) — run sudo mesh stop first`)
46
+ process.exit(1)
47
+ }
48
+ } catch { /* not running */ }
49
+
50
+ if (process.getuid?.() !== 0) {
51
+ console.error('mesh: requires sudo to bind ports 80/443')
52
+ console.error(' sudo mesh start')
53
+ process.exit(1)
54
+ }
55
+
56
+ const forwardArgs = args.filter(a => a !== 'start')
57
+ const child = spawn(process.execPath, [process.argv[1], 'route', ...forwardArgs], {
58
+ detached: true,
59
+ stdio: 'ignore',
60
+ })
61
+
62
+ const pid = child.pid
63
+ const deadline = Date.now() + 5000
64
+
65
+ ;(function poll() {
66
+ let alive = true
67
+ try { process.kill(pid, 0) } catch (e) { if (e.code !== 'EPERM') alive = false }
68
+
69
+ if (!alive) {
70
+ console.error('mesh: failed to start — check your mesh.yml and that ports 80/443 are free')
71
+ process.exit(1)
72
+ }
73
+
74
+ try {
75
+ const state = JSON.parse(readFileSync(STATE_FILE, 'utf8'))
76
+ if (state.pid === pid) {
77
+ child.unref()
78
+ console.log(`mesh: started (pid ${pid})`)
79
+ process.exit(0)
80
+ }
81
+ } catch { /* not ready yet */ }
82
+
83
+ if (Date.now() >= deadline) {
84
+ child.kill()
85
+ console.error('mesh: timed out waiting for proxy to start')
86
+ process.exit(1)
87
+ }
88
+
89
+ setTimeout(poll, 50)
90
+ })()
91
+ }
92
+
93
+ if (cmd === 'stop') {
94
+ let state
95
+ try {
96
+ state = JSON.parse(readFileSync(STATE_FILE, 'utf8'))
97
+ } catch {
98
+ console.error('mesh: not running')
99
+ process.exit(1)
100
+ }
101
+ try {
102
+ process.kill(state.pid, 'SIGTERM')
103
+ console.log(`mesh: stopped (pid ${state.pid})`)
104
+ } catch (err) {
105
+ if (err.code === 'EPERM') {
106
+ console.error(`mesh: permission denied — try: sudo mesh stop`)
107
+ process.exit(1)
108
+ }
109
+ if (err.code === 'ESRCH') {
110
+ try { unlinkSync(STATE_FILE) } catch {}
111
+ console.error('mesh: not running (stale state removed)')
112
+ process.exit(1)
113
+ }
114
+ console.error('mesh:', err.message)
115
+ process.exit(1)
116
+ }
117
+ process.exit(0)
118
+ }
119
+
120
+ if (cmd === 'status') {
121
+ let state
122
+ try {
123
+ state = JSON.parse(readFileSync(STATE_FILE, 'utf8'))
124
+ } catch {
125
+ console.error('mesh: not running')
126
+ process.exit(1)
127
+ }
128
+
129
+ let alive = false
130
+ try {
131
+ process.kill(state.pid, 0)
132
+ alive = true
133
+ } catch (err) {
134
+ if (err.code === 'EPERM') alive = true
135
+ }
136
+
137
+ if (!alive) {
138
+ try { unlinkSync(STATE_FILE) } catch {}
139
+ console.error('mesh: not running (stale state removed)')
140
+ process.exit(1)
141
+ }
142
+
143
+ const protocol = state.https ? 'https' : 'http'
144
+ const pad = Math.max(...Object.keys(state.services).map(s => s.length))
145
+ console.log('')
146
+ console.log(` ${CYAN}mesh${RESET} running ${DIM}pid ${state.pid}${RESET}`)
147
+ console.log('')
148
+ for (const [name, port] of Object.entries(state.services)) {
149
+ console.log(` ${GREEN}${name.padEnd(pad)}.test${RESET} ${DIM}→ :${port} ${protocol}://${name}.test${RESET}`)
150
+ }
151
+ const rules = state.rules ?? {}
152
+ if (Object.keys(rules).length) {
153
+ console.log('')
154
+ for (const [svc, ruleList] of Object.entries(rules)) {
155
+ for (const r of ruleList) {
156
+ const type = r.status ? `${r.status}` : `${r.delay}ms delay`
157
+ const methodStr = r.method ? `${r.method} ` : ''
158
+ console.log(` ${YELLOW}${svc}${r.path}${RESET} ${DIM}${methodStr}${r.rate}% → ${type}${RESET}`)
159
+ }
160
+ }
161
+ }
162
+ console.log('')
163
+ process.exit(0)
164
+ }
165
+
30
166
  if (cmd !== 'route') {
31
167
  console.error('Usage:')
32
- console.error(' mesh init create mesh.yml in current directory')
33
- console.error(' sudo mesh route start proxy')
34
- console.error(' sudo mesh route --config <path> use a specific config file')
168
+ console.error(' mesh init create mesh.yml in current directory')
169
+ console.error(' sudo mesh start start proxy in background')
170
+ console.error(' sudo mesh start --config <path> use a specific config file')
171
+ console.error(' sudo mesh stop stop the background proxy')
172
+ console.error(' mesh status show running services')
173
+ console.error(' sudo mesh route start proxy in foreground (debug)')
35
174
  process.exit(1)
36
175
  }
37
176
 
@@ -60,10 +199,13 @@ const configDir = dirname(resolve(configPath))
60
199
  const certs = ensureCerts(services, configDir)
61
200
  const servers = startProxy(services, rules, certs)
62
201
 
202
+ writeFileSync(STATE_FILE, JSON.stringify({ pid: process.pid, configPath, services, rules, https: !!certs }))
203
+
63
204
  // ── Crash safety — clean /etc/hosts even on unexpected exit ───────────────────
64
205
 
65
206
  function shutdown(code = 0) {
66
207
  removeHosts()
208
+ try { unlinkSync(STATE_FILE) } catch {}
67
209
  servers.http.close()
68
210
  servers.https?.close()
69
211
  process.exit(code)
@@ -99,6 +241,7 @@ watch(configPath, () => {
99
241
  Object.assign(rules, next.rules)
100
242
 
101
243
  writeHosts(services)
244
+ writeFileSync(STATE_FILE, JSON.stringify({ pid: process.pid, configPath, services, rules, https: !!certs }))
102
245
 
103
246
  if (certs && hasNewServices) {
104
247
  const newCerts = ensureCerts(services, configDir)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@avelor/mesh",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
4
4
  "description": "Local dev proxy. Named services, failure rules, no ports.",
5
5
  "keywords": [
6
6
  "proxy",
@@ -35,6 +35,9 @@
35
35
  "http-proxy": "^1.18.1",
36
36
  "js-yaml": "^4.1.0"
37
37
  },
38
+ "scripts": {
39
+ "test": "node --test test/*.test.js"
40
+ },
38
41
  "engines": {
39
42
  "node": ">=18"
40
43
  }
package/src/config.js CHANGED
@@ -2,7 +2,8 @@ import { readFileSync, existsSync } from 'fs'
2
2
  import { resolve } from 'path'
3
3
  import yaml from 'js-yaml'
4
4
 
5
- const NAME_RE = /^[a-z0-9][a-z0-9.-]*$/
5
+ const NAME_RE = /^[a-z0-9][a-z0-9.-]*$/
6
+ const METHODS = new Set(['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS'])
6
7
 
7
8
  export function findConfigFile(cwd = process.cwd()) {
8
9
  for (const name of ['mesh.yml', 'mesh.yaml']) {
@@ -53,11 +54,16 @@ export function loadConfig(cwdOrFile = process.cwd()) {
53
54
  if (r.delay !== undefined && (typeof r.delay !== 'number' || r.delay < 0)) {
54
55
  throw new Error(`${prefix}.delay must be a positive number`)
55
56
  }
57
+ if (r.method !== undefined && !METHODS.has(r.method.toUpperCase())) {
58
+ throw new Error(`${prefix}.method must be a valid HTTP method (GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS)`)
59
+ }
56
60
  return {
57
61
  path: r.path ?? '/',
62
+ method: r.method ? r.method.toUpperCase() : null,
58
63
  status: r.status ?? null,
59
64
  delay: r.delay ?? null,
60
65
  rate: r.rate ?? 100,
66
+ body: r.body ?? null,
61
67
  }
62
68
  })
63
69
  }
@@ -0,0 +1,68 @@
1
+ export function wantsHtml(req) {
2
+ return (req.headers.accept ?? '').includes('text/html')
3
+ }
4
+
5
+ export function errorPage(status, name, { services = null, port = null, protocol = 'http' } = {}) {
6
+ const is404 = status === 404
7
+ const heading = is404
8
+ ? `<span class="hl">${name}.test</span> not found`
9
+ : `<span class="hl">${name}.test</span> is not responding`
10
+ const message = is404
11
+ ? 'No service is configured for this hostname.'
12
+ : `The service is configured but not reachable on <span class="mono">:${port}</span>. Is it running?`
13
+
14
+ const servicesBlock = (is404 && services && Object.keys(services).length)
15
+ ? `<div class="services">
16
+ <div class="label">configured services</div>
17
+ ${Object.entries(services).map(([n, p]) =>
18
+ `<div class="row">
19
+ <a class="name" href="${protocol}://${n}.test">${n}.test</a>
20
+ <span class="port">:${p}</span>
21
+ </div>`
22
+ ).join('')}
23
+ </div>`
24
+ : ''
25
+
26
+ return `<!DOCTYPE html>
27
+ <html lang="en">
28
+ <head>
29
+ <meta charset="utf-8">
30
+ <meta name="viewport" content="width=device-width, initial-scale=1">
31
+ <title>mesh — ${status}</title>
32
+ <style>
33
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
34
+ body {
35
+ background: #0d0d0d;
36
+ color: #555;
37
+ font-family: 'SF Mono', ui-monospace, 'Cascadia Code', monospace;
38
+ font-size: 13px;
39
+ min-height: 100vh;
40
+ display: flex;
41
+ align-items: center;
42
+ justify-content: center;
43
+ padding: 40px 24px;
44
+ }
45
+ .wrap { width: 100%; max-width: 440px; }
46
+ .status { color: #2a2a2a; font-size: 11px; letter-spacing: 0.08em; margin-bottom: 28px; }
47
+ h1 { color: #ccc; font-size: 16px; font-weight: 500; line-height: 1.5; margin-bottom: 10px; }
48
+ .hl { color: #e2e2e2; }
49
+ p { line-height: 1.7; }
50
+ .mono { font-family: inherit; color: #888; }
51
+ .services { margin-top: 40px; }
52
+ .label { font-size: 10px; letter-spacing: 0.1em; text-transform: uppercase; color: #2a2a2a; margin-bottom: 14px; }
53
+ .row { display: flex; align-items: baseline; gap: 10px; padding: 7px 0; border-top: 1px solid #181818; }
54
+ .name { color: #4ade80; text-decoration: none; }
55
+ .name:hover { color: #86efac; }
56
+ .port { color: #2e2e2e; }
57
+ </style>
58
+ </head>
59
+ <body>
60
+ <div class="wrap">
61
+ <div class="status">mesh / ${status}</div>
62
+ <h1>${heading}</h1>
63
+ <p>${message}</p>
64
+ ${servicesBlock}
65
+ </div>
66
+ </body>
67
+ </html>`
68
+ }
package/src/hosts.js CHANGED
@@ -4,31 +4,26 @@ const HOSTS_FILE = '/etc/hosts'
4
4
  const MESH_START = '# mesh:start'
5
5
  const MESH_END = '# mesh:end'
6
6
 
7
- export function writeHosts(services) {
8
- const current = readFileSync(HOSTS_FILE, 'utf8')
9
-
10
- // Remove any previous mesh block
11
- const clean = removeMeshBlock(current)
12
-
7
+ export function writeHosts(services, hostsFile = HOSTS_FILE) {
8
+ const current = readFileSync(hostsFile, 'utf8')
9
+ const clean = removeMeshBlock(current)
13
10
  const entries = Object.keys(services)
14
- .map(name => `127.0.0.1 ${name}.test`)
11
+ .flatMap(name => [`127.0.0.1 ${name}.test`, `::1 ${name}.test`])
15
12
  .join('\n')
16
-
17
13
  const next = `${clean.trimEnd()}\n\n${MESH_START}\n${entries}\n${MESH_END}\n`
18
-
19
- writeFileSync(HOSTS_FILE, next, 'utf8')
14
+ writeFileSync(hostsFile, next, 'utf8')
20
15
  }
21
16
 
22
- export function removeHosts() {
17
+ export function removeHosts(hostsFile = HOSTS_FILE) {
23
18
  try {
24
- const current = readFileSync(HOSTS_FILE, 'utf8')
25
- writeFileSync(HOSTS_FILE, removeMeshBlock(current).trimEnd() + '\n', 'utf8')
19
+ const current = readFileSync(hostsFile, 'utf8')
20
+ writeFileSync(hostsFile, removeMeshBlock(current).trimEnd() + '\n', 'utf8')
26
21
  } catch {
27
22
  // Best-effort cleanup
28
23
  }
29
24
  }
30
25
 
31
- function removeMeshBlock(content) {
26
+ export function removeMeshBlock(content) {
32
27
  return content.replace(
33
28
  new RegExp(`\\n?${MESH_START}[\\s\\S]*?${MESH_END}\\n?`, 'g'),
34
29
  ''
package/src/proxy.js CHANGED
@@ -1,8 +1,30 @@
1
1
  import http from 'http'
2
2
  import https from 'https'
3
+ import net from 'net'
3
4
  import { readFileSync } from 'fs'
4
5
  import httpProxy from 'http-proxy'
5
6
  import { matchRule, applyRule } from './rules.js'
7
+ import { wantsHtml, errorPage } from './error-page.js'
8
+
9
+ const hostCache = new Map()
10
+
11
+ function probeHost(port) {
12
+ if (hostCache.has(port)) return Promise.resolve(hostCache.get(port))
13
+ const probe = addr => new Promise((resolve, reject) => {
14
+ const s = net.connect(port, addr)
15
+ s.setTimeout(200)
16
+ s.on('connect', () => { s.destroy(); resolve(addr) })
17
+ s.on('timeout', () => { s.destroy(); reject(new Error('timeout')) })
18
+ s.on('error', reject)
19
+ })
20
+ return Promise.any([probe('127.0.0.1'), probe('::1')])
21
+ .then(host => { hostCache.set(port, host); return host })
22
+ .catch(() => '127.0.0.1')
23
+ }
24
+
25
+ function fmtHost(host) {
26
+ return host.includes(':') ? `[${host}]` : host
27
+ }
6
28
 
7
29
  const RESET = '\x1b[0m'
8
30
  const DIM = '\x1b[2m'
@@ -15,11 +37,17 @@ export function startProxy(services, rules, certs = null) {
15
37
  const proxy = httpProxy.createProxyServer({ xfwd: true })
16
38
 
17
39
  proxy.on('error', (err, req, res) => {
18
- const host = req.headers.host?.split('.')[0] ?? '?'
19
- log(RED, 'ERR', host, req.url, `→ ${err.code ?? err.message}`)
40
+ const { name, target } = resolveService(req.headers.host)
41
+ log(RED, 'ERR', name, req.url, `→ ${err.code ?? err.message}`)
20
42
  if (!res.headersSent) {
21
- res.writeHead(502, { 'Content-Type': 'application/json' })
22
- res.end(JSON.stringify({ error: 'Service unavailable', service: host }))
43
+ const protocol = certs ? 'https' : 'http'
44
+ if (wantsHtml(req)) {
45
+ res.writeHead(502, { 'Content-Type': 'text/html; charset=utf-8' })
46
+ res.end(errorPage(502, name, { port: target, protocol }))
47
+ } else {
48
+ res.writeHead(502, { 'Content-Type': 'application/json' })
49
+ res.end(JSON.stringify({ error: 'Service unavailable', service: name }))
50
+ }
23
51
  }
24
52
  })
25
53
 
@@ -33,14 +61,20 @@ export function startProxy(services, rules, certs = null) {
33
61
  const { name, target } = resolveService(req.headers.host)
34
62
 
35
63
  if (!target) {
36
- res.writeHead(404, { 'Content-Type': 'application/json' })
37
- res.end(JSON.stringify({ error: `Unknown service: ${name}` }))
64
+ const protocol = certs ? 'https' : 'http'
65
+ if (wantsHtml(req)) {
66
+ res.writeHead(404, { 'Content-Type': 'text/html; charset=utf-8' })
67
+ res.end(errorPage(404, name, { services, protocol }))
68
+ } else {
69
+ res.writeHead(404, { 'Content-Type': 'application/json' })
70
+ res.end(JSON.stringify({ error: `Unknown service: ${name}` }))
71
+ }
38
72
  return
39
73
  }
40
74
 
41
75
  const pathname = new URL(req.url, 'http://x').pathname
42
76
 
43
- const rule = matchRule(rules, name, pathname)
77
+ const rule = matchRule(rules, name, pathname, req.method)
44
78
 
45
79
  if (rule) {
46
80
  const injected = await applyRule(rule, res)
@@ -52,14 +86,16 @@ export function startProxy(services, rules, certs = null) {
52
86
  log(YELLOW, `${rule.delay}ms`, name, pathname, `→ :${target} (delayed)`)
53
87
  }
54
88
 
55
- proxy.web(req, res, { target: `http://127.0.0.1:${target}` })
89
+ const host = await probeHost(target)
90
+ proxy.web(req, res, { target: `http://${fmtHost(host)}:${target}` })
56
91
  if (!rule) log(DIM, '→', name, pathname, `→ :${target}`)
57
92
  }
58
93
 
59
- function handleUpgrade(req, socket, head) {
94
+ async function handleUpgrade(req, socket, head) {
60
95
  const { name, target } = resolveService(req.headers.host)
61
96
  if (!target) { socket.destroy(); return }
62
- proxy.ws(req, socket, head, { target: `ws://127.0.0.1:${target}` }, err => {
97
+ const host = await probeHost(target)
98
+ proxy.ws(req, socket, head, { target: `ws://${fmtHost(host)}:${target}` }, err => {
63
99
  if (err) log(RED, 'WSE', name, req.url, `→ ${err.code ?? err.message}`)
64
100
  })
65
101
  log(DIM, 'WS', name, req.url, `→ :${target}`)
@@ -85,7 +121,7 @@ export function startProxy(services, rules, certs = null) {
85
121
  throw err
86
122
  })
87
123
 
88
- httpServer.listen(80, '127.0.0.1', () => onReady(services, rules, certs))
124
+ httpServer.listen(80, '::', () => onReady(services, rules, certs))
89
125
 
90
126
  let httpsServer = null
91
127
 
@@ -102,7 +138,7 @@ export function startProxy(services, rules, certs = null) {
102
138
  }
103
139
  throw err
104
140
  })
105
- httpsServer.listen(443, '127.0.0.1')
141
+ httpsServer.listen(443, '::')
106
142
  }
107
143
 
108
144
  return { http: httpServer, https: httpsServer }
@@ -122,8 +158,9 @@ function onReady(services, rules, certs) {
122
158
  console.log('')
123
159
  for (const [svc, ruleList] of Object.entries(rules)) {
124
160
  for (const r of ruleList) {
125
- const type = r.status ? `${r.status}` : `${r.delay}ms delay`
126
- console.log(` ${YELLOW}${svc}${r.path}${RESET} ${DIM}${r.rate}% ${type}${RESET}`)
161
+ const type = r.status ? `${r.status}` : `${r.delay}ms delay`
162
+ const methodStr = r.method ? `${r.method} ` : ''
163
+ console.log(` ${YELLOW}${svc}${r.path}${RESET} ${DIM}${methodStr}${r.rate}% → ${type}${RESET}`)
127
164
  }
128
165
  }
129
166
  }
package/src/rules.js CHANGED
@@ -1,9 +1,10 @@
1
- export function matchRule(rules, serviceName, pathname) {
1
+ export function matchRule(rules, serviceName, pathname, method) {
2
2
  const serviceRules = rules[serviceName]
3
3
  if (!serviceRules) return null
4
4
 
5
5
  for (const rule of serviceRules) {
6
6
  if (!pathname.startsWith(rule.path)) continue
7
+ if (rule.method && rule.method !== method.toUpperCase()) continue
7
8
  if (rule.rate === 0) continue
8
9
  if (Math.random() * 100 > rule.rate) continue
9
10
  return rule
@@ -16,8 +17,12 @@ export function applyRule(rule, res) {
16
17
  return new Promise(resolve => {
17
18
  const respond = () => {
18
19
  if (rule.status) {
19
- res.writeHead(rule.status, { 'Content-Type': 'application/json' })
20
- res.end(JSON.stringify({ error: statusText(rule.status), injected: true }))
20
+ const body = rule.body ?? { error: statusText(rule.status), injected: true }
21
+ const isObj = typeof body !== 'string'
22
+ const raw = isObj ? JSON.stringify(body) : body
23
+ const type = isObj ? 'application/json' : 'text/plain'
24
+ res.writeHead(rule.status, { 'Content-Type': type })
25
+ res.end(raw)
21
26
  }
22
27
  resolve(rule.status != null)
23
28
  }