@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 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,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 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')
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.1.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 = /^[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
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(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
@@ -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 host = req.headers.host?.split('.')[0] ?? '?'
19
- log(RED, 'ERR', host, req.url, `→ ${err.code ?? err.message}`)
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
- res.writeHead(502, { 'Content-Type': 'application/json' })
22
- res.end(JSON.stringify({ error: 'Service unavailable', service: host }))
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
- res.writeHead(404, { 'Content-Type': 'application/json' })
37
- res.end(JSON.stringify({ error: `Unknown service: ${name}` }))
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 = r.status ? `${r.status}` : `${r.delay}ms delay`
126
- console.log(` ${YELLOW}${svc}${r.path}${RESET} ${DIM}${r.rate}% ${type}${RESET}`)
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
- 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
  }