@avelor/mesh 0.2.1 → 0.4.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
@@ -2,6 +2,8 @@
2
2
 
3
3
  Local dev proxy. Named services instead of ports. Failure injection without mocks.
4
4
 
5
+ ![demo](demo.gif)
6
+
5
7
  ```
6
8
  app.test → :3000
7
9
  api.test → :4000
@@ -26,10 +28,10 @@ Requires Node.js 18+. For HTTPS, install [mkcert](https://github.com/FiloSottile
26
28
 
27
29
  ```bash
28
30
  mesh init # create mesh.yml in current directory
29
- sudo mesh start # start proxy in background
31
+ mesh start # start proxy in background (will prompt for sudo)
30
32
  mesh status # show running services
31
- sudo mesh stop # stop the proxy
32
- sudo mesh route # start in foreground (useful for debugging)
33
+ mesh stop # stop the proxy
34
+ mesh route # start in foreground (useful for debugging)
33
35
  ```
34
36
 
35
37
  `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.
@@ -136,7 +138,7 @@ cd examples/basic
136
138
  node app.js
137
139
 
138
140
  # terminal 3 (from examples/basic)
139
- sudo mesh route
141
+ mesh route
140
142
  ```
141
143
 
142
144
  Then open `https://app.test`.
package/bin/mesh.js CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { watch, existsSync, readFileSync, writeFileSync, unlinkSync } from 'fs'
3
- import { spawn } from 'child_process'
3
+ import { spawn, spawnSync } from 'child_process'
4
4
  import { dirname, resolve } from 'path'
5
5
  import { createRequire } from 'module'
6
6
  import { loadConfig } from '../src/config.js'
@@ -8,9 +8,18 @@ import { writeHosts, removeHosts } from '../src/hosts.js'
8
8
  import { startProxy } from '../src/proxy.js'
9
9
  import { ensureCerts } from '../src/certs.js'
10
10
  import { init } from '../src/init.js'
11
+ import { findFreePort, startServices } from '../src/services.js'
11
12
 
12
13
  const STATE_FILE = '/tmp/.mesh.json'
13
14
 
15
+ // Re-exec the current command under sudo if not already root.
16
+ // Uses sudo -E to preserve PATH so mkcert and other tools remain findable.
17
+ function autoSudo() {
18
+ if (process.getuid?.() === 0) return
19
+ const result = spawnSync('sudo', ['-E', process.execPath, ...process.argv.slice(1)], { stdio: 'inherit' })
20
+ process.exit(result.status ?? 1)
21
+ }
22
+
14
23
  const RESET = '\x1b[0m'
15
24
  const DIM = '\x1b[2m'
16
25
  const GREEN = '\x1b[32m'
@@ -42,16 +51,12 @@ if (cmd === 'start') {
42
51
  let alive = false
43
52
  try { process.kill(state.pid, 0); alive = true } catch (e) { if (e.code === 'EPERM') alive = true }
44
53
  if (alive) {
45
- console.error(`mesh: already running (pid ${state.pid}) — run sudo mesh stop first`)
54
+ console.error(`mesh: already running (pid ${state.pid}) — run mesh stop first`)
46
55
  process.exit(1)
47
56
  }
48
57
  } catch { /* not running */ }
49
58
 
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
- }
59
+ autoSudo()
55
60
 
56
61
  const forwardArgs = args.filter(a => a !== 'start')
57
62
  const child = spawn(process.execPath, [process.argv[1], 'route', ...forwardArgs], {
@@ -103,8 +108,7 @@ if (cmd === 'stop') {
103
108
  console.log(`mesh: stopped (pid ${state.pid})`)
104
109
  } catch (err) {
105
110
  if (err.code === 'EPERM') {
106
- console.error(`mesh: permission denied — try: sudo mesh stop`)
107
- process.exit(1)
111
+ autoSudo()
108
112
  }
109
113
  if (err.code === 'ESRCH') {
110
114
  try { unlinkSync(STATE_FILE) } catch {}
@@ -165,97 +169,116 @@ if (cmd === 'status') {
165
169
 
166
170
  if (cmd !== 'route') {
167
171
  console.error('Usage:')
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)')
172
+ console.error(' mesh init create mesh.yml in current directory')
173
+ console.error(' mesh start start proxy in background')
174
+ console.error(' mesh start --config <path> use a specific config file')
175
+ console.error(' mesh stop stop the background proxy')
176
+ console.error(' mesh status show running services')
177
+ console.error(' mesh route start proxy in foreground (debug)')
174
178
  process.exit(1)
175
179
  }
176
180
 
177
- if (process.getuid?.() !== 0) {
178
- console.error('mesh: requires sudo to write /etc/hosts and bind ports 80/443')
179
- console.error(' both are cleaned up automatically on exit')
180
- console.error(' sudo mesh route')
181
- process.exit(1)
182
- }
181
+ autoSudo()
183
182
 
184
- // ── Load config ───────────────────────────────────────────────────────────────
183
+ // ── Load config + start managed services ─────────────────────────────────────
185
184
 
186
- let config
187
- try {
188
- config = loadConfig(configArg ?? process.cwd())
189
- } catch (err) {
190
- console.error('mesh:', err.message)
191
- process.exit(1)
192
- }
185
+ ;(async () => {
186
+ let config
187
+ try {
188
+ config = loadConfig(configArg ?? process.cwd())
189
+ } catch (err) {
190
+ console.error('mesh:', err.message)
191
+ process.exit(1)
192
+ }
193
193
 
194
- const { services, rules, configPath } = config
194
+ const { services: rawServices, rules, configPath } = config
195
195
 
196
- writeHosts(services)
196
+ // Split into managed (command string) and static (port number) services.
197
+ // For managed services, find a guaranteed-free port and inject it via PORT env.
198
+ const managed = {} // { [name]: { command, port } }
199
+ const services = {} // { [name]: port } — always numbers from here on
197
200
 
198
- const configDir = dirname(resolve(configPath))
199
- const certs = ensureCerts(services, configDir)
200
- const servers = startProxy(services, rules, certs)
201
+ for (const [name, value] of Object.entries(rawServices)) {
202
+ if (typeof value === 'string') {
203
+ const port = await findFreePort()
204
+ managed[name] = { command: value, port }
205
+ services[name] = port
206
+ } else {
207
+ services[name] = value
208
+ }
209
+ }
201
210
 
202
- writeFileSync(STATE_FILE, JSON.stringify({ pid: process.pid, configPath, services, rules, https: !!certs }))
211
+ const serviceManager = startServices(managed)
203
212
 
204
- // ── Crash safety — clean /etc/hosts even on unexpected exit ───────────────────
213
+ writeHosts(services)
205
214
 
206
- function shutdown(code = 0) {
207
- removeHosts()
208
- try { unlinkSync(STATE_FILE) } catch {}
209
- servers.http.close()
210
- servers.https?.close()
211
- process.exit(code)
212
- }
215
+ const configDir = dirname(resolve(configPath))
216
+ const certs = ensureCerts(services, configDir)
217
+ const servers = startProxy(services, rules, certs)
213
218
 
214
- process.on('SIGINT', () => { console.log('\n mesh cleaning up...'); shutdown(0) })
215
- process.on('SIGTERM', () => shutdown(0))
219
+ writeFileSync(STATE_FILE, JSON.stringify({ pid: process.pid, configPath, services, rules, https: !!certs }))
216
220
 
217
- process.on('uncaughtException', err => {
218
- console.error('\n mesh uncaught exception:', err.message)
219
- shutdown(1)
220
- })
221
+ // ── Crash safety — clean /etc/hosts even on unexpected exit ─────────────────
221
222
 
222
- process.on('unhandledRejection', err => {
223
- console.error('\n mesh unhandled rejection:', err?.message ?? err)
224
- shutdown(1)
225
- })
223
+ function shutdown(code = 0) {
224
+ serviceManager.stop()
225
+ removeHosts()
226
+ try { unlinkSync(STATE_FILE) } catch {}
227
+ servers.http.close()
228
+ servers.https?.close()
229
+ process.exit(code)
230
+ }
226
231
 
227
- // ── Hot-reload ────────────────────────────────────────────────────────────────
232
+ process.on('SIGINT', () => { console.log('\n mesh cleaning up...'); shutdown(0) })
233
+ process.on('SIGTERM', () => shutdown(0))
228
234
 
229
- let reloadTimer
230
- watch(configPath, () => {
231
- clearTimeout(reloadTimer)
232
- reloadTimer = setTimeout(() => {
233
- try {
234
- const next = loadConfig(configPath)
235
+ process.on('uncaughtException', err => {
236
+ console.error('\n mesh uncaught exception:', err.message)
237
+ shutdown(1)
238
+ })
235
239
 
236
- const hasNewServices = Object.keys(next.services).some(k => !services[k])
240
+ process.on('unhandledRejection', err => {
241
+ console.error('\n mesh unhandled rejection:', err?.message ?? err)
242
+ shutdown(1)
243
+ })
244
+
245
+ // ── Hot-reload ───────────────────────────────────────────────────────────────
246
+ // Managed service ports are fixed at startup — only static ports and rules reload.
237
247
 
238
- Object.keys(services).forEach(k => delete services[k])
239
- Object.assign(services, next.services)
240
- Object.keys(rules).forEach(k => delete rules[k])
241
- Object.assign(rules, next.rules)
248
+ let reloadTimer
249
+ watch(configPath, () => {
250
+ clearTimeout(reloadTimer)
251
+ reloadTimer = setTimeout(() => {
252
+ try {
253
+ const next = loadConfig(configPath)
242
254
 
243
- writeHosts(services)
244
- writeFileSync(STATE_FILE, JSON.stringify({ pid: process.pid, configPath, services, rules, https: !!certs }))
255
+ const hasNewServices = Object.keys(next.services).some(k => !services[k])
245
256
 
246
- if (certs && hasNewServices) {
247
- const newCerts = ensureCerts(services, configDir)
248
- if (newCerts && servers.https) {
249
- servers.https.setSecureContext({
250
- cert: readFileSync(newCerts.certFile),
251
- key: readFileSync(newCerts.keyFile),
252
- })
257
+ Object.keys(services).forEach(k => delete services[k])
258
+ for (const [name, value] of Object.entries(next.services)) {
259
+ // Preserve the already-assigned port for managed services
260
+ services[name] = managed[name]?.port ?? (typeof value === 'number' ? value : services[name])
261
+ }
262
+ Object.keys(rules).forEach(k => delete rules[k])
263
+ Object.assign(rules, next.rules)
264
+
265
+ writeHosts(services)
266
+ writeFileSync(STATE_FILE, JSON.stringify({ pid: process.pid, configPath, services, rules, https: !!certs }))
267
+
268
+ if (certs && hasNewServices) {
269
+ const newCerts = ensureCerts(services, configDir)
270
+ if (newCerts && servers.https) {
271
+ servers.https.setSecureContext({
272
+ cert: readFileSync(newCerts.certFile),
273
+ key: readFileSync(newCerts.keyFile),
274
+ })
275
+ }
253
276
  }
254
- }
255
277
 
256
- console.log('\n mesh config reloaded\n')
257
- } catch (err) {
258
- console.error('\n mesh config reload failed:', err.message, '\n')
259
- }
260
- }, 100)
261
- })
278
+ console.log('\n mesh config reloaded\n')
279
+ } catch (err) {
280
+ console.error('\n mesh config reload failed:', err.message, '\n')
281
+ }
282
+ }, 100)
283
+ })
284
+ })()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@avelor/mesh",
3
- "version": "0.2.1",
3
+ "version": "0.4.0",
4
4
  "description": "Local dev proxy. Named services, failure rules, no ports.",
5
5
  "keywords": [
6
6
  "proxy",
package/src/certs.js CHANGED
@@ -1,7 +1,18 @@
1
1
  import { execFileSync, spawnSync } from 'child_process'
2
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
2
+ import { chownSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
3
3
  import { resolve } from 'path'
4
4
 
5
+ // When running under sudo, restore file ownership to the invoking user
6
+ // so that .mesh/ doesn't end up owned by root.
7
+ function fixOwnership(...paths) {
8
+ const uid = parseInt(process.env.SUDO_UID ?? '')
9
+ const gid = parseInt(process.env.SUDO_GID ?? '')
10
+ if (!uid || !gid) return
11
+ for (const p of paths) {
12
+ try { chownSync(p, uid, gid) } catch {}
13
+ }
14
+ }
15
+
5
16
  function mkcertInstalled() {
6
17
  return spawnSync('which', ['mkcert']).status === 0
7
18
  }
@@ -37,13 +48,15 @@ export function ensureCerts(services, cwd = process.cwd()) {
37
48
  const keyFile = resolve(dir, 'key.pem')
38
49
 
39
50
  mkdirSync(dir, { recursive: true })
51
+ fixOwnership(dir)
40
52
 
41
53
  if (!caInstalled()) {
42
54
  execFileSync('mkcert', ['-install'], { stdio: 'ignore' })
43
55
  }
44
56
 
45
- const domains = Object.keys(services).map(n => `${n}.test`).sort()
46
- const cached = cachedDomains(dir).sort()
57
+ const domains = Object.keys(services).map(n => `${n}.test`).sort()
58
+ const cached = cachedDomains(dir).sort()
59
+ const domainsFile = resolve(dir, 'domains.json')
47
60
 
48
61
  const needsRegen = JSON.stringify(domains) !== JSON.stringify(cached)
49
62
  || !existsSync(certFile)
@@ -51,7 +64,8 @@ export function ensureCerts(services, cwd = process.cwd()) {
51
64
 
52
65
  if (needsRegen) {
53
66
  execFileSync('mkcert', ['-cert-file', certFile, '-key-file', keyFile, ...domains], { stdio: 'ignore' })
54
- writeFileSync(resolve(dir, 'domains.json'), JSON.stringify(domains))
67
+ writeFileSync(domainsFile, JSON.stringify(domains))
68
+ fixOwnership(certFile, keyFile, domainsFile)
55
69
  }
56
70
 
57
71
  return { certFile, keyFile }
package/src/config.js CHANGED
@@ -33,11 +33,17 @@ export function loadConfig(cwdOrFile = process.cwd()) {
33
33
  if (!NAME_RE.test(name)) {
34
34
  throw new Error(`invalid service name "${name}" — only a-z, 0-9, hyphens and dots allowed`)
35
35
  }
36
- const port = Number(raw)
37
- if (!Number.isInteger(port) || port < 1 || port > 65535) {
36
+ if (typeof raw === 'string') {
37
+ if (!raw.trim()) throw new Error(`service "${name}" command cannot be empty`)
38
+ services[name] = raw.trim()
39
+ } else if (typeof raw === 'number') {
40
+ if (!Number.isInteger(raw) || raw < 1 || raw > 65535) {
41
+ throw new Error(`invalid port for service "${name}": ${raw}`)
42
+ }
43
+ services[name] = raw
44
+ } else {
38
45
  throw new Error(`invalid port for service "${name}": ${raw}`)
39
46
  }
40
- services[name] = port
41
47
  }
42
48
 
43
49
  const rules = {}
package/src/init.js CHANGED
@@ -39,5 +39,5 @@ export function init(cwd = process.cwd()) {
39
39
  }
40
40
  }
41
41
 
42
- console.log(' edit your services and run: sudo mesh route')
42
+ console.log(' edit your services and run: mesh route')
43
43
  }
package/src/proxy.js CHANGED
@@ -6,20 +6,36 @@ import httpProxy from 'http-proxy'
6
6
  import { matchRule, applyRule } from './rules.js'
7
7
  import { wantsHtml, errorPage } from './error-page.js'
8
8
 
9
- const hostCache = new Map()
9
+ // Maps configuredPort { host, port } — cleared on ECONNREFUSED so next
10
+ // request re-discovers if the service moved to a different port.
11
+ const endpointCache = new Map()
12
+ const SCAN_RANGE = 10
10
13
 
11
- function probeHost(port) {
12
- if (hostCache.has(port)) return Promise.resolve(hostCache.get(port))
14
+ function probePort(port) {
13
15
  const probe = addr => new Promise((resolve, reject) => {
14
16
  const s = net.connect(port, addr)
15
- s.setTimeout(200)
17
+ s.setTimeout(100)
16
18
  s.on('connect', () => { s.destroy(); resolve(addr) })
17
- s.on('timeout', () => { s.destroy(); reject(new Error('timeout')) })
19
+ s.on('timeout', () => { s.destroy(); reject() })
18
20
  s.on('error', reject)
19
21
  })
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')
22
+ return Promise.any([probe('127.0.0.1'), probe('::1')]).catch(() => null)
23
+ }
24
+
25
+ async function resolveEndpoint(configuredPort) {
26
+ const cached = endpointCache.get(configuredPort)
27
+ if (cached) return cached
28
+
29
+ // Probe configured port and the next SCAN_RANGE ports concurrently.
30
+ // results preserves order so .find() returns the closest open port.
31
+ const candidates = Array.from({ length: SCAN_RANGE + 1 }, (_, i) => configuredPort + i)
32
+ const results = await Promise.all(
33
+ candidates.map(port => probePort(port).then(host => host ? { host, port } : null))
34
+ )
35
+
36
+ const found = results.find(r => r !== null) ?? { host: '127.0.0.1', port: configuredPort }
37
+ endpointCache.set(configuredPort, found)
38
+ return found
23
39
  }
24
40
 
25
41
  function fmtHost(host) {
@@ -38,6 +54,9 @@ export function startProxy(services, rules, certs = null) {
38
54
 
39
55
  proxy.on('error', (err, req, res) => {
40
56
  const { name, target } = resolveService(req.headers.host)
57
+ if (err.code === 'ECONNREFUSED' || err.code === 'ECONNRESET') {
58
+ endpointCache.delete(target)
59
+ }
41
60
  log(RED, 'ERR', name, req.url, `→ ${err.code ?? err.message}`)
42
61
  if (!res.headersSent) {
43
62
  const protocol = certs ? 'https' : 'http'
@@ -86,19 +105,24 @@ export function startProxy(services, rules, certs = null) {
86
105
  log(YELLOW, `${rule.delay}ms`, name, pathname, `→ :${target} (delayed)`)
87
106
  }
88
107
 
89
- const host = await probeHost(target)
90
- proxy.web(req, res, { target: `http://${fmtHost(host)}:${target}` })
91
- if (!rule) log(DIM, '→', name, pathname, `→ :${target}`)
108
+ const endpoint = await resolveEndpoint(target)
109
+ proxy.web(req, res, { target: `http://${fmtHost(endpoint.host)}:${endpoint.port}` })
110
+ if (!rule) {
111
+ const portLabel = endpoint.port !== target
112
+ ? `:${endpoint.port} ${DIM}(shifted from :${target})${RESET}`
113
+ : `:${target}`
114
+ log(DIM, '→', name, pathname, `→ ${portLabel}`)
115
+ }
92
116
  }
93
117
 
94
118
  async function handleUpgrade(req, socket, head) {
95
119
  const { name, target } = resolveService(req.headers.host)
96
120
  if (!target) { socket.destroy(); return }
97
- const host = await probeHost(target)
98
- proxy.ws(req, socket, head, { target: `ws://${fmtHost(host)}:${target}` }, err => {
121
+ const endpoint = await resolveEndpoint(target)
122
+ proxy.ws(req, socket, head, { target: `ws://${fmtHost(endpoint.host)}:${endpoint.port}` }, err => {
99
123
  if (err) log(RED, 'WSE', name, req.url, `→ ${err.code ?? err.message}`)
100
124
  })
101
- log(DIM, 'WS', name, req.url, `→ :${target}`)
125
+ log(DIM, 'WS', name, req.url, `→ :${endpoint.port}`)
102
126
  }
103
127
 
104
128
  const httpServer = http.createServer((req, res) => {
@@ -0,0 +1,91 @@
1
+ import { spawn } from 'child_process'
2
+ import net from 'net'
3
+
4
+ const RESET = '\x1b[0m'
5
+ const DIM = '\x1b[2m'
6
+ const CYAN = '\x1b[36m'
7
+ const YELLOW = '\x1b[33m'
8
+ const RED = '\x1b[31m'
9
+
10
+ export function findFreePort() {
11
+ return new Promise((resolve, reject) => {
12
+ const server = net.createServer()
13
+ server.listen(0, '127.0.0.1', () => {
14
+ const { port } = server.address()
15
+ server.close(() => resolve(port))
16
+ })
17
+ server.on('error', reject)
18
+ })
19
+ }
20
+
21
+ export function startServices(managed) {
22
+ // managed: { [name]: { command: string, port: number } }
23
+ if (!Object.keys(managed).length) return { stop() {} }
24
+
25
+ let stopping = false
26
+ const children = new Map()
27
+
28
+ function logLine(name, line) {
29
+ if (!line.trim()) return
30
+ const time = new Date().toTimeString().slice(0, 8)
31
+ process.stdout.write(` ${DIM}${time}${RESET} ${CYAN}${name}${RESET} ${DIM}│${RESET} ${line}\n`)
32
+ }
33
+
34
+ function launch(name, command, port, restartCount = 0, startedAt = Date.now()) {
35
+ const child = spawn(command, [], {
36
+ env: { ...process.env, PORT: String(port), HOST: '127.0.0.1' },
37
+ shell: true,
38
+ stdio: ['ignore', 'pipe', 'pipe'],
39
+ })
40
+
41
+ children.set(name, child)
42
+
43
+ let buffer = ''
44
+ function flush(chunk) {
45
+ buffer += String(chunk)
46
+ const lines = buffer.split('\n')
47
+ buffer = lines.pop()
48
+ for (const line of lines) logLine(name, line)
49
+ }
50
+
51
+ child.stdout.on('data', flush)
52
+ child.stderr.on('data', flush)
53
+
54
+ child.on('exit', (code, signal) => {
55
+ if (buffer.trim()) logLine(name, buffer)
56
+ buffer = ''
57
+ if (stopping) return
58
+
59
+ const uptime = Date.now() - startedAt
60
+ const nextCount = uptime > 10_000 ? 0 : restartCount + 1
61
+ const delay = Math.min(1000 * 2 ** nextCount, 10_000)
62
+ const time = new Date().toTimeString().slice(0, 8)
63
+
64
+ console.log(
65
+ ` ${DIM}${time}${RESET} ${RED}${name}${RESET} ` +
66
+ `${DIM}exited (${code ?? signal}) — restarting in ${delay / 1000}s${RESET}`
67
+ )
68
+
69
+ setTimeout(() => {
70
+ if (!stopping) launch(name, command, port, nextCount, Date.now())
71
+ }, delay)
72
+ })
73
+
74
+ const time = new Date().toTimeString().slice(0, 8)
75
+ const label = restartCount === 0 ? CYAN : YELLOW
76
+ console.log(` ${DIM}${time}${RESET} ${label}${name}${RESET} ${DIM}→ :${port} $ ${command}${RESET}`)
77
+ }
78
+
79
+ for (const [name, { command, port }] of Object.entries(managed)) {
80
+ launch(name, command, port)
81
+ }
82
+
83
+ return {
84
+ stop() {
85
+ stopping = true
86
+ for (const child of children.values()) {
87
+ try { child.kill('SIGTERM') } catch {}
88
+ }
89
+ },
90
+ }
91
+ }