@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 +4 -4
- package/bin/mesh.js +85 -60
- package/package.json +1 -1
- package/src/config.js +9 -3
- package/src/init.js +1 -1
- package/src/proxy.js +38 -14
- package/src/services.js +91 -0
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
|
-
|
|
31
|
+
mesh start # start proxy in background (will prompt for sudo)
|
|
32
32
|
mesh status # show running services
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
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
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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
|
-
|
|
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
|
|
197
|
-
|
|
198
|
-
const
|
|
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
|
-
|
|
211
|
+
const serviceManager = startServices(managed)
|
|
201
212
|
|
|
202
|
-
|
|
213
|
+
writeHosts(services)
|
|
203
214
|
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
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
|
-
|
|
213
|
-
process.on('SIGTERM', () => shutdown(0))
|
|
219
|
+
writeFileSync(STATE_FILE, JSON.stringify({ pid: process.pid, configPath, services, rules, https: !!certs }))
|
|
214
220
|
|
|
215
|
-
|
|
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
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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
|
-
|
|
232
|
+
process.on('SIGINT', () => { console.log('\n mesh cleaning up...'); shutdown(0) })
|
|
233
|
+
process.on('SIGTERM', () => shutdown(0))
|
|
226
234
|
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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
|
-
|
|
245
|
+
// ── Hot-reload ───────────────────────────────────────────────────────────────
|
|
246
|
+
// Managed service ports are fixed at startup — only static ports and rules reload.
|
|
235
247
|
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
248
|
+
let reloadTimer
|
|
249
|
+
watch(configPath, () => {
|
|
250
|
+
clearTimeout(reloadTimer)
|
|
251
|
+
reloadTimer = setTimeout(() => {
|
|
252
|
+
try {
|
|
253
|
+
const next = loadConfig(configPath)
|
|
240
254
|
|
|
241
|
-
|
|
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
|
-
|
|
245
|
-
const
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
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
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
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
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
|
-
|
|
37
|
-
|
|
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
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
|
-
|
|
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
|
|
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(
|
|
17
|
+
s.setTimeout(100)
|
|
16
18
|
s.on('connect', () => { s.destroy(); resolve(addr) })
|
|
17
|
-
s.on('timeout', () => { s.destroy(); reject(
|
|
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
|
-
|
|
22
|
-
|
|
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
|
|
90
|
-
proxy.web(req, res, { target: `http://${fmtHost(host)}:${
|
|
91
|
-
if (!rule)
|
|
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
|
|
98
|
-
proxy.ws(req, socket, head, { target: `ws://${fmtHost(host)}:${
|
|
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, `→ :${
|
|
125
|
+
log(DIM, 'WS', name, req.url, `→ :${endpoint.port}`)
|
|
102
126
|
}
|
|
103
127
|
|
|
104
128
|
const httpServer = http.createServer((req, res) => {
|
package/src/services.js
ADDED
|
@@ -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
|
+
}
|