@avelor/mesh 0.3.0 → 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
@@ -28,10 +28,10 @@ Requires Node.js 18+. For HTTPS, install [mkcert](https://github.com/FiloSottile
28
28
 
29
29
  ```bash
30
30
  mesh init # create mesh.yml in current directory
31
- sudo mesh start # start proxy in background
31
+ mesh start # start proxy in background (will prompt for sudo)
32
32
  mesh status # show running services
33
- sudo mesh stop # stop the proxy
34
- sudo mesh route # start in foreground (useful for debugging)
33
+ mesh stop # stop the proxy
34
+ mesh route # start in foreground (useful for debugging)
35
35
  ```
36
36
 
37
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.
@@ -138,7 +138,7 @@ cd examples/basic
138
138
  node app.js
139
139
 
140
140
  # terminal 3 (from examples/basic)
141
- sudo mesh route
141
+ mesh route
142
142
  ```
143
143
 
144
144
  Then open `https://app.test`.
package/bin/mesh.js CHANGED
@@ -8,6 +8,7 @@ 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
 
@@ -179,81 +180,105 @@ if (cmd !== 'route') {
179
180
 
180
181
  autoSudo()
181
182
 
182
- // ── Load config ───────────────────────────────────────────────────────────────
183
+ // ── Load config + start managed services ─────────────────────────────────────
183
184
 
184
- let config
185
- try {
186
- config = loadConfig(configArg ?? process.cwd())
187
- } catch (err) {
188
- console.error('mesh:', err.message)
189
- process.exit(1)
190
- }
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
+ }
191
193
 
192
- const { services, rules, configPath } = config
194
+ const { services: rawServices, rules, configPath } = config
193
195
 
194
- 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
195
200
 
196
- const configDir = dirname(resolve(configPath))
197
- const certs = ensureCerts(services, configDir)
198
- 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
+ }
199
210
 
200
- writeFileSync(STATE_FILE, JSON.stringify({ pid: process.pid, configPath, services, rules, https: !!certs }))
211
+ const serviceManager = startServices(managed)
201
212
 
202
- // ── Crash safety — clean /etc/hosts even on unexpected exit ───────────────────
213
+ writeHosts(services)
203
214
 
204
- function shutdown(code = 0) {
205
- removeHosts()
206
- try { unlinkSync(STATE_FILE) } catch {}
207
- servers.http.close()
208
- servers.https?.close()
209
- process.exit(code)
210
- }
215
+ const configDir = dirname(resolve(configPath))
216
+ const certs = ensureCerts(services, configDir)
217
+ const servers = startProxy(services, rules, certs)
211
218
 
212
- process.on('SIGINT', () => { console.log('\n mesh cleaning up...'); shutdown(0) })
213
- process.on('SIGTERM', () => shutdown(0))
219
+ writeFileSync(STATE_FILE, JSON.stringify({ pid: process.pid, configPath, services, rules, https: !!certs }))
214
220
 
215
- process.on('uncaughtException', err => {
216
- console.error('\n mesh uncaught exception:', err.message)
217
- shutdown(1)
218
- })
221
+ // ── Crash safety — clean /etc/hosts even on unexpected exit ─────────────────
219
222
 
220
- process.on('unhandledRejection', err => {
221
- console.error('\n mesh unhandled rejection:', err?.message ?? err)
222
- shutdown(1)
223
- })
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
+ }
224
231
 
225
- // ── Hot-reload ────────────────────────────────────────────────────────────────
232
+ process.on('SIGINT', () => { console.log('\n mesh cleaning up...'); shutdown(0) })
233
+ process.on('SIGTERM', () => shutdown(0))
226
234
 
227
- let reloadTimer
228
- watch(configPath, () => {
229
- clearTimeout(reloadTimer)
230
- reloadTimer = setTimeout(() => {
231
- try {
232
- const next = loadConfig(configPath)
235
+ process.on('uncaughtException', err => {
236
+ console.error('\n mesh uncaught exception:', err.message)
237
+ shutdown(1)
238
+ })
239
+
240
+ process.on('unhandledRejection', err => {
241
+ console.error('\n mesh unhandled rejection:', err?.message ?? err)
242
+ shutdown(1)
243
+ })
233
244
 
234
- const hasNewServices = Object.keys(next.services).some(k => !services[k])
245
+ // ── Hot-reload ───────────────────────────────────────────────────────────────
246
+ // Managed service ports are fixed at startup — only static ports and rules reload.
235
247
 
236
- Object.keys(services).forEach(k => delete services[k])
237
- Object.assign(services, next.services)
238
- Object.keys(rules).forEach(k => delete rules[k])
239
- Object.assign(rules, next.rules)
248
+ let reloadTimer
249
+ watch(configPath, () => {
250
+ clearTimeout(reloadTimer)
251
+ reloadTimer = setTimeout(() => {
252
+ try {
253
+ const next = loadConfig(configPath)
240
254
 
241
- writeHosts(services)
242
- writeFileSync(STATE_FILE, JSON.stringify({ pid: process.pid, configPath, services, rules, https: !!certs }))
255
+ const hasNewServices = Object.keys(next.services).some(k => !services[k])
243
256
 
244
- if (certs && hasNewServices) {
245
- const newCerts = ensureCerts(services, configDir)
246
- if (newCerts && servers.https) {
247
- servers.https.setSecureContext({
248
- cert: readFileSync(newCerts.certFile),
249
- key: readFileSync(newCerts.keyFile),
250
- })
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
+ }
251
276
  }
252
- }
253
277
 
254
- console.log('\n mesh config reloaded\n')
255
- } catch (err) {
256
- console.error('\n mesh config reload failed:', err.message, '\n')
257
- }
258
- }, 100)
259
- })
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.3.0",
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/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
+ }