@avelor/mesh 0.1.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/LICENSE +21 -0
- package/README.md +147 -0
- package/bin/mesh.js +118 -0
- package/package.json +41 -0
- package/src/certs.js +58 -0
- package/src/config.js +66 -0
- package/src/hosts.js +36 -0
- package/src/init.js +43 -0
- package/src/proxy.js +136 -0
- package/src/rules.js +47 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Avelor
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
# mesh
|
|
2
|
+
|
|
3
|
+
Local dev proxy. Named services instead of ports. Failure injection without mocks.
|
|
4
|
+
|
|
5
|
+
```
|
|
6
|
+
app.test → :3000
|
|
7
|
+
api.test → :4000
|
|
8
|
+
admin.test → :5000
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
No more `:3000`, `:4000`, `:5000` open in different tabs. No more mixing them up.
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## Install
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
npm install -g @avelor/mesh
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Requires Node.js 18+. For HTTPS, install [mkcert](https://github.com/FiloSottile/mkcert).
|
|
22
|
+
|
|
23
|
+
---
|
|
24
|
+
|
|
25
|
+
## Usage
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
mesh init # create mesh.yml in current directory
|
|
29
|
+
sudo mesh route # start proxy (writes /etc/hosts)
|
|
30
|
+
```
|
|
31
|
+
|
|
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.
|
|
33
|
+
|
|
34
|
+
---
|
|
35
|
+
|
|
36
|
+
## mesh.yml
|
|
37
|
+
|
|
38
|
+
```yaml
|
|
39
|
+
services:
|
|
40
|
+
app: 3000
|
|
41
|
+
api: 4000
|
|
42
|
+
admin: 5000
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
That's enough to get started. Services are available at `app.test`, `api.test`, `admin.test`.
|
|
46
|
+
|
|
47
|
+
### Failure rules
|
|
48
|
+
|
|
49
|
+
Test how your app handles errors — no mocks, no code changes.
|
|
50
|
+
|
|
51
|
+
```yaml
|
|
52
|
+
services:
|
|
53
|
+
app: 3000
|
|
54
|
+
api: 4000
|
|
55
|
+
|
|
56
|
+
rules:
|
|
57
|
+
api:
|
|
58
|
+
- path: /payments
|
|
59
|
+
status: 503
|
|
60
|
+
rate: 30
|
|
61
|
+
- path: /auth/login
|
|
62
|
+
status: 401
|
|
63
|
+
rate: 20
|
|
64
|
+
- path: /slow-endpoint
|
|
65
|
+
delay: 2000
|
|
66
|
+
rate: 100
|
|
67
|
+
- path: /flaky
|
|
68
|
+
status: 500
|
|
69
|
+
delay: 800
|
|
70
|
+
rate: 25
|
|
71
|
+
```
|
|
72
|
+
|
|
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)|
|
|
79
|
+
|
|
80
|
+
`status` and `delay` can be combined: wait N ms, then fail with that status.
|
|
81
|
+
|
|
82
|
+
### Subdomains
|
|
83
|
+
|
|
84
|
+
Multiple names can point to the same port:
|
|
85
|
+
|
|
86
|
+
```yaml
|
|
87
|
+
services:
|
|
88
|
+
app: 3000
|
|
89
|
+
tenant1.app: 3000
|
|
90
|
+
tenant2.app: 3000
|
|
91
|
+
api: 4000
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
Rules apply per name, so `tenant1.app` and `tenant2.app` can have different failure scenarios.
|
|
95
|
+
|
|
96
|
+
---
|
|
97
|
+
|
|
98
|
+
## HTTPS
|
|
99
|
+
|
|
100
|
+
If [mkcert](https://github.com/FiloSottile/mkcert) is installed, `mesh route` automatically generates a locally-trusted certificate for all your `.test` domains and serves them over HTTPS. Port `:80` redirects to `:443`.
|
|
101
|
+
|
|
102
|
+
```bash
|
|
103
|
+
# macOS
|
|
104
|
+
brew install mkcert
|
|
105
|
+
|
|
106
|
+
# Linux
|
|
107
|
+
apt install mkcert
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
If mkcert is not found, mesh falls back to HTTP only.
|
|
111
|
+
|
|
112
|
+
Generated certificates are stored in `.mesh/` (added to `.gitignore` by `mesh init`).
|
|
113
|
+
|
|
114
|
+
---
|
|
115
|
+
|
|
116
|
+
## Examples
|
|
117
|
+
|
|
118
|
+
See [`examples/basic`](examples/basic) and [`examples/multi`](examples/multi) for working setups with a frontend and API server.
|
|
119
|
+
|
|
120
|
+
```bash
|
|
121
|
+
# terminal 1
|
|
122
|
+
cd examples/basic
|
|
123
|
+
node api.js
|
|
124
|
+
|
|
125
|
+
# terminal 2
|
|
126
|
+
cd examples/basic
|
|
127
|
+
node app.js
|
|
128
|
+
|
|
129
|
+
# terminal 3 (from examples/basic)
|
|
130
|
+
sudo mesh route
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
Then open `https://app.test`.
|
|
134
|
+
|
|
135
|
+
---
|
|
136
|
+
|
|
137
|
+
## How it works
|
|
138
|
+
|
|
139
|
+
`mesh route` runs a local HTTP/HTTPS proxy on `127.0.0.1`. Incoming requests are matched by hostname, forwarded to the configured port, and optionally intercepted by failure rules before reaching the target service.
|
|
140
|
+
|
|
141
|
+
`/etc/hosts` entries are written on start and removed on exit (`SIGINT` / `SIGTERM`).
|
|
142
|
+
|
|
143
|
+
---
|
|
144
|
+
|
|
145
|
+
## License
|
|
146
|
+
|
|
147
|
+
MIT
|
package/bin/mesh.js
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { watch, existsSync, readFileSync } from 'fs'
|
|
3
|
+
import { dirname, resolve } from 'path'
|
|
4
|
+
import { createRequire } from 'module'
|
|
5
|
+
import { loadConfig } from '../src/config.js'
|
|
6
|
+
import { writeHosts, removeHosts } from '../src/hosts.js'
|
|
7
|
+
import { startProxy } from '../src/proxy.js'
|
|
8
|
+
import { ensureCerts } from '../src/certs.js'
|
|
9
|
+
import { init } from '../src/init.js'
|
|
10
|
+
|
|
11
|
+
// ── Argument parsing ──────────────────────────────────────────────────────────
|
|
12
|
+
|
|
13
|
+
const args = process.argv.slice(2)
|
|
14
|
+
const cmd = args.find(a => !a.startsWith('-'))
|
|
15
|
+
|
|
16
|
+
const configIdx = args.indexOf('--config')
|
|
17
|
+
const configArg = configIdx !== -1 ? args[configIdx + 1] : null
|
|
18
|
+
|
|
19
|
+
if (args.includes('--version') || args.includes('-v')) {
|
|
20
|
+
const { version } = createRequire(import.meta.url)('../package.json')
|
|
21
|
+
console.log(version)
|
|
22
|
+
process.exit(0)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (cmd === 'init') {
|
|
26
|
+
init()
|
|
27
|
+
process.exit(0)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (cmd !== 'route') {
|
|
31
|
+
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')
|
|
35
|
+
process.exit(1)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (process.getuid?.() !== 0) {
|
|
39
|
+
console.error('mesh: requires sudo to write /etc/hosts and bind ports 80/443')
|
|
40
|
+
console.error(' both are cleaned up automatically on exit')
|
|
41
|
+
console.error(' sudo mesh route')
|
|
42
|
+
process.exit(1)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ── Load config ───────────────────────────────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
let config
|
|
48
|
+
try {
|
|
49
|
+
config = loadConfig(configArg ?? process.cwd())
|
|
50
|
+
} catch (err) {
|
|
51
|
+
console.error('mesh:', err.message)
|
|
52
|
+
process.exit(1)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const { services, rules, configPath } = config
|
|
56
|
+
|
|
57
|
+
writeHosts(services)
|
|
58
|
+
|
|
59
|
+
const configDir = dirname(resolve(configPath))
|
|
60
|
+
const certs = ensureCerts(services, configDir)
|
|
61
|
+
const servers = startProxy(services, rules, certs)
|
|
62
|
+
|
|
63
|
+
// ── Crash safety — clean /etc/hosts even on unexpected exit ───────────────────
|
|
64
|
+
|
|
65
|
+
function shutdown(code = 0) {
|
|
66
|
+
removeHosts()
|
|
67
|
+
servers.http.close()
|
|
68
|
+
servers.https?.close()
|
|
69
|
+
process.exit(code)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
process.on('SIGINT', () => { console.log('\n mesh cleaning up...'); shutdown(0) })
|
|
73
|
+
process.on('SIGTERM', () => shutdown(0))
|
|
74
|
+
|
|
75
|
+
process.on('uncaughtException', err => {
|
|
76
|
+
console.error('\n mesh uncaught exception:', err.message)
|
|
77
|
+
shutdown(1)
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
process.on('unhandledRejection', err => {
|
|
81
|
+
console.error('\n mesh unhandled rejection:', err?.message ?? err)
|
|
82
|
+
shutdown(1)
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
// ── Hot-reload ────────────────────────────────────────────────────────────────
|
|
86
|
+
|
|
87
|
+
let reloadTimer
|
|
88
|
+
watch(configPath, () => {
|
|
89
|
+
clearTimeout(reloadTimer)
|
|
90
|
+
reloadTimer = setTimeout(() => {
|
|
91
|
+
try {
|
|
92
|
+
const next = loadConfig(configPath)
|
|
93
|
+
|
|
94
|
+
const hasNewServices = Object.keys(next.services).some(k => !services[k])
|
|
95
|
+
|
|
96
|
+
Object.keys(services).forEach(k => delete services[k])
|
|
97
|
+
Object.assign(services, next.services)
|
|
98
|
+
Object.keys(rules).forEach(k => delete rules[k])
|
|
99
|
+
Object.assign(rules, next.rules)
|
|
100
|
+
|
|
101
|
+
writeHosts(services)
|
|
102
|
+
|
|
103
|
+
if (certs && hasNewServices) {
|
|
104
|
+
const newCerts = ensureCerts(services, configDir)
|
|
105
|
+
if (newCerts && servers.https) {
|
|
106
|
+
servers.https.setSecureContext({
|
|
107
|
+
cert: readFileSync(newCerts.certFile),
|
|
108
|
+
key: readFileSync(newCerts.keyFile),
|
|
109
|
+
})
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
console.log('\n mesh config reloaded\n')
|
|
114
|
+
} catch (err) {
|
|
115
|
+
console.error('\n mesh config reload failed:', err.message, '\n')
|
|
116
|
+
}
|
|
117
|
+
}, 100)
|
|
118
|
+
})
|
package/package.json
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@avelor/mesh",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Local dev proxy. Named services, failure rules, no ports.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"proxy",
|
|
7
|
+
"dev",
|
|
8
|
+
"local",
|
|
9
|
+
"localhost",
|
|
10
|
+
"https",
|
|
11
|
+
"hosts",
|
|
12
|
+
"dns",
|
|
13
|
+
"chaos",
|
|
14
|
+
"fault-injection",
|
|
15
|
+
"developer-tools"
|
|
16
|
+
],
|
|
17
|
+
"license": "MIT",
|
|
18
|
+
"author": "Avelor <cruz@avelor.es>",
|
|
19
|
+
"repository": {
|
|
20
|
+
"type": "git",
|
|
21
|
+
"url": "https://github.com/avelor-es/mesh"
|
|
22
|
+
},
|
|
23
|
+
"publishConfig": {
|
|
24
|
+
"access": "public"
|
|
25
|
+
},
|
|
26
|
+
"type": "module",
|
|
27
|
+
"bin": {
|
|
28
|
+
"mesh": "./bin/mesh.js"
|
|
29
|
+
},
|
|
30
|
+
"files": [
|
|
31
|
+
"bin",
|
|
32
|
+
"src"
|
|
33
|
+
],
|
|
34
|
+
"dependencies": {
|
|
35
|
+
"http-proxy": "^1.18.1",
|
|
36
|
+
"js-yaml": "^4.1.0"
|
|
37
|
+
},
|
|
38
|
+
"engines": {
|
|
39
|
+
"node": ">=18"
|
|
40
|
+
}
|
|
41
|
+
}
|
package/src/certs.js
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { execFileSync, spawnSync } from 'child_process'
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
|
|
3
|
+
import { resolve } from 'path'
|
|
4
|
+
|
|
5
|
+
function mkcertInstalled() {
|
|
6
|
+
return spawnSync('which', ['mkcert']).status === 0
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function caInstalled() {
|
|
10
|
+
try {
|
|
11
|
+
const caRoot = execFileSync('mkcert', ['-CAROOT'], { encoding: 'utf8' }).trim()
|
|
12
|
+
return existsSync(resolve(caRoot, 'rootCA.pem'))
|
|
13
|
+
} catch {
|
|
14
|
+
return false
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function cachedDomains(dir) {
|
|
19
|
+
try {
|
|
20
|
+
return JSON.parse(readFileSync(resolve(dir, 'domains.json'), 'utf8'))
|
|
21
|
+
} catch {
|
|
22
|
+
return []
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function ensureCerts(services, cwd = process.cwd()) {
|
|
27
|
+
if (!mkcertInstalled()) {
|
|
28
|
+
console.warn('mesh: mkcert not found — running HTTP only')
|
|
29
|
+
console.warn(' macOS: brew install mkcert')
|
|
30
|
+
console.warn(' Linux: apt install mkcert / snap install mkcert')
|
|
31
|
+
console.warn('')
|
|
32
|
+
return null
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const dir = resolve(cwd, '.mesh')
|
|
36
|
+
const certFile = resolve(dir, 'cert.pem')
|
|
37
|
+
const keyFile = resolve(dir, 'key.pem')
|
|
38
|
+
|
|
39
|
+
mkdirSync(dir, { recursive: true })
|
|
40
|
+
|
|
41
|
+
if (!caInstalled()) {
|
|
42
|
+
execFileSync('mkcert', ['-install'], { stdio: 'ignore' })
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const domains = Object.keys(services).map(n => `${n}.test`).sort()
|
|
46
|
+
const cached = cachedDomains(dir).sort()
|
|
47
|
+
|
|
48
|
+
const needsRegen = JSON.stringify(domains) !== JSON.stringify(cached)
|
|
49
|
+
|| !existsSync(certFile)
|
|
50
|
+
|| !existsSync(keyFile)
|
|
51
|
+
|
|
52
|
+
if (needsRegen) {
|
|
53
|
+
execFileSync('mkcert', ['-cert-file', certFile, '-key-file', keyFile, ...domains], { stdio: 'ignore' })
|
|
54
|
+
writeFileSync(resolve(dir, 'domains.json'), JSON.stringify(domains))
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return { certFile, keyFile }
|
|
58
|
+
}
|
package/src/config.js
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { readFileSync, existsSync } from 'fs'
|
|
2
|
+
import { resolve } from 'path'
|
|
3
|
+
import yaml from 'js-yaml'
|
|
4
|
+
|
|
5
|
+
const NAME_RE = /^[a-z0-9][a-z0-9.-]*$/
|
|
6
|
+
|
|
7
|
+
export function findConfigFile(cwd = process.cwd()) {
|
|
8
|
+
for (const name of ['mesh.yml', 'mesh.yaml']) {
|
|
9
|
+
const p = resolve(cwd, name)
|
|
10
|
+
if (existsSync(p)) return p
|
|
11
|
+
}
|
|
12
|
+
throw new Error(`mesh.yml not found in ${cwd} — run 'mesh init' to create one`)
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function loadConfig(cwdOrFile = process.cwd()) {
|
|
16
|
+
let path
|
|
17
|
+
if (cwdOrFile.endsWith('.yml') || cwdOrFile.endsWith('.yaml')) {
|
|
18
|
+
path = resolve(cwdOrFile)
|
|
19
|
+
if (!existsSync(path)) throw new Error(`config file not found: ${path}`)
|
|
20
|
+
} else {
|
|
21
|
+
path = findConfigFile(cwdOrFile)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const data = yaml.load(readFileSync(path, 'utf8'))
|
|
25
|
+
|
|
26
|
+
if (!data?.services || typeof data.services !== 'object') {
|
|
27
|
+
throw new Error('mesh.yml must define at least one service')
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const services = {}
|
|
31
|
+
for (const [name, raw] of Object.entries(data.services)) {
|
|
32
|
+
if (!NAME_RE.test(name)) {
|
|
33
|
+
throw new Error(`invalid service name "${name}" — only a-z, 0-9, hyphens and dots allowed`)
|
|
34
|
+
}
|
|
35
|
+
const port = Number(raw)
|
|
36
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
37
|
+
throw new Error(`invalid port for service "${name}": ${raw}`)
|
|
38
|
+
}
|
|
39
|
+
services[name] = port
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const rules = {}
|
|
43
|
+
for (const [name, list] of Object.entries(data.rules ?? {})) {
|
|
44
|
+
if (!services[name]) throw new Error(`rule references unknown service "${name}"`)
|
|
45
|
+
rules[name] = (list ?? []).map((r, i) => {
|
|
46
|
+
const prefix = `rules.${name}[${i}]`
|
|
47
|
+
if (r.status !== undefined && (typeof r.status !== 'number' || !Number.isInteger(r.status))) {
|
|
48
|
+
throw new Error(`${prefix}.status must be an integer`)
|
|
49
|
+
}
|
|
50
|
+
if (r.rate !== undefined && (typeof r.rate !== 'number' || r.rate < 0 || r.rate > 100)) {
|
|
51
|
+
throw new Error(`${prefix}.rate must be a number between 0 and 100`)
|
|
52
|
+
}
|
|
53
|
+
if (r.delay !== undefined && (typeof r.delay !== 'number' || r.delay < 0)) {
|
|
54
|
+
throw new Error(`${prefix}.delay must be a positive number`)
|
|
55
|
+
}
|
|
56
|
+
return {
|
|
57
|
+
path: r.path ?? '/',
|
|
58
|
+
status: r.status ?? null,
|
|
59
|
+
delay: r.delay ?? null,
|
|
60
|
+
rate: r.rate ?? 100,
|
|
61
|
+
}
|
|
62
|
+
})
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return { services, rules, configPath: path }
|
|
66
|
+
}
|
package/src/hosts.js
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync } from 'fs'
|
|
2
|
+
|
|
3
|
+
const HOSTS_FILE = '/etc/hosts'
|
|
4
|
+
const MESH_START = '# mesh:start'
|
|
5
|
+
const MESH_END = '# mesh:end'
|
|
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
|
+
|
|
13
|
+
const entries = Object.keys(services)
|
|
14
|
+
.map(name => `127.0.0.1 ${name}.test`)
|
|
15
|
+
.join('\n')
|
|
16
|
+
|
|
17
|
+
const next = `${clean.trimEnd()}\n\n${MESH_START}\n${entries}\n${MESH_END}\n`
|
|
18
|
+
|
|
19
|
+
writeFileSync(HOSTS_FILE, next, 'utf8')
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function removeHosts() {
|
|
23
|
+
try {
|
|
24
|
+
const current = readFileSync(HOSTS_FILE, 'utf8')
|
|
25
|
+
writeFileSync(HOSTS_FILE, removeMeshBlock(current).trimEnd() + '\n', 'utf8')
|
|
26
|
+
} catch {
|
|
27
|
+
// Best-effort cleanup
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function removeMeshBlock(content) {
|
|
32
|
+
return content.replace(
|
|
33
|
+
new RegExp(`\\n?${MESH_START}[\\s\\S]*?${MESH_END}\\n?`, 'g'),
|
|
34
|
+
''
|
|
35
|
+
)
|
|
36
|
+
}
|
package/src/init.js
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { existsSync, writeFileSync, readFileSync, appendFileSync } from 'fs'
|
|
2
|
+
import { resolve } from 'path'
|
|
3
|
+
|
|
4
|
+
const TEMPLATE = `services:
|
|
5
|
+
app: 3000
|
|
6
|
+
api: 4000
|
|
7
|
+
|
|
8
|
+
# rules:
|
|
9
|
+
# api:
|
|
10
|
+
# - path: /payments
|
|
11
|
+
# status: 503
|
|
12
|
+
# rate: 30
|
|
13
|
+
# - path: /slow-endpoint
|
|
14
|
+
# delay: 2000
|
|
15
|
+
# rate: 100
|
|
16
|
+
# - path: /flaky
|
|
17
|
+
# status: 500
|
|
18
|
+
# delay: 800
|
|
19
|
+
# rate: 25
|
|
20
|
+
`
|
|
21
|
+
|
|
22
|
+
export function init(cwd = process.cwd()) {
|
|
23
|
+
const path = resolve(cwd, 'mesh.yml')
|
|
24
|
+
|
|
25
|
+
if (existsSync(path)) {
|
|
26
|
+
console.error('mesh: mesh.yml already exists')
|
|
27
|
+
process.exit(1)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
writeFileSync(path, TEMPLATE, 'utf8')
|
|
31
|
+
console.log('mesh: created mesh.yml')
|
|
32
|
+
|
|
33
|
+
const gitignorePath = resolve(cwd, '.gitignore')
|
|
34
|
+
if (existsSync(gitignorePath)) {
|
|
35
|
+
const content = readFileSync(gitignorePath, 'utf8')
|
|
36
|
+
if (!content.includes('.mesh')) {
|
|
37
|
+
appendFileSync(gitignorePath, '\n.mesh/\n')
|
|
38
|
+
console.log('mesh: added .mesh/ to .gitignore')
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
console.log(' edit your services and run: sudo mesh route')
|
|
43
|
+
}
|
package/src/proxy.js
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import http from 'http'
|
|
2
|
+
import https from 'https'
|
|
3
|
+
import { readFileSync } from 'fs'
|
|
4
|
+
import httpProxy from 'http-proxy'
|
|
5
|
+
import { matchRule, applyRule } from './rules.js'
|
|
6
|
+
|
|
7
|
+
const RESET = '\x1b[0m'
|
|
8
|
+
const DIM = '\x1b[2m'
|
|
9
|
+
const GREEN = '\x1b[32m'
|
|
10
|
+
const YELLOW = '\x1b[33m'
|
|
11
|
+
const RED = '\x1b[31m'
|
|
12
|
+
const CYAN = '\x1b[36m'
|
|
13
|
+
|
|
14
|
+
export function startProxy(services, rules, certs = null) {
|
|
15
|
+
const proxy = httpProxy.createProxyServer({ xfwd: true })
|
|
16
|
+
|
|
17
|
+
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}`)
|
|
20
|
+
if (!res.headersSent) {
|
|
21
|
+
res.writeHead(502, { 'Content-Type': 'application/json' })
|
|
22
|
+
res.end(JSON.stringify({ error: 'Service unavailable', service: host }))
|
|
23
|
+
}
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
function resolveService(host) {
|
|
27
|
+
const hostname = (host ?? '').replace(/:\d+$/, '')
|
|
28
|
+
const name = hostname.endsWith('.test') ? hostname.slice(0, -5) : hostname
|
|
29
|
+
return { name, target: services[name] }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function handle(req, res) {
|
|
33
|
+
const { name, target } = resolveService(req.headers.host)
|
|
34
|
+
|
|
35
|
+
if (!target) {
|
|
36
|
+
res.writeHead(404, { 'Content-Type': 'application/json' })
|
|
37
|
+
res.end(JSON.stringify({ error: `Unknown service: ${name}` }))
|
|
38
|
+
return
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const pathname = new URL(req.url, 'http://x').pathname
|
|
42
|
+
|
|
43
|
+
const rule = matchRule(rules, name, pathname)
|
|
44
|
+
|
|
45
|
+
if (rule) {
|
|
46
|
+
const injected = await applyRule(rule, res)
|
|
47
|
+
if (injected) {
|
|
48
|
+
const label = rule.delay ? `${rule.delay}ms+${rule.status}` : `${rule.status}`
|
|
49
|
+
log(YELLOW, label, name, pathname, `→ injected`)
|
|
50
|
+
return
|
|
51
|
+
}
|
|
52
|
+
log(YELLOW, `${rule.delay}ms`, name, pathname, `→ :${target} (delayed)`)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
proxy.web(req, res, { target: `http://127.0.0.1:${target}` })
|
|
56
|
+
if (!rule) log(DIM, '→', name, pathname, `→ :${target}`)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function handleUpgrade(req, socket, head) {
|
|
60
|
+
const { name, target } = resolveService(req.headers.host)
|
|
61
|
+
if (!target) { socket.destroy(); return }
|
|
62
|
+
proxy.ws(req, socket, head, { target: `ws://127.0.0.1:${target}` }, err => {
|
|
63
|
+
if (err) log(RED, 'WSE', name, req.url, `→ ${err.code ?? err.message}`)
|
|
64
|
+
})
|
|
65
|
+
log(DIM, 'WS', name, req.url, `→ :${target}`)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const httpServer = http.createServer((req, res) => {
|
|
69
|
+
if (certs) {
|
|
70
|
+
const host = (req.headers.host ?? '').replace(/:80$/, '')
|
|
71
|
+
res.writeHead(301, { Location: `https://${host}${req.url}` })
|
|
72
|
+
res.end()
|
|
73
|
+
return
|
|
74
|
+
}
|
|
75
|
+
handle(req, res)
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
httpServer.on('upgrade', handleUpgrade)
|
|
79
|
+
|
|
80
|
+
httpServer.on('error', err => {
|
|
81
|
+
if (err.code === 'EADDRINUSE') {
|
|
82
|
+
console.error('mesh: port 80 is already in use — stop whatever is running on it and retry')
|
|
83
|
+
process.exit(1)
|
|
84
|
+
}
|
|
85
|
+
throw err
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
httpServer.listen(80, '127.0.0.1', () => onReady(services, rules, certs))
|
|
89
|
+
|
|
90
|
+
let httpsServer = null
|
|
91
|
+
|
|
92
|
+
if (certs) {
|
|
93
|
+
httpsServer = https.createServer(
|
|
94
|
+
{ cert: readFileSync(certs.certFile), key: readFileSync(certs.keyFile) },
|
|
95
|
+
handle
|
|
96
|
+
)
|
|
97
|
+
httpsServer.on('upgrade', handleUpgrade)
|
|
98
|
+
httpsServer.on('error', err => {
|
|
99
|
+
if (err.code === 'EADDRINUSE') {
|
|
100
|
+
console.error('mesh: port 443 is already in use — stop whatever is running on it and retry')
|
|
101
|
+
process.exit(1)
|
|
102
|
+
}
|
|
103
|
+
throw err
|
|
104
|
+
})
|
|
105
|
+
httpsServer.listen(443, '127.0.0.1')
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return { http: httpServer, https: httpsServer }
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function onReady(services, rules, certs) {
|
|
112
|
+
const pad = Math.max(...Object.keys(services).map(s => s.length))
|
|
113
|
+
const protocol = certs ? 'https' : 'http'
|
|
114
|
+
|
|
115
|
+
console.log('')
|
|
116
|
+
console.log(` ${CYAN}mesh${RESET} ${certs ? 'https + http→https redirect' : 'http only'}`)
|
|
117
|
+
console.log('')
|
|
118
|
+
for (const [name, port] of Object.entries(services)) {
|
|
119
|
+
console.log(` ${GREEN}${name.padEnd(pad)}.test${RESET} ${DIM}→ :${port} ${protocol}://${name}.test${RESET}`)
|
|
120
|
+
}
|
|
121
|
+
if (Object.keys(rules).length) {
|
|
122
|
+
console.log('')
|
|
123
|
+
for (const [svc, ruleList] of Object.entries(rules)) {
|
|
124
|
+
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}`)
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
console.log('')
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function log(color, label, service, path, tail) {
|
|
134
|
+
const time = new Date().toTimeString().slice(0, 8)
|
|
135
|
+
console.log(` ${DIM}${time}${RESET} ${color}${label}${RESET} ${service}${DIM}${path}${RESET} ${DIM}${tail}${RESET}`)
|
|
136
|
+
}
|
package/src/rules.js
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
export function matchRule(rules, serviceName, pathname) {
|
|
2
|
+
const serviceRules = rules[serviceName]
|
|
3
|
+
if (!serviceRules) return null
|
|
4
|
+
|
|
5
|
+
for (const rule of serviceRules) {
|
|
6
|
+
if (!pathname.startsWith(rule.path)) continue
|
|
7
|
+
if (rule.rate === 0) continue
|
|
8
|
+
if (Math.random() * 100 > rule.rate) continue
|
|
9
|
+
return rule
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
return null
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function applyRule(rule, res) {
|
|
16
|
+
return new Promise(resolve => {
|
|
17
|
+
const respond = () => {
|
|
18
|
+
if (rule.status) {
|
|
19
|
+
res.writeHead(rule.status, { 'Content-Type': 'application/json' })
|
|
20
|
+
res.end(JSON.stringify({ error: statusText(rule.status), injected: true }))
|
|
21
|
+
}
|
|
22
|
+
resolve(rule.status != null)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (rule.delay) {
|
|
26
|
+
setTimeout(respond, rule.delay)
|
|
27
|
+
} else {
|
|
28
|
+
respond()
|
|
29
|
+
}
|
|
30
|
+
})
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function statusText(code) {
|
|
34
|
+
const map = {
|
|
35
|
+
400: 'Bad Request',
|
|
36
|
+
401: 'Unauthorized',
|
|
37
|
+
403: 'Forbidden',
|
|
38
|
+
404: 'Not Found',
|
|
39
|
+
408: 'Request Timeout',
|
|
40
|
+
429: 'Too Many Requests',
|
|
41
|
+
500: 'Internal Server Error',
|
|
42
|
+
502: 'Bad Gateway',
|
|
43
|
+
503: 'Service Unavailable',
|
|
44
|
+
504: 'Gateway Timeout',
|
|
45
|
+
}
|
|
46
|
+
return map[code] ?? 'Error'
|
|
47
|
+
}
|