@avelor/mesh 0.1.0 → 0.2.0
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 +19 -10
- package/bin/mesh.js +108 -4
- package/package.json +4 -1
- package/src/config.js +7 -1
- package/src/error-page.js +68 -0
- package/src/hosts.js +8 -13
- package/src/proxy.js +23 -9
- package/src/rules.js +8 -3
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
|
|
29
|
-
sudo mesh
|
|
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
|
|
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
|
-
| `
|
|
77
|
-
| `
|
|
78
|
-
| `
|
|
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,13 @@ 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 CYAN = '\x1b[36m'
|
|
18
|
+
|
|
11
19
|
// ── Argument parsing ──────────────────────────────────────────────────────────
|
|
12
20
|
|
|
13
21
|
const args = process.argv.slice(2)
|
|
@@ -27,11 +35,103 @@ if (cmd === 'init') {
|
|
|
27
35
|
process.exit(0)
|
|
28
36
|
}
|
|
29
37
|
|
|
38
|
+
if (cmd === 'start') {
|
|
39
|
+
try {
|
|
40
|
+
const state = JSON.parse(readFileSync(STATE_FILE, 'utf8'))
|
|
41
|
+
let alive = false
|
|
42
|
+
try { process.kill(state.pid, 0); alive = true } catch (e) { if (e.code === 'EPERM') alive = true }
|
|
43
|
+
if (alive) {
|
|
44
|
+
console.error(`mesh: already running (pid ${state.pid}) — run sudo mesh stop first`)
|
|
45
|
+
process.exit(1)
|
|
46
|
+
}
|
|
47
|
+
} catch { /* not running */ }
|
|
48
|
+
|
|
49
|
+
if (process.getuid?.() !== 0) {
|
|
50
|
+
console.error('mesh: requires sudo to bind ports 80/443')
|
|
51
|
+
console.error(' sudo mesh start')
|
|
52
|
+
process.exit(1)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const forwardArgs = args.filter(a => a !== 'start')
|
|
56
|
+
const child = spawn(process.execPath, [process.argv[1], 'route', ...forwardArgs], {
|
|
57
|
+
detached: true,
|
|
58
|
+
stdio: 'ignore',
|
|
59
|
+
})
|
|
60
|
+
child.unref()
|
|
61
|
+
console.log(`mesh: started (pid ${child.pid})`)
|
|
62
|
+
process.exit(0)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (cmd === 'stop') {
|
|
66
|
+
let state
|
|
67
|
+
try {
|
|
68
|
+
state = JSON.parse(readFileSync(STATE_FILE, 'utf8'))
|
|
69
|
+
} catch {
|
|
70
|
+
console.error('mesh: not running')
|
|
71
|
+
process.exit(1)
|
|
72
|
+
}
|
|
73
|
+
try {
|
|
74
|
+
process.kill(state.pid, 'SIGTERM')
|
|
75
|
+
console.log(`mesh: stopped (pid ${state.pid})`)
|
|
76
|
+
} catch (err) {
|
|
77
|
+
if (err.code === 'EPERM') {
|
|
78
|
+
console.error(`mesh: permission denied — try: sudo mesh stop`)
|
|
79
|
+
process.exit(1)
|
|
80
|
+
}
|
|
81
|
+
if (err.code === 'ESRCH') {
|
|
82
|
+
try { unlinkSync(STATE_FILE) } catch {}
|
|
83
|
+
console.error('mesh: not running (stale state removed)')
|
|
84
|
+
process.exit(1)
|
|
85
|
+
}
|
|
86
|
+
console.error('mesh:', err.message)
|
|
87
|
+
process.exit(1)
|
|
88
|
+
}
|
|
89
|
+
process.exit(0)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (cmd === 'status') {
|
|
93
|
+
let state
|
|
94
|
+
try {
|
|
95
|
+
state = JSON.parse(readFileSync(STATE_FILE, 'utf8'))
|
|
96
|
+
} catch {
|
|
97
|
+
console.error('mesh: not running')
|
|
98
|
+
process.exit(1)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
let alive = false
|
|
102
|
+
try {
|
|
103
|
+
process.kill(state.pid, 0)
|
|
104
|
+
alive = true
|
|
105
|
+
} catch (err) {
|
|
106
|
+
if (err.code === 'EPERM') alive = true
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (!alive) {
|
|
110
|
+
try { unlinkSync(STATE_FILE) } catch {}
|
|
111
|
+
console.error('mesh: not running (stale state removed)')
|
|
112
|
+
process.exit(1)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const protocol = state.https ? 'https' : 'http'
|
|
116
|
+
const pad = Math.max(...Object.keys(state.services).map(s => s.length))
|
|
117
|
+
console.log('')
|
|
118
|
+
console.log(` ${CYAN}mesh${RESET} running ${DIM}pid ${state.pid}${RESET}`)
|
|
119
|
+
console.log('')
|
|
120
|
+
for (const [name, port] of Object.entries(state.services)) {
|
|
121
|
+
console.log(` ${GREEN}${name.padEnd(pad)}.test${RESET} ${DIM}→ :${port} ${protocol}://${name}.test${RESET}`)
|
|
122
|
+
}
|
|
123
|
+
console.log('')
|
|
124
|
+
process.exit(0)
|
|
125
|
+
}
|
|
126
|
+
|
|
30
127
|
if (cmd !== 'route') {
|
|
31
128
|
console.error('Usage:')
|
|
32
|
-
console.error(' mesh init
|
|
33
|
-
console.error(' sudo mesh
|
|
34
|
-
console.error(' sudo mesh
|
|
129
|
+
console.error(' mesh init create mesh.yml in current directory')
|
|
130
|
+
console.error(' sudo mesh start start proxy in background')
|
|
131
|
+
console.error(' sudo mesh start --config <path> use a specific config file')
|
|
132
|
+
console.error(' sudo mesh stop stop the background proxy')
|
|
133
|
+
console.error(' mesh status show running services')
|
|
134
|
+
console.error(' sudo mesh route start proxy in foreground (debug)')
|
|
35
135
|
process.exit(1)
|
|
36
136
|
}
|
|
37
137
|
|
|
@@ -60,10 +160,13 @@ const configDir = dirname(resolve(configPath))
|
|
|
60
160
|
const certs = ensureCerts(services, configDir)
|
|
61
161
|
const servers = startProxy(services, rules, certs)
|
|
62
162
|
|
|
163
|
+
writeFileSync(STATE_FILE, JSON.stringify({ pid: process.pid, configPath, services, https: !!certs }))
|
|
164
|
+
|
|
63
165
|
// ── Crash safety — clean /etc/hosts even on unexpected exit ───────────────────
|
|
64
166
|
|
|
65
167
|
function shutdown(code = 0) {
|
|
66
168
|
removeHosts()
|
|
169
|
+
try { unlinkSync(STATE_FILE) } catch {}
|
|
67
170
|
servers.http.close()
|
|
68
171
|
servers.https?.close()
|
|
69
172
|
process.exit(code)
|
|
@@ -99,6 +202,7 @@ watch(configPath, () => {
|
|
|
99
202
|
Object.assign(rules, next.rules)
|
|
100
203
|
|
|
101
204
|
writeHosts(services)
|
|
205
|
+
writeFileSync(STATE_FILE, JSON.stringify({ pid: process.pid, configPath, services, https: !!certs }))
|
|
102
206
|
|
|
103
207
|
if (certs && hasNewServices) {
|
|
104
208
|
const newCerts = ensureCerts(services, configDir)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@avelor/mesh",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
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
|
|
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(
|
|
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
11
|
.map(name => `127.0.0.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(
|
|
25
|
-
writeFileSync(
|
|
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
|
@@ -3,6 +3,7 @@ import https from 'https'
|
|
|
3
3
|
import { readFileSync } from 'fs'
|
|
4
4
|
import httpProxy from 'http-proxy'
|
|
5
5
|
import { matchRule, applyRule } from './rules.js'
|
|
6
|
+
import { wantsHtml, errorPage } from './error-page.js'
|
|
6
7
|
|
|
7
8
|
const RESET = '\x1b[0m'
|
|
8
9
|
const DIM = '\x1b[2m'
|
|
@@ -15,11 +16,17 @@ export function startProxy(services, rules, certs = null) {
|
|
|
15
16
|
const proxy = httpProxy.createProxyServer({ xfwd: true })
|
|
16
17
|
|
|
17
18
|
proxy.on('error', (err, req, res) => {
|
|
18
|
-
const
|
|
19
|
-
log(RED, 'ERR',
|
|
19
|
+
const { name, target } = resolveService(req.headers.host)
|
|
20
|
+
log(RED, 'ERR', name, req.url, `→ ${err.code ?? err.message}`)
|
|
20
21
|
if (!res.headersSent) {
|
|
21
|
-
|
|
22
|
-
|
|
22
|
+
const protocol = certs ? 'https' : 'http'
|
|
23
|
+
if (wantsHtml(req)) {
|
|
24
|
+
res.writeHead(502, { 'Content-Type': 'text/html; charset=utf-8' })
|
|
25
|
+
res.end(errorPage(502, name, { port: target, protocol }))
|
|
26
|
+
} else {
|
|
27
|
+
res.writeHead(502, { 'Content-Type': 'application/json' })
|
|
28
|
+
res.end(JSON.stringify({ error: 'Service unavailable', service: name }))
|
|
29
|
+
}
|
|
23
30
|
}
|
|
24
31
|
})
|
|
25
32
|
|
|
@@ -33,14 +40,20 @@ export function startProxy(services, rules, certs = null) {
|
|
|
33
40
|
const { name, target } = resolveService(req.headers.host)
|
|
34
41
|
|
|
35
42
|
if (!target) {
|
|
36
|
-
|
|
37
|
-
|
|
43
|
+
const protocol = certs ? 'https' : 'http'
|
|
44
|
+
if (wantsHtml(req)) {
|
|
45
|
+
res.writeHead(404, { 'Content-Type': 'text/html; charset=utf-8' })
|
|
46
|
+
res.end(errorPage(404, name, { services, protocol }))
|
|
47
|
+
} else {
|
|
48
|
+
res.writeHead(404, { 'Content-Type': 'application/json' })
|
|
49
|
+
res.end(JSON.stringify({ error: `Unknown service: ${name}` }))
|
|
50
|
+
}
|
|
38
51
|
return
|
|
39
52
|
}
|
|
40
53
|
|
|
41
54
|
const pathname = new URL(req.url, 'http://x').pathname
|
|
42
55
|
|
|
43
|
-
const rule = matchRule(rules, name, pathname)
|
|
56
|
+
const rule = matchRule(rules, name, pathname, req.method)
|
|
44
57
|
|
|
45
58
|
if (rule) {
|
|
46
59
|
const injected = await applyRule(rule, res)
|
|
@@ -122,8 +135,9 @@ function onReady(services, rules, certs) {
|
|
|
122
135
|
console.log('')
|
|
123
136
|
for (const [svc, ruleList] of Object.entries(rules)) {
|
|
124
137
|
for (const r of ruleList) {
|
|
125
|
-
const type
|
|
126
|
-
|
|
138
|
+
const type = r.status ? `${r.status}` : `${r.delay}ms delay`
|
|
139
|
+
const methodStr = r.method ? `${r.method} ` : ''
|
|
140
|
+
console.log(` ${YELLOW}${svc}${r.path}${RESET} ${DIM}${methodStr}${r.rate}% → ${type}${RESET}`)
|
|
127
141
|
}
|
|
128
142
|
}
|
|
129
143
|
}
|
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
|
-
|
|
20
|
-
|
|
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
|
}
|