@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 +6 -4
- package/bin/mesh.js +104 -81
- package/package.json +1 -1
- package/src/certs.js +18 -4
- 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
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
Local dev proxy. Named services instead of ports. Failure injection without mocks.
|
|
4
4
|
|
|
5
|
+

|
|
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
|
-
|
|
31
|
+
mesh start # start proxy in background (will prompt for sudo)
|
|
30
32
|
mesh status # show running services
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
|
|
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 }
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
169
|
-
console.error('
|
|
170
|
-
console.error('
|
|
171
|
-
console.error('
|
|
172
|
-
console.error(' mesh status
|
|
173
|
-
console.error('
|
|
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
|
-
|
|
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
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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
|
-
|
|
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
|
|
199
|
-
|
|
200
|
-
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
|
+
}
|
|
201
210
|
|
|
202
|
-
|
|
211
|
+
const serviceManager = startServices(managed)
|
|
203
212
|
|
|
204
|
-
|
|
213
|
+
writeHosts(services)
|
|
205
214
|
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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
|
-
|
|
215
|
-
process.on('SIGTERM', () => shutdown(0))
|
|
219
|
+
writeFileSync(STATE_FILE, JSON.stringify({ pid: process.pid, configPath, services, rules, https: !!certs }))
|
|
216
220
|
|
|
217
|
-
|
|
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
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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
|
-
|
|
232
|
+
process.on('SIGINT', () => { console.log('\n mesh cleaning up...'); shutdown(0) })
|
|
233
|
+
process.on('SIGTERM', () => shutdown(0))
|
|
228
234
|
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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
|
-
|
|
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
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
248
|
+
let reloadTimer
|
|
249
|
+
watch(configPath, () => {
|
|
250
|
+
clearTimeout(reloadTimer)
|
|
251
|
+
reloadTimer = setTimeout(() => {
|
|
252
|
+
try {
|
|
253
|
+
const next = loadConfig(configPath)
|
|
242
254
|
|
|
243
|
-
|
|
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
|
-
|
|
247
|
-
const
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
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
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
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
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
|
|
46
|
-
const cached
|
|
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(
|
|
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
|
-
|
|
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
|
+
}
|