rollbridge 0.1.2 → 0.1.4

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/src/doctor.js ADDED
@@ -0,0 +1,114 @@
1
+ // @ts-check
2
+
3
+ import {constants as fsConstants} from "node:fs"
4
+ import fs from "node:fs/promises"
5
+ import net from "node:net"
6
+ import path from "node:path"
7
+ import {inspectControlSocket} from "./daemon.js"
8
+
9
+ /**
10
+ * @typedef {{detail: string, name: string, ok: boolean}} DoctorCheck
11
+ */
12
+
13
+ /**
14
+ * Runs pre-flight environment checks for a validated config: control socket
15
+ * reachability, control-socket directory writability, and proxy port availability.
16
+ * @param {import("./config.js").RollbridgeConfig} config - Normalized config.
17
+ * @returns {Promise<DoctorCheck[]>} One check per probed aspect.
18
+ */
19
+ export async function runEnvironmentChecks(config) {
20
+ /** @type {DoctorCheck[]} */
21
+ const checks = []
22
+ const socketInspection = await inspectControlSocketSafely(config.control.path)
23
+
24
+ checks.push(controlSocketCheck(config, socketInspection))
25
+ checks.push(await controlSocketDirectoryCheck(config))
26
+ checks.push(await proxyPortCheck(config))
27
+
28
+ return checks
29
+ }
30
+
31
+ /**
32
+ * @param {string} socketPath - Control socket path.
33
+ * @returns {Promise<{alive: boolean, application?: string} | {error: string}>} Probe result, or the probe error.
34
+ */
35
+ async function inspectControlSocketSafely(socketPath) {
36
+ try {
37
+ return await inspectControlSocket(socketPath)
38
+ } catch (error) {
39
+ return {error: error instanceof Error ? error.message : String(error)}
40
+ }
41
+ }
42
+
43
+ /**
44
+ * @param {import("./config.js").RollbridgeConfig} config - Normalized config.
45
+ * @param {{alive: boolean, application?: string} | {error: string}} inspection - Control socket probe result.
46
+ * @returns {DoctorCheck} Control socket reachability check.
47
+ */
48
+ function controlSocketCheck(config, inspection) {
49
+ const socketPath = config.control.path
50
+
51
+ if ("error" in inspection) {
52
+ return {detail: `could not probe ${socketPath}: ${inspection.error}`, name: "control socket", ok: false}
53
+ }
54
+
55
+ if (!inspection.alive) {
56
+ return {detail: `no daemon running; ${socketPath} is free to bind`, name: "control socket", ok: true}
57
+ }
58
+
59
+ if (inspection.application === undefined) {
60
+ return {detail: `another process is already listening on ${socketPath}; the daemon would fail to bind it`, name: "control socket", ok: false}
61
+ }
62
+
63
+ return {detail: `a Rollbridge daemon for "${inspection.application}" is already running on ${socketPath}; stop it before starting another`, name: "control socket", ok: false}
64
+ }
65
+
66
+ /**
67
+ * @param {import("./config.js").RollbridgeConfig} config - Normalized config.
68
+ * @returns {Promise<DoctorCheck>} Whether the control socket's directory is writable.
69
+ */
70
+ async function controlSocketDirectoryCheck(config) {
71
+ const directory = path.dirname(path.resolve(config.control.path))
72
+
73
+ try {
74
+ // Creating a Unix socket needs both write and search (execute) permission on the directory.
75
+ await fs.access(directory, fsConstants.W_OK | fsConstants.X_OK)
76
+
77
+ return {detail: `${directory} is writable`, name: "control socket directory", ok: true}
78
+ } catch {
79
+ return {detail: `${directory} is missing or not writable`, name: "control socket directory", ok: false}
80
+ }
81
+ }
82
+
83
+ /**
84
+ * @param {import("./config.js").RollbridgeConfig} config - Normalized config.
85
+ * @returns {Promise<DoctorCheck>} Whether the proxy port can be bound.
86
+ */
87
+ async function proxyPortCheck(config) {
88
+ const address = `${config.proxy.host}:${config.proxy.port}`
89
+ const bind = await canBindPort(config.proxy.host, config.proxy.port)
90
+
91
+ if (bind.ok) {
92
+ return {detail: `${address} is available`, name: "proxy port", ok: true}
93
+ }
94
+
95
+ return {detail: `${address} is unavailable (${bind.code})`, name: "proxy port", ok: false}
96
+ }
97
+
98
+ /**
99
+ * @param {string} host - Bind host.
100
+ * @param {number} port - Candidate port.
101
+ * @returns {Promise<{ok: true} | {code: string, ok: false}>} Whether the port can be bound.
102
+ */
103
+ async function canBindPort(host, port) {
104
+ return await new Promise((resolve) => {
105
+ const server = net.createServer()
106
+
107
+ server.once("error", (error) => {
108
+ const code = error && typeof error === "object" && "code" in error ? String(error.code) : "EUNKNOWN"
109
+
110
+ resolve({code, ok: false})
111
+ })
112
+ server.listen(port, host, () => server.close(() => resolve({ok: true})))
113
+ })
114
+ }
package/src/health.js CHANGED
@@ -9,6 +9,10 @@
9
9
  * @returns {Promise<void>} Resolves when healthy.
10
10
  */
11
11
  export async function waitForHealth({health, host, port}) {
12
+ if (health.startDelayMs > 0) {
13
+ await new Promise((resolve) => setTimeout(resolve, health.startDelayMs))
14
+ }
15
+
12
16
  const deadline = Date.now() + health.timeoutMs
13
17
  const url = `http://${host}:${port}${health.path}`
14
18
  let lastError = "no attempts"
@@ -9,7 +9,7 @@ import {spawn} from "node:child_process"
9
9
  * @typedef {import("node:child_process").ChildProcess["signalCode"]} ProcessExitSignal
10
10
  * @typedef {{at: string, line: string, stream: "stdout" | "stderr"}} ManagedProcessLog
11
11
  * @typedef {{command: string, cwd: string | undefined, env: Record<string, string | undefined>, logger: (message: string, data?: Record<string, import("./json.js").JsonValue>) => void, outputLines: number, restartDelayMs: number, shouldRestart: () => boolean, stopTimeoutMs: number}} ManagedProcessDefinition
12
- * @typedef {{command: string, cwd: string | undefined, exitCode: number | null | undefined, exitSignal: ProcessExitSignal | undefined, id: string, logs: ManagedProcessLog[], pid: number | undefined, state: ManagedProcessState}} ManagedProcessStatus
12
+ * @typedef {{command: string, cwd: string | undefined, exitCode: number | null | undefined, exitSignal: ProcessExitSignal | undefined, id: string, logs: ManagedProcessLog[], pid: number | undefined, restarts: number, startedAt: string | undefined, state: ManagedProcessState, uptimeMs: number | undefined}} ManagedProcessStatus
13
13
  */
14
14
 
15
15
  export default class ManagedProcess extends EventEmitter {
@@ -39,6 +39,8 @@ export default class ManagedProcess extends EventEmitter {
39
39
  this.stopTimeoutMs = stopTimeoutMs
40
40
  this.state = /** @type {ManagedProcessState} */ ("stopped")
41
41
  this.logs = /** @type {ManagedProcessLog[]} */ ([])
42
+ this.restarts = 0
43
+ this.startedAtMs = /** @type {number | undefined} */ (undefined)
42
44
  this.intentionalStop = false
43
45
  this.restartTimer = undefined
44
46
  this.child = undefined
@@ -77,6 +79,7 @@ export default class ManagedProcess extends EventEmitter {
77
79
 
78
80
  child.once("spawn", () => {
79
81
  this.state = "running"
82
+ this.startedAtMs = Date.now()
80
83
  this.logger("process started", {command: this.command, id: this.id, pid: child.pid || null})
81
84
  this.emit("started")
82
85
  resolve(undefined)
@@ -145,6 +148,7 @@ export default class ManagedProcess extends EventEmitter {
145
148
  if (!wasIntentional && this.shouldRestart()) {
146
149
  this.restartTimer = setTimeout(() => {
147
150
  this.restartTimer = undefined
151
+ this.restarts += 1
148
152
  this.start().catch((error) => {
149
153
  this.logger("process restart failed", {error: error instanceof Error ? error.message : String(error), id: this.id})
150
154
  })
@@ -229,7 +233,10 @@ export default class ManagedProcess extends EventEmitter {
229
233
  id: this.id,
230
234
  logs: this.logs.slice(-this.outputLines),
231
235
  pid: this.pid,
232
- state: this.state
236
+ restarts: this.restarts,
237
+ startedAt: this.startedAtMs === undefined ? undefined : new Date(this.startedAtMs).toISOString(),
238
+ state: this.state,
239
+ uptimeMs: this.state === "running" && this.startedAtMs !== undefined ? Date.now() - this.startedAtMs : undefined
233
240
  }
234
241
  }
235
242
  }
@@ -67,18 +67,46 @@ export default class ReleaseGroup extends EventEmitter {
67
67
  if (processConfig.policy === "proxied" && processConfig.port && processConfig.health) {
68
68
  await waitForHealth({
69
69
  health: processConfig.health,
70
- host: this.config.proxy.host,
70
+ host: this.config.proxy.upstreamHost,
71
71
  port: this.ports[processConfig.id]
72
72
  })
73
73
  }
74
74
  }
75
75
  } catch (error) {
76
76
  this.state = "failed"
77
+ this.logStartupFailure(error instanceof Error ? error : String(error))
77
78
  await this.stop()
78
79
  throw error
79
80
  }
80
81
  }
81
82
 
83
+ /**
84
+ * Logs process diagnostics before failed startup cleanup stops and removes the release processes.
85
+ * @param {Error | string} error - Startup failure.
86
+ * @returns {void}
87
+ */
88
+ logStartupFailure(error) {
89
+ this.logger("release startup failed", {
90
+ error: error instanceof Error ? error.message : error,
91
+ releaseId: this.releaseId
92
+ })
93
+
94
+ for (const processInstance of this.processes.values()) {
95
+ const status = processInstance.status()
96
+
97
+ this.logger("release startup process status", {
98
+ command: status.command,
99
+ exitCode: status.exitCode ?? null,
100
+ exitSignal: status.exitSignal ?? null,
101
+ logs: status.logs,
102
+ pid: status.pid ?? null,
103
+ processId: status.id,
104
+ releaseId: this.releaseId,
105
+ state: status.state
106
+ })
107
+ }
108
+ }
109
+
82
110
  /**
83
111
  * Starts companions before the proxied process so release-local dependencies are available before health checks.
84
112
  * @returns {import("./config.js").ProcessConfig[]} Ordered process configs.
@@ -112,7 +140,7 @@ export default class ReleaseGroup extends EventEmitter {
112
140
  }
113
141
 
114
142
  this.ports[processConfig.id] = await findAvailablePort({
115
- host: this.config.proxy.host,
143
+ host: this.config.proxy.upstreamHost,
116
144
  range: processConfig.port,
117
145
  usedPorts
118
146
  })
@@ -186,7 +214,8 @@ export default class ReleaseGroup extends EventEmitter {
186
214
  processId: processConfig.id,
187
215
  proxy: {
188
216
  host: this.config.proxy.host,
189
- port: this.config.proxy.port
217
+ port: this.config.proxy.port,
218
+ upstreamHost: this.config.proxy.upstreamHost
190
219
  },
191
220
  releaseId: this.releaseId,
192
221
  releasePath: this.releasePath,
@@ -211,7 +240,7 @@ export default class ReleaseGroup extends EventEmitter {
211
240
 
212
241
  return {
213
242
  process: processInstance,
214
- target: `http://${this.config.proxy.host}:${port}`
243
+ target: `http://${this.config.proxy.upstreamHost}:${port}`
215
244
  }
216
245
  }
217
246
 
@@ -55,6 +55,21 @@ test("validateConfig returns a normalized config and no issues for a valid confi
55
55
  assert.equal(config.proxy.port, 8182)
56
56
  })
57
57
 
58
+ test("validateConfig defaults wildcard proxy upstreams to loopback", () => {
59
+ const {config, issues} = validateConfig({
60
+ application: "demo",
61
+ control: {path: "/tmp/demo.sock"},
62
+ processes: [
63
+ {command: "run web", health: {path: "/ping"}, id: "web", policy: "proxied", port: {from: 18000, to: 18099}}
64
+ ],
65
+ proxy: {host: "0.0.0.0", port: 8182}
66
+ })
67
+
68
+ assert.deepEqual(issues, [])
69
+ assert.equal(config.proxy.host, "0.0.0.0")
70
+ assert.equal(config.proxy.upstreamHost, "127.0.0.1")
71
+ })
72
+
58
73
  test("validateConfig defaults outputLines and accepts a positive override", () => {
59
74
  const {config, issues} = validateConfig({
60
75
  application: "demo",
@@ -117,6 +132,62 @@ test("validateConfig parses control.mode, defaults it to unset, and rejects inva
117
132
  assert.ok(invalid.issues.some((issue) => issue.message === "control.mode must be an octal file mode between 0 and 0o777"))
118
133
  })
119
134
 
135
+ test("validateConfig defaults health.startDelayMs to 0, accepts an override, and rejects negatives", () => {
136
+ /**
137
+ * @param {import("../src/json.js").JsonValue} health - Health config under test, or undefined to omit it.
138
+ * @returns {{config: import("../src/config.js").RollbridgeConfig, issues: import("../src/config.js").ConfigIssue[]}} Validation result.
139
+ */
140
+ const validateHealth = (health) => validateConfig({
141
+ application: "demo",
142
+ control: {path: "/tmp/demo.sock"},
143
+ processes: [{command: "run web", health, id: "web", policy: "proxied", port: {from: 18000, to: 18099}}],
144
+ proxy: {host: "127.0.0.1", port: 8182}
145
+ })
146
+
147
+ const defaulted = validateHealth({path: "/ping"})
148
+
149
+ assert.deepEqual(defaulted.issues, [])
150
+ assert.equal(defaulted.config.processes[0].health?.startDelayMs, 0)
151
+
152
+ const custom = validateHealth({path: "/ping", startDelayMs: 2000})
153
+
154
+ assert.deepEqual(custom.issues, [])
155
+ assert.equal(custom.config.processes[0].health?.startDelayMs, 2000)
156
+
157
+ const negative = validateHealth({path: "/ping", startDelayMs: -1})
158
+
159
+ assert.ok(negative.issues.some((issue) => issue.message === "processes[0].health.startDelayMs must be a non-negative number"))
160
+ })
161
+
162
+ test("validateConfig defaults releaseRetention, accepts overrides, and rejects bad values", () => {
163
+ /**
164
+ * @param {import("../src/json.js").JsonValue} releaseRetention - Retention config under test, or undefined.
165
+ * @returns {{config: import("../src/config.js").RollbridgeConfig, issues: import("../src/config.js").ConfigIssue[]}} Validation result.
166
+ */
167
+ const validateRetention = (releaseRetention) => validateConfig({
168
+ application: "demo",
169
+ control: {path: "/tmp/demo.sock"},
170
+ processes: [{command: "run web", id: "web", policy: "proxied", port: {from: 18000, to: 18099}}],
171
+ proxy: {host: "127.0.0.1", port: 8182},
172
+ releaseRetention
173
+ })
174
+
175
+ const defaulted = validateRetention(undefined)
176
+
177
+ assert.deepEqual(defaulted.issues, [])
178
+ assert.deepEqual(defaulted.config.releaseRetention, {keep: 10, maxAgeMs: 0})
179
+
180
+ const custom = validateRetention({keep: 3, maxAgeMs: 60000})
181
+
182
+ assert.deepEqual(custom.issues, [])
183
+ assert.deepEqual(custom.config.releaseRetention, {keep: 3, maxAgeMs: 60000})
184
+
185
+ const invalid = validateRetention({keep: -1, maxAgeMs: -5})
186
+
187
+ assert.ok(invalid.issues.some((issue) => issue.message === "releaseRetention.keep must be a non-negative integer"))
188
+ assert.ok(invalid.issues.some((issue) => issue.message === "releaseRetention.maxAgeMs must be a non-negative number"))
189
+ })
190
+
120
191
  test("normalizeConfig throws an aggregated error listing every issue", () => {
121
192
  assert.throws(
122
193
  () => normalizeConfig({
@@ -178,6 +249,40 @@ test("validate CLI command accepts a valid config without setting a failure exit
178
249
  }
179
250
  })
180
251
 
252
+ test("validate --json emits a machine-readable result", async () => {
253
+ const validPath = await writeConfig({
254
+ application: "demo",
255
+ control: {path: "/tmp/rollbridge-json-valid.sock"},
256
+ processes: [{command: "run web", id: "web", policy: "proxied", port: {from: 18000, to: 18099}}],
257
+ proxy: {host: "127.0.0.1", port: 8182}
258
+ })
259
+ const invalidPath = await writeConfig({
260
+ application: "demo",
261
+ processes: [{command: "run web", id: "web", policy: "proxied"}],
262
+ proxy: {port: 8182}
263
+ })
264
+
265
+ try {
266
+ const valid = JSON.parse((await captureCli(["node", "rollbridge", "validate", "--json", "-c", validPath])).output)
267
+
268
+ assert.equal(valid.valid, true)
269
+ assert.deepEqual(valid.issues, [])
270
+ assert.equal(valid.config.processes, 1)
271
+ assert.notEqual(process.exitCode, 1)
272
+
273
+ const invalid = JSON.parse((await captureCli(["node", "rollbridge", "validate", "--json", "-c", invalidPath])).output)
274
+
275
+ assert.equal(invalid.valid, false)
276
+ assert.equal(invalid.config, null)
277
+ assert.ok(invalid.issues.some((/** @type {{message: string}} */ issue) => /must define a port range/.test(issue.message)))
278
+ assert.equal(process.exitCode, 1)
279
+ } finally {
280
+ process.exitCode = 0
281
+ await fs.rm(path.dirname(validPath), {force: true, recursive: true})
282
+ await fs.rm(path.dirname(invalidPath), {force: true, recursive: true})
283
+ }
284
+ })
285
+
181
286
  /**
182
287
  * @param {Record<string, import("../src/json.js").JsonValue>} config - Raw config object.
183
288
  * @returns {Promise<string>} Path to the written config module.
@@ -0,0 +1,228 @@
1
+ // @ts-check
2
+
3
+ import assert from "node:assert/strict"
4
+ import fs from "node:fs/promises"
5
+ import net from "node:net"
6
+ import os from "node:os"
7
+ import path from "node:path"
8
+ import test from "node:test"
9
+ import RollbridgeDaemon from "../src/daemon.js"
10
+ import {normalizeConfig} from "../src/config.js"
11
+ import {runEnvironmentChecks} from "../src/doctor.js"
12
+ import {runCli} from "../src/cli.js"
13
+
14
+ /**
15
+ * @param {object} args - Options.
16
+ * @param {string} args.controlPath - Control socket path.
17
+ * @param {number} args.proxyPort - Proxy port.
18
+ * @returns {import("../src/config.js").RollbridgeConfig} Normalized config.
19
+ */
20
+ function buildConfig({controlPath, proxyPort}) {
21
+ return normalizeConfig({
22
+ application: "doctor-test",
23
+ control: {path: controlPath},
24
+ processes: [{command: "run web", id: "web", policy: "proxied", port: {from: 18000, to: 18099}}],
25
+ proxy: {host: "127.0.0.1", port: proxyPort}
26
+ })
27
+ }
28
+
29
+ /**
30
+ * @returns {Promise<number>} A port that was free when probed.
31
+ */
32
+ async function freePort() {
33
+ return await new Promise((resolve) => {
34
+ const server = net.createServer()
35
+
36
+ server.listen(0, "127.0.0.1", () => {
37
+ const address = server.address()
38
+ const port = address && typeof address === "object" ? address.port : 0
39
+
40
+ server.close(() => resolve(port))
41
+ })
42
+ })
43
+ }
44
+
45
+ /**
46
+ * @returns {Promise<{port: number, server: import("node:net").Server}>} An occupied port and its server.
47
+ */
48
+ async function occupyPort() {
49
+ const server = net.createServer()
50
+ const port = await new Promise((resolve) => {
51
+ server.listen(0, "127.0.0.1", () => {
52
+ const address = server.address()
53
+
54
+ resolve(address && typeof address === "object" ? address.port : 0)
55
+ })
56
+ })
57
+
58
+ return {port, server}
59
+ }
60
+
61
+ /**
62
+ * @param {DoctorCheck[]} checks - Checks.
63
+ * @param {string} name - Check name.
64
+ * @returns {DoctorCheck} The matching check.
65
+ * @typedef {import("../src/doctor.js").DoctorCheck} DoctorCheck
66
+ */
67
+ function checkNamed(checks, name) {
68
+ const check = checks.find((candidate) => candidate.name === name)
69
+
70
+ assert.ok(check, `expected a "${name}" check`)
71
+
72
+ return check
73
+ }
74
+
75
+ test("runEnvironmentChecks passes when no daemon runs, the port is free, and the directory is writable", async () => {
76
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-doctor-"))
77
+
78
+ try {
79
+ const checks = await runEnvironmentChecks(buildConfig({controlPath: path.join(root, "rollbridge.sock"), proxyPort: await freePort()}))
80
+
81
+ assert.equal(checkNamed(checks, "control socket").ok, true)
82
+ assert.equal(checkNamed(checks, "control socket directory").ok, true)
83
+ assert.equal(checkNamed(checks, "proxy port").ok, true)
84
+ } finally {
85
+ await fs.rm(root, {force: true, recursive: true})
86
+ }
87
+ })
88
+
89
+ test("runEnvironmentChecks reports an unavailable proxy port", async () => {
90
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-doctor-"))
91
+ const {port, server} = await occupyPort()
92
+
93
+ try {
94
+ const checks = await runEnvironmentChecks(buildConfig({controlPath: path.join(root, "rollbridge.sock"), proxyPort: port}))
95
+ const proxyCheck = checkNamed(checks, "proxy port")
96
+
97
+ assert.equal(proxyCheck.ok, false)
98
+ assert.match(proxyCheck.detail, /unavailable/)
99
+ } finally {
100
+ await new Promise((resolve) => server.close(() => resolve(undefined)))
101
+ await fs.rm(root, {force: true, recursive: true})
102
+ }
103
+ })
104
+
105
+ test("runEnvironmentChecks reports a missing control socket directory", async () => {
106
+ const checks = await runEnvironmentChecks(buildConfig({controlPath: "/rollbridge-doctor-missing-dir/rollbridge.sock", proxyPort: await freePort()}))
107
+ const directoryCheck = checkNamed(checks, "control socket directory")
108
+
109
+ assert.equal(directoryCheck.ok, false)
110
+ assert.match(directoryCheck.detail, /missing or not writable/)
111
+ })
112
+
113
+ test("runEnvironmentChecks fails when a Rollbridge daemon already holds the socket and port", async () => {
114
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-doctor-"))
115
+ const config = buildConfig({controlPath: path.join(root, "rollbridge.sock"), proxyPort: await freePort()})
116
+ const daemon = new RollbridgeDaemon({config, logger: () => {}})
117
+
118
+ await daemon.start()
119
+
120
+ try {
121
+ const checks = await runEnvironmentChecks(config)
122
+ const socketCheck = checkNamed(checks, "control socket")
123
+
124
+ // A daemon already running means `rollbridge daemon` would fail to bind, so doctor must fail too.
125
+ assert.equal(socketCheck.ok, false)
126
+ assert.match(socketCheck.detail, /a Rollbridge daemon for "doctor-test" is already running/)
127
+ assert.equal(checkNamed(checks, "proxy port").ok, false)
128
+ } finally {
129
+ await daemon.shutdown()
130
+ await fs.rm(root, {force: true, recursive: true})
131
+ }
132
+ })
133
+
134
+ /**
135
+ * Runs the CLI while capturing console output.
136
+ * @param {string[]} argv - Process argv.
137
+ * @returns {Promise<string>} Captured stdout and stderr lines.
138
+ */
139
+ async function captureCli(argv) {
140
+ const originalLog = console.log
141
+ const originalError = console.error
142
+ /** @type {string[]} */
143
+ const lines = []
144
+ const collect = (/** @type {string[]} */ ...args) => { lines.push(args.map((arg) => String(arg)).join(" ")) }
145
+
146
+ console.log = collect
147
+ console.error = collect
148
+
149
+ try {
150
+ await runCli(argv)
151
+ } finally {
152
+ console.log = originalLog
153
+ console.error = originalError
154
+ }
155
+
156
+ return lines.join("\n")
157
+ }
158
+
159
+ test("doctor CLI passes for a valid, bindable config", async () => {
160
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-doctor-"))
161
+ const rawConfig = {
162
+ application: "doctor-cli-test",
163
+ control: {path: path.join(root, "rollbridge.sock")},
164
+ processes: [{command: "run web", id: "web", policy: "proxied", port: {from: 18000, to: 18099}}],
165
+ proxy: {host: "127.0.0.1", port: await freePort()}
166
+ }
167
+
168
+ await fs.writeFile(path.join(root, "rollbridge.js"), `module.exports = ${JSON.stringify(rawConfig)}\n`)
169
+
170
+ try {
171
+ const output = await captureCli(["node", "rollbridge", "doctor", "-c", path.join(root, "rollbridge.js")])
172
+
173
+ assert.match(output, /All checks passed\./)
174
+ assert.notEqual(process.exitCode, 1)
175
+ } finally {
176
+ await fs.rm(root, {force: true, recursive: true})
177
+ }
178
+ })
179
+
180
+ test("doctor CLI fails and exits non-zero for an invalid config", async () => {
181
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-doctor-"))
182
+
183
+ await fs.writeFile(path.join(root, "rollbridge.js"), "module.exports = {application: \"x\", proxy: {port: 8182}, processes: []}\n")
184
+
185
+ try {
186
+ const output = await captureCli(["node", "rollbridge", "doctor", "-c", path.join(root, "rollbridge.js")])
187
+
188
+ assert.equal(process.exitCode, 1)
189
+ assert.match(output, /✗ config:/)
190
+ } finally {
191
+ process.exitCode = 0
192
+ await fs.rm(root, {force: true, recursive: true})
193
+ }
194
+ })
195
+
196
+ test("doctor --json emits structured checks", async () => {
197
+ const okRoot = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-doctor-"))
198
+ const okConfig = {
199
+ application: "doctor-json-test",
200
+ control: {path: path.join(okRoot, "rollbridge.sock")},
201
+ processes: [{command: "run web", id: "web", policy: "proxied", port: {from: 18000, to: 18099}}],
202
+ proxy: {host: "127.0.0.1", port: await freePort()}
203
+ }
204
+
205
+ await fs.writeFile(path.join(okRoot, "rollbridge.js"), `module.exports = ${JSON.stringify(okConfig)}\n`)
206
+
207
+ const badRoot = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-doctor-"))
208
+
209
+ await fs.writeFile(path.join(badRoot, "rollbridge.js"), "module.exports = {application: \"x\", proxy: {port: 8182}, processes: []}\n")
210
+
211
+ try {
212
+ const passing = JSON.parse(await captureCli(["node", "rollbridge", "doctor", "--json", "-c", path.join(okRoot, "rollbridge.js")]))
213
+
214
+ assert.equal(passing.ok, true)
215
+ assert.ok(passing.checks.some((/** @type {{name: string, ok: boolean}} */ check) => check.name === "proxy port" && check.ok === true))
216
+ assert.notEqual(process.exitCode, 1)
217
+
218
+ const failing = JSON.parse(await captureCli(["node", "rollbridge", "doctor", "--json", "-c", path.join(badRoot, "rollbridge.js")]))
219
+
220
+ assert.equal(failing.ok, false)
221
+ assert.ok(failing.checks.some((/** @type {{name: string, ok: boolean}} */ check) => check.name === "config" && check.ok === false))
222
+ assert.equal(process.exitCode, 1)
223
+ } finally {
224
+ process.exitCode = 0
225
+ await fs.rm(okRoot, {force: true, recursive: true})
226
+ await fs.rm(badRoot, {force: true, recursive: true})
227
+ }
228
+ })
@@ -0,0 +1,2 @@
1
+ // Exits non-zero shortly after starting, to exercise auto-restart behavior.
2
+ setTimeout(() => process.exit(1), 40)
@@ -0,0 +1,63 @@
1
+ // @ts-check
2
+
3
+ import assert from "node:assert/strict"
4
+ import http from "node:http"
5
+ import test from "node:test"
6
+ import {waitForHealth} from "../src/health.js"
7
+
8
+ /**
9
+ * Starts a health server that records when it first receives a probe.
10
+ * @returns {Promise<{firstProbeDelay: () => number, port: number, close: () => Promise<void>}>} Server handle.
11
+ */
12
+ async function startHealthServer() {
13
+ const start = Date.now()
14
+ let firstProbeAt = 0
15
+ const server = http.createServer((request, response) => {
16
+ if (firstProbeAt === 0) firstProbeAt = Date.now()
17
+
18
+ response.writeHead(200, {"Content-Type": "text/plain"})
19
+ response.end("ok")
20
+ })
21
+
22
+ await new Promise((resolve) => server.listen(0, "127.0.0.1", () => resolve(undefined)))
23
+
24
+ const address = server.address()
25
+
26
+ return {
27
+ close: () => new Promise((resolve) => server.close(() => resolve(undefined))),
28
+ firstProbeDelay: () => firstProbeAt - start,
29
+ port: address && typeof address === "object" ? address.port : 0
30
+ }
31
+ }
32
+
33
+ test("waitForHealth delays the first probe by startDelayMs", async () => {
34
+ const server = await startHealthServer()
35
+
36
+ try {
37
+ await waitForHealth({
38
+ health: {intervalMs: 25, path: "/ping", startDelayMs: 200, timeoutMs: 2000},
39
+ host: "127.0.0.1",
40
+ port: server.port
41
+ })
42
+
43
+ assert.ok(server.firstProbeDelay() >= 180, `expected first probe to be delayed ~200ms, was ${server.firstProbeDelay()}ms`)
44
+ } finally {
45
+ await server.close()
46
+ }
47
+ })
48
+
49
+ test("waitForHealth probes immediately when startDelayMs is 0", async () => {
50
+ const server = await startHealthServer()
51
+
52
+ try {
53
+ await waitForHealth({
54
+ health: {intervalMs: 25, path: "/ping", startDelayMs: 0, timeoutMs: 2000},
55
+ host: "127.0.0.1",
56
+ port: server.port
57
+ })
58
+
59
+ assert.ok(server.firstProbeDelay() < 150, `expected an immediate first probe, was ${server.firstProbeDelay()}ms`)
60
+ } finally {
61
+ await server.close()
62
+ }
63
+ })