@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 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
+ }