rollbridge 0.1.2 → 0.1.5

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/package.json CHANGED
@@ -1,7 +1,26 @@
1
1
  {
2
2
  "name": "rollbridge",
3
- "version": "0.1.2",
3
+ "version": "0.1.5",
4
4
  "description": "Zero-downtime process supervisor and local traffic switcher for deploy-managed apps.",
5
+ "keywords": [
6
+ "deploy",
7
+ "zero-downtime",
8
+ "process-supervisor",
9
+ "reverse-proxy",
10
+ "websocket",
11
+ "rollbridge",
12
+ "velocious"
13
+ ],
14
+ "homepage": "https://github.com/kaspernj/rollbridge#readme",
15
+ "bugs": {
16
+ "url": "https://github.com/kaspernj/rollbridge/issues"
17
+ },
18
+ "license": "MIT",
19
+ "author": "kaspernj <kasper@diestoeckels.de>",
20
+ "repository": {
21
+ "type": "git",
22
+ "url": "git+https://github.com/kaspernj/rollbridge.git"
23
+ },
5
24
  "type": "module",
6
25
  "bin": {
7
26
  "rollbridge": "./bin/rollbridge"
@@ -9,7 +28,7 @@
9
28
  "scripts": {
10
29
  "all-checks": "npm run typecheck && npm run lint && npm test",
11
30
  "lint": "eslint",
12
- "release:patch": "node scripts/release-patch.js",
31
+ "release:patch": "release-patch",
13
32
  "test": "node --test test/*.test.js",
14
33
  "typecheck": "tsc --noEmit"
15
34
  },
@@ -27,6 +46,7 @@
27
46
  "eslint": "^10.4.0",
28
47
  "eslint-plugin-jsdoc": "^62.9.0",
29
48
  "globals": "^17.6.0",
49
+ "release-patch": "^1.0.0",
30
50
  "typescript": "^6.0.3"
31
51
  }
32
52
  }
package/src/cli.js CHANGED
@@ -7,6 +7,7 @@ import {spawn} from "node:child_process"
7
7
  import {Command} from "commander"
8
8
  import RollbridgeDaemon from "./daemon.js"
9
9
  import {loadConfig, parseConfigFile, resolveConfigPath, validateConfig} from "./config.js"
10
+ import {runEnvironmentChecks} from "./doctor.js"
10
11
  import {sendControlCommand} from "./control-client.js"
11
12
 
12
13
  const DEFAULT_DAEMON_START_TIMEOUT_MS = 10000
@@ -135,6 +136,33 @@ export async function runCli(argv) {
135
136
  console.log(JSON.stringify(response, null, 2))
136
137
  })
137
138
 
139
+ program
140
+ .command("restart")
141
+ .description("Restart running non-proxied processes (by id, by policy, or all).")
142
+ .option("-c, --config <path>", "Config file path (defaults to rollbridge.js)")
143
+ .option("--process <id>", "Restart only the process with this id")
144
+ .option("--policy <policy>", "Restart only processes with this policy (companion, singleton, or service)")
145
+ .action(async (options) => {
146
+ if (options.policy !== undefined && !["companion", "service", "singleton"].includes(options.policy)) {
147
+ console.error("--policy must be one of: companion, singleton, service.")
148
+ process.exitCode = 1
149
+ return
150
+ }
151
+
152
+ const configPath = await resolveConfigPath(options.config)
153
+ const config = await loadConfig(configPath)
154
+ const response = await sendControlCommand({
155
+ command: {
156
+ command: "restart",
157
+ policy: options.policy,
158
+ processId: options.process
159
+ },
160
+ path: config.control.path
161
+ })
162
+
163
+ console.log(JSON.stringify(response, null, 2))
164
+ })
165
+
138
166
  program
139
167
  .command("shutdown")
140
168
  .option("-c, --config <path>", "Config file path (defaults to rollbridge.js)")
@@ -153,20 +181,33 @@ export async function runCli(argv) {
153
181
  .command("validate")
154
182
  .description("Parse the config and report all errors without starting the daemon.")
155
183
  .option("-c, --config <path>", "Config file path (defaults to rollbridge.js)")
184
+ .option("--json", "Output machine-readable JSON")
156
185
  .action(async (options) => {
157
186
  let configPath
158
187
 
159
188
  try {
160
189
  configPath = await resolveConfigPath(options.config)
161
190
  } catch (error) {
162
- console.error(error instanceof Error ? error.message : String(error))
191
+ const message = error instanceof Error ? error.message : String(error)
192
+
193
+ if (options.json) console.log(JSON.stringify({config: null, issues: [{fix: "Pass --config or add a rollbridge.js.", message}], path: null, valid: false}, null, 2))
194
+ else console.error(message)
163
195
  process.exitCode = 1
164
196
  return
165
197
  }
166
198
 
167
199
  const {config, issues} = await validateConfigFile(configPath)
200
+ const valid = issues.length === 0
201
+
202
+ if (options.json) {
203
+ const summary = valid ? {application: config.application, processes: config.processes.length, proxy: {host: config.proxy.host, port: config.proxy.port}} : null
168
204
 
169
- if (issues.length === 0) {
205
+ console.log(JSON.stringify({config: summary, issues, path: configPath, valid}, null, 2))
206
+ if (!valid) process.exitCode = 1
207
+ return
208
+ }
209
+
210
+ if (valid) {
170
211
  const processCount = config.processes.length
171
212
 
172
213
  console.log(`${configPath} is valid: ${processCount} ${processCount === 1 ? "process" : "processes"}, proxy on ${config.proxy.host}:${config.proxy.port}.`)
@@ -183,9 +224,134 @@ export async function runCli(argv) {
183
224
  process.exitCode = 1
184
225
  })
185
226
 
227
+ program
228
+ .command("doctor")
229
+ .description("Check the environment before starting the daemon: config, control socket, and proxy port.")
230
+ .option("-c, --config <path>", "Config file path (defaults to rollbridge.js)")
231
+ .option("--json", "Output machine-readable JSON")
232
+ .action(async (options) => {
233
+ let configPath
234
+
235
+ try {
236
+ configPath = await resolveConfigPath(options.config)
237
+ } catch (error) {
238
+ const message = error instanceof Error ? error.message : String(error)
239
+
240
+ if (options.json) console.log(JSON.stringify({checks: [{detail: message, name: "config", ok: false}], ok: false}, null, 2))
241
+ else console.error(message)
242
+ process.exitCode = 1
243
+ return
244
+ }
245
+
246
+ const {config, issues} = await validateConfigFile(configPath)
247
+ /** @type {import("./doctor.js").DoctorCheck[]} */
248
+ const checks = []
249
+
250
+ if (issues.length > 0) {
251
+ checks.push({detail: `${issues.length} ${issues.length === 1 ? "issue" : "issues"} — run "rollbridge validate" for details`, name: "config", ok: false})
252
+ } else {
253
+ checks.push({detail: `valid: ${config.processes.length} ${config.processes.length === 1 ? "process" : "processes"}, proxy on ${config.proxy.host}:${config.proxy.port}`, name: "config", ok: true})
254
+ checks.push(...await runEnvironmentChecks(config))
255
+ }
256
+
257
+ const failed = checks.filter((check) => !check.ok).length
258
+
259
+ if (options.json) {
260
+ console.log(JSON.stringify({checks, ok: failed === 0}, null, 2))
261
+ } else {
262
+ for (const check of checks) {
263
+ console.log(`${check.ok ? "✓" : "✗"} ${check.name}: ${check.detail}`)
264
+ }
265
+
266
+ if (failed === 0) console.log("\nAll checks passed.")
267
+ else console.error(`\n${failed} check${failed === 1 ? "" : "s"} failed.`)
268
+ }
269
+
270
+ if (failed > 0) process.exitCode = 1
271
+ })
272
+
273
+ program
274
+ .command("logs")
275
+ .description("Print recent stdout/stderr captured from managed processes.")
276
+ .option("-c, --config <path>", "Config file path (defaults to rollbridge.js)")
277
+ .option("--process <id>", "Only show logs for the process with this id")
278
+ .option("--json", "Output machine-readable JSON")
279
+ .action(async (options) => {
280
+ const configPath = await resolveConfigPath(options.config)
281
+ const config = await loadConfig(configPath)
282
+ const response = await sendControlCommand({
283
+ command: {command: "status"},
284
+ path: config.control.path
285
+ })
286
+ const sources = collectLogSources(/** @type {import("./daemon.js").DaemonStatus} */ (response))
287
+
288
+ if (options.json) {
289
+ const filtered = options.process === undefined ? sources : sources.filter((source) => source.id === options.process)
290
+
291
+ console.log(JSON.stringify(filtered, null, 2))
292
+ return
293
+ }
294
+
295
+ console.log(formatLogSources(sources, options.process))
296
+ })
297
+
186
298
  await program.parseAsync(argv)
187
299
  }
188
300
 
301
+ /**
302
+ * @typedef {{id: string, logs: import("./managed-process.js").ManagedProcessLog[], source: string}} LogSource
303
+ */
304
+
305
+ /**
306
+ * Flattens managed-process logs from a daemon status payload, labelling each process by origin.
307
+ * @param {import("./daemon.js").DaemonStatus} status - Daemon status payload.
308
+ * @returns {LogSource[]} One entry per managed process.
309
+ */
310
+ function collectLogSources(status) {
311
+ /** @type {LogSource[]} */
312
+ const sources = []
313
+
314
+ for (const release of status.releases) {
315
+ for (const processStatus of release.processes) {
316
+ sources.push({id: processStatus.id, logs: processStatus.logs, source: `release ${release.releaseId} (${release.state})`})
317
+ }
318
+ }
319
+
320
+ for (const service of status.services) {
321
+ sources.push({id: service.process.id, logs: service.process.logs, source: "service"})
322
+ }
323
+
324
+ for (const singleton of status.singletons) {
325
+ sources.push({id: singleton.process.id, logs: singleton.process.logs, source: "singleton"})
326
+ }
327
+
328
+ return sources
329
+ }
330
+
331
+ /**
332
+ * Formats collected log sources for display, optionally filtered to a single process id.
333
+ * @param {LogSource[]} sources - Collected log sources.
334
+ * @param {string | undefined} processFilter - Only include the process with this id when set.
335
+ * @returns {string} Human-readable log output.
336
+ */
337
+ export function formatLogSources(sources, processFilter) {
338
+ const matched = processFilter === undefined ? sources : sources.filter((source) => source.id === processFilter)
339
+
340
+ if (matched.length === 0) {
341
+ return processFilter === undefined ? "No managed processes." : `No process found with id "${processFilter}".`
342
+ }
343
+
344
+ return matched
345
+ .map((source) => {
346
+ const header = `== ${source.id} [${source.source}] ==`
347
+
348
+ if (source.logs.length === 0) return `${header}\n (no recent output)`
349
+
350
+ return `${header}\n${source.logs.map((log) => ` ${log.at} [${log.stream}] ${log.line}`).join("\n")}`
351
+ })
352
+ .join("\n\n")
353
+ }
354
+
189
355
  /**
190
356
  * Reads, parses, and validates a config file, collecting read, parse, and validation issues.
191
357
  * @param {string} configPath - Config file path.
package/src/config.js CHANGED
@@ -7,12 +7,14 @@ import {pathToFileURL} from "node:url"
7
7
  /**
8
8
  * @typedef {import("./json.js").JsonValue} JsonValue
9
9
  * @typedef {{from: number, to: number}} PortRange
10
- * @typedef {{path: string, timeoutMs: number, intervalMs: number}} HealthConfig
10
+ * @typedef {{path: string, startDelayMs: number, timeoutMs: number, intervalMs: number}} HealthConfig
11
11
  * @typedef {"proxied" | "companion" | "singleton" | "service"} ProcessPolicy
12
- * @typedef {{cwd?: string, env: Record<string, string>, gracefulStopMs: number, health?: HealthConfig, id: string, outputLines: number, policy: ProcessPolicy, port?: PortRange, restartDelayMs: number, command: string}} ProcessConfig
12
+ * @typedef {{backoffFactor: number, maxDelayMs: number, maxRestarts: number | undefined, windowMs: number}} RestartConfig
13
+ * @typedef {{cwd?: string, env: Record<string, string>, gracefulStopMs: number, health?: HealthConfig, id: string, outputLines: number, policy: ProcessPolicy, port?: PortRange, restart: RestartConfig, restartDelayMs: number, command: string}} ProcessConfig
13
14
  * @typedef {{mode?: number, path: string}} ControlConfig
14
- * @typedef {{drainTimeoutMs: number, forceStopTimeoutMs: number, healthPath: string, healthTimeoutMs: number, host: string, port: number}} ProxyConfig
15
- * @typedef {{application: string, control: ControlConfig, processes: ProcessConfig[], proxy: ProxyConfig}} RollbridgeConfig
15
+ * @typedef {{drainTimeoutMs: number, forceStopTimeoutMs: number, healthPath: string, healthTimeoutMs: number, host: string, port: number, upstreamHost: string}} ProxyConfig
16
+ * @typedef {{keep: number, maxAgeMs: number}} ReleaseRetentionConfig
17
+ * @typedef {{application: string, control: ControlConfig, processes: ProcessConfig[], proxy: ProxyConfig, releaseRetention: ReleaseRetentionConfig}} RollbridgeConfig
16
18
  * @typedef {{fix: string, message: string}} ConfigIssue
17
19
  */
18
20
 
@@ -127,10 +129,11 @@ export function validateConfig(rawConfig, configPath = process.cwd()) {
127
129
  path: normalizeString(controlSource.path, "control.path", issues, {default: `/tmp/rollbridge-${application}.sock`})
128
130
  }
129
131
  const processes = processesSource.map((processSource, index) => normalizeProcess(processSource, index, proxy, issues))
132
+ const releaseRetention = normalizeReleaseRetention(objectAt(source.releaseRetention, "releaseRetention", issues, {}), issues)
130
133
 
131
134
  validateProcessSet(processes, issues)
132
135
 
133
- return {config: {application, control, processes, proxy}, issues}
136
+ return {config: {application, control, processes, proxy, releaseRetention}, issues}
134
137
  }
135
138
 
136
139
  /**
@@ -139,16 +142,29 @@ export function validateConfig(rawConfig, configPath = process.cwd()) {
139
142
  * @returns {ProxyConfig} Normalized proxy config.
140
143
  */
141
144
  function normalizeProxy(source, issues) {
145
+ const host = normalizeString(source.host, "proxy.host", issues, {default: "127.0.0.1"})
146
+
142
147
  return {
143
148
  drainTimeoutMs: normalizeNumber(source.drainTimeoutMs, "proxy.drainTimeoutMs", issues, {default: 60000}),
144
149
  forceStopTimeoutMs: normalizeNumber(source.forceStopTimeoutMs, "proxy.forceStopTimeoutMs", issues, {default: 10000}),
145
150
  healthPath: normalizeString(source.healthPath, "proxy.healthPath", issues, {default: "/ping"}),
146
151
  healthTimeoutMs: normalizeNumber(source.healthTimeoutMs, "proxy.healthTimeoutMs", issues, {default: 30000}),
147
- host: normalizeString(source.host, "proxy.host", issues, {default: "127.0.0.1"}),
148
- port: normalizeNumber(source.port, "proxy.port", issues, {default: 8182})
152
+ host,
153
+ port: normalizeNumber(source.port, "proxy.port", issues, {default: 8182}),
154
+ upstreamHost: normalizeString(source.upstreamHost, "proxy.upstreamHost", issues, {default: defaultUpstreamHost(host)})
149
155
  }
150
156
  }
151
157
 
158
+ /**
159
+ * @param {string} host - Public proxy bind host.
160
+ * @returns {string} Default loopback upstream host for wildcard binds.
161
+ */
162
+ function defaultUpstreamHost(host) {
163
+ if (host === "0.0.0.0" || host === "::") return "127.0.0.1"
164
+
165
+ return host
166
+ }
167
+
152
168
  /**
153
169
  * @param {JsonValue} value - Raw process config.
154
170
  * @param {number} index - Process index.
@@ -160,7 +176,7 @@ function normalizeProcess(value, index, proxy, issues) {
160
176
  if (!isPlainObject(value)) {
161
177
  issues.push({fix: `Define processes[${index}] as a mapping with id, policy, and command.`, message: `processes[${index}] must be an object`})
162
178
 
163
- return {command: "", cwd: undefined, env: {}, gracefulStopMs: proxy.forceStopTimeoutMs, health: undefined, id: "", outputLines: 50, policy: "companion", port: undefined, restartDelayMs: 1000}
179
+ return {command: "", cwd: undefined, env: {}, gracefulStopMs: proxy.forceStopTimeoutMs, health: undefined, id: "", outputLines: 50, policy: "companion", port: undefined, restart: defaultRestartConfig(), restartDelayMs: 1000}
164
180
  }
165
181
 
166
182
  const source = value
@@ -175,10 +191,80 @@ function normalizeProcess(value, index, proxy, issues) {
175
191
  outputLines: normalizeOutputLines(source.outputLines, `processes[${index}].outputLines`, issues),
176
192
  policy: normalizePolicy(source.policy, `processes[${index}].policy`, issues),
177
193
  port: normalizePortRange(source.port, `processes[${index}].port`, issues),
194
+ restart: normalizeRestart(source.restart, `processes[${index}].restart`, issues),
178
195
  restartDelayMs: normalizeNumber(source.restartDelayMs, `processes[${index}].restartDelayMs`, issues, {default: 1000})
179
196
  }
180
197
  }
181
198
 
199
+ /**
200
+ * @returns {RestartConfig} Default restart policy: unlimited restarts with a constant delay.
201
+ */
202
+ function defaultRestartConfig() {
203
+ return {backoffFactor: 1, maxDelayMs: 0, maxRestarts: undefined, windowMs: 0}
204
+ }
205
+
206
+ /**
207
+ * @param {JsonValue} value - Raw restart policy.
208
+ * @param {string} key - Config key.
209
+ * @param {ConfigIssue[]} issues - Issue collector.
210
+ * @returns {RestartConfig} Normalized restart policy.
211
+ */
212
+ function normalizeRestart(value, key, issues) {
213
+ if (value === undefined || value === null) return defaultRestartConfig()
214
+
215
+ if (!isPlainObject(value)) {
216
+ issues.push({fix: `Set ${key} to a mapping with maxRestarts, windowMs, backoffFactor, and maxDelayMs.`, message: `${key} must be an object`})
217
+
218
+ return defaultRestartConfig()
219
+ }
220
+
221
+ const windowMs = normalizeNumber(value.windowMs, `${key}.windowMs`, issues, {default: 0})
222
+ const maxDelayMs = normalizeNumber(value.maxDelayMs, `${key}.maxDelayMs`, issues, {default: 0})
223
+
224
+ return {
225
+ backoffFactor: normalizeBackoffFactor(value.backoffFactor, `${key}.backoffFactor`, issues),
226
+ maxDelayMs: nonNegativeOrDefault(maxDelayMs, `${key}.maxDelayMs`, issues, 0, false),
227
+ maxRestarts: normalizeMaxRestarts(value.maxRestarts, `${key}.maxRestarts`, issues),
228
+ windowMs: nonNegativeOrDefault(windowMs, `${key}.windowMs`, issues, 0, false)
229
+ }
230
+ }
231
+
232
+ /**
233
+ * @param {JsonValue} value - Raw maximum restart count.
234
+ * @param {string} key - Config key.
235
+ * @param {ConfigIssue[]} issues - Issue collector.
236
+ * @returns {number | undefined} Restart cap, or undefined for unlimited restarts.
237
+ */
238
+ function normalizeMaxRestarts(value, key, issues) {
239
+ if (value === undefined || value === null) return undefined
240
+
241
+ if (typeof value !== "number" || !Number.isInteger(value) || value < 0) {
242
+ issues.push({fix: `Set ${key} to a non-negative integer (0 disables automatic restarts), or omit it for unlimited restarts.`, message: `${key} must be a non-negative integer`})
243
+
244
+ return undefined
245
+ }
246
+
247
+ return value
248
+ }
249
+
250
+ /**
251
+ * @param {JsonValue} value - Raw backoff factor.
252
+ * @param {string} key - Config key.
253
+ * @param {ConfigIssue[]} issues - Issue collector.
254
+ * @returns {number} Backoff multiplier (>= 1; 1 keeps a constant delay).
255
+ */
256
+ function normalizeBackoffFactor(value, key, issues) {
257
+ const factor = normalizeNumber(value, key, issues, {default: 1})
258
+
259
+ if (factor < 1) {
260
+ issues.push({fix: `Set ${key} to a number >= 1 (1 keeps a constant delay; 2 doubles the delay each restart).`, message: `${key} must be a number greater than or equal to 1`})
261
+
262
+ return 1
263
+ }
264
+
265
+ return factor
266
+ }
267
+
182
268
  /**
183
269
  * @param {JsonValue} value - Raw output retention value.
184
270
  * @param {string} key - Config key.
@@ -197,6 +283,39 @@ function normalizeOutputLines(value, key, issues) {
197
283
  return outputLines
198
284
  }
199
285
 
286
+ /**
287
+ * @param {Record<string, JsonValue>} source - Raw release retention config.
288
+ * @param {ConfigIssue[]} issues - Issue collector.
289
+ * @returns {ReleaseRetentionConfig} Normalized release retention policy.
290
+ */
291
+ function normalizeReleaseRetention(source, issues) {
292
+ const keep = normalizeNumber(source.keep, "releaseRetention.keep", issues, {default: 10})
293
+ const maxAgeMs = normalizeNumber(source.maxAgeMs, "releaseRetention.maxAgeMs", issues, {default: 0})
294
+
295
+ return {
296
+ keep: nonNegativeOrDefault(keep, "releaseRetention.keep", issues, 10, true),
297
+ maxAgeMs: nonNegativeOrDefault(maxAgeMs, "releaseRetention.maxAgeMs", issues, 0, false)
298
+ }
299
+ }
300
+
301
+ /**
302
+ * @param {number} value - Already type-normalized number.
303
+ * @param {string} key - Config key.
304
+ * @param {ConfigIssue[]} issues - Issue collector.
305
+ * @param {number} fallback - Value to use when invalid.
306
+ * @param {boolean} requireInteger - Whether the value must be an integer.
307
+ * @returns {number} The value when non-negative (and integer when required), else the fallback.
308
+ */
309
+ function nonNegativeOrDefault(value, key, issues, fallback, requireInteger) {
310
+ if (value < 0 || (requireInteger && !Number.isInteger(value))) {
311
+ issues.push({fix: `Set ${key} to a non-negative ${requireInteger ? "integer" : "number"}, e.g. ${fallback}.`, message: `${key} must be a non-negative ${requireInteger ? "integer" : "number"}`})
312
+
313
+ return fallback
314
+ }
315
+
316
+ return value
317
+ }
318
+
200
319
  /**
201
320
  * @param {JsonValue} value - Raw socket permission mode.
202
321
  * @param {string} key - Config key.
@@ -291,10 +410,29 @@ function normalizeHealth(value, key, proxy, issues) {
291
410
  return {
292
411
  intervalMs: normalizeNumber(source.intervalMs, `${key}.intervalMs`, issues, {default: 250}),
293
412
  path: normalizeString(source.path, `${key}.path`, issues, {default: proxy.healthPath}),
413
+ startDelayMs: normalizeStartDelayMs(source.startDelayMs, `${key}.startDelayMs`, issues),
294
414
  timeoutMs: normalizeNumber(source.timeoutMs, `${key}.timeoutMs`, issues, {default: proxy.healthTimeoutMs})
295
415
  }
296
416
  }
297
417
 
418
+ /**
419
+ * @param {JsonValue} value - Raw startup delay.
420
+ * @param {string} key - Config key.
421
+ * @param {ConfigIssue[]} issues - Issue collector.
422
+ * @returns {number} Milliseconds to wait before the first health probe (default 0).
423
+ */
424
+ function normalizeStartDelayMs(value, key, issues) {
425
+ const startDelayMs = normalizeNumber(value, key, issues, {default: 0})
426
+
427
+ if (startDelayMs < 0) {
428
+ issues.push({fix: `Set ${key} to a non-negative number of milliseconds, e.g. 0 or 2000.`, message: `${key} must be a non-negative number`})
429
+
430
+ return 0
431
+ }
432
+
433
+ return startDelayMs
434
+ }
435
+
298
436
  /**
299
437
  * @param {JsonValue} value - Raw env config.
300
438
  * @param {string} key - Config key.
package/src/daemon.js CHANGED
@@ -10,7 +10,7 @@ import ReleaseGroup from "./release-group.js"
10
10
  * @typedef {import("./json.js").JsonValue} JsonValue
11
11
  * @typedef {{releaseId?: string, releasePath: string, revision?: string}} DeployArgs
12
12
  * @typedef {{id: string, process: import("./managed-process.js").ManagedProcessStatus}} ProcessStatus
13
- * @typedef {{activeReleaseId: string | null, application: string, control: import("./config.js").ControlConfig, proxy: {host: string, port: number | undefined}, releases: import("./release-group.js").ReleaseStatus[], services: ProcessStatus[], singletons: ProcessStatus[]}} DaemonStatus
13
+ * @typedef {{activeReleaseId: string | null, application: string, control: import("./config.js").ControlConfig, proxy: {host: string, port: number | undefined, upstreamHost: string}, releases: import("./release-group.js").ReleaseStatus[], services: ProcessStatus[], singletons: ProcessStatus[]}} DaemonStatus
14
14
  */
15
15
 
16
16
  export default class RollbridgeDaemon {
@@ -233,6 +233,13 @@ export default class RollbridgeDaemon {
233
233
  return this.status()
234
234
  }
235
235
 
236
+ if (commandName === "restart") {
237
+ return await this.restartProcesses({
238
+ policy: stringOrUndefined(data.policy),
239
+ processId: stringOrUndefined(data.processId)
240
+ })
241
+ }
242
+
236
243
  if (commandName === "shutdown") {
237
244
  setImmediate(() => {
238
245
  this.shutdown().catch((error) => {
@@ -286,9 +293,7 @@ export default class RollbridgeDaemon {
286
293
  await this.replaceSingletons(release)
287
294
 
288
295
  if (previousRelease) {
289
- previousRelease.drainAndStop(this.config.proxy.drainTimeoutMs).catch((error) => {
290
- this.logger("release drain failed", {error: error instanceof Error ? error.message : String(error), releaseId: previousRelease.releaseId})
291
- })
296
+ void this.drainAndPrune(previousRelease)
292
297
  }
293
298
 
294
299
  return {
@@ -367,6 +372,7 @@ export default class RollbridgeDaemon {
367
372
  env: nextDefinition.env,
368
373
  logger: nextDefinition.logger,
369
374
  outputLines: nextDefinition.outputLines,
375
+ restart: nextDefinition.restart,
370
376
  restartDelayMs: nextDefinition.restartDelayMs,
371
377
  shouldRestart: nextDefinition.shouldRestart,
372
378
  stopTimeoutMs: nextDefinition.stopTimeoutMs
@@ -396,6 +402,75 @@ export default class RollbridgeDaemon {
396
402
  }
397
403
  }
398
404
 
405
+ /**
406
+ * Restarts non-proxied processes selected by id or policy, or all of them: running
407
+ * processes are bounced (stop then start) and crashed or stopped ones are revived,
408
+ * matching the conventional meaning of "restart".
409
+ *
410
+ * The proxied process is never restarted in place (that would drop traffic); use a
411
+ * deploy for a zero-downtime replacement.
412
+ * @param {{policy?: string, processId?: string}} selector - Restart selector; restarts all non-proxied processes when both are omitted.
413
+ * @returns {Promise<Record<string, JsonValue>>} The ids that were restarted.
414
+ */
415
+ async restartProcesses({policy, processId} = {}) {
416
+ if (policy === "proxied" || (processId !== undefined && this.isProxiedId(processId))) {
417
+ throw new Error('The proxied process cannot be restarted in place; use "rollbridge deploy" for a zero-downtime replacement.')
418
+ }
419
+
420
+ const targets = this.collectRestartTargets({policy, processId})
421
+
422
+ if (processId !== undefined && targets.length === 0) {
423
+ throw new Error(`No managed process with id "${processId}" to restart.`)
424
+ }
425
+
426
+ for (const target of targets) {
427
+ this.logger("process restart requested", {processId: target.id})
428
+ await target.process.stop()
429
+ await target.process.start()
430
+ }
431
+
432
+ return {restarted: targets.map((target) => target.id)}
433
+ }
434
+
435
+ /**
436
+ * @param {{policy?: string, processId?: string}} selector - Restart selector.
437
+ * @returns {{id: string, process: import("./managed-process.js").default}[]} Running non-proxied processes matching the selector.
438
+ */
439
+ collectRestartTargets({policy, processId}) {
440
+ const targets = /** @type {{id: string, process: import("./managed-process.js").default}[]} */ ([])
441
+
442
+ for (const processConfig of this.config.processes) {
443
+ if (processConfig.policy === "proxied") continue
444
+ if (processId !== undefined && processConfig.id !== processId) continue
445
+ if (policy !== undefined && processConfig.policy !== policy) continue
446
+
447
+ const process = this.findProcessInstance(processConfig)
448
+
449
+ if (process) targets.push({id: processConfig.id, process})
450
+ }
451
+
452
+ return targets
453
+ }
454
+
455
+ /**
456
+ * @param {import("./config.js").ProcessConfig} processConfig - Process definition.
457
+ * @returns {import("./managed-process.js").default | undefined} The running instance, if any.
458
+ */
459
+ findProcessInstance(processConfig) {
460
+ if (processConfig.policy === "service") return this.services.get(processConfig.id)
461
+ if (processConfig.policy === "singleton") return this.singletons.get(processConfig.id)
462
+
463
+ return this.activeRelease ? this.activeRelease.getProcess(processConfig.id) : undefined
464
+ }
465
+
466
+ /**
467
+ * @param {string} id - Process id.
468
+ * @returns {boolean} True when the id belongs to the proxied process.
469
+ */
470
+ isProxiedId(id) {
471
+ return this.config.processes.some((processConfig) => processConfig.policy === "proxied" && processConfig.id === id)
472
+ }
473
+
399
474
  /**
400
475
  * @param {string | undefined} releaseId - Release id, or active release when omitted.
401
476
  * @returns {Promise<void>} Resolves when stopped.
@@ -407,6 +482,31 @@ export default class RollbridgeDaemon {
407
482
  if (release === this.activeRelease) this.activeRelease = undefined
408
483
 
409
484
  await release.stop()
485
+ this.pruneStoppedReleases()
486
+ }
487
+
488
+ /**
489
+ * Drains and stops a retired release in the background, then prunes stopped releases.
490
+ * @param {ReleaseGroup} release - Release to drain and stop.
491
+ * @returns {Promise<void>} Resolves once drained, stopped, and pruned.
492
+ */
493
+ async drainAndPrune(release) {
494
+ try {
495
+ await release.drainAndStop(this.config.proxy.drainTimeoutMs)
496
+ } catch (error) {
497
+ this.logger("release drain failed", {error: error instanceof Error ? error.message : String(error), releaseId: release.releaseId})
498
+ } finally {
499
+ this.pruneStoppedReleases()
500
+ }
501
+ }
502
+
503
+ /** @returns {void} Removes stopped releases beyond the retention policy. */
504
+ pruneStoppedReleases() {
505
+ const statuses = [...this.releases.values()].map((release) => release.status())
506
+
507
+ for (const releaseId of releasesToPrune(statuses, this.config.releaseRetention, Date.now())) {
508
+ this.releases.delete(releaseId)
509
+ }
410
510
  }
411
511
 
412
512
  /** @returns {Promise<void>} Stops proxy, control socket, and child processes. */
@@ -446,7 +546,8 @@ export default class RollbridgeDaemon {
446
546
  control: {...this.config.control},
447
547
  proxy: {
448
548
  host: this.config.proxy.host,
449
- port: this.proxyPort ?? this.config.proxy.port
549
+ port: this.proxyPort ?? this.config.proxy.port,
550
+ upstreamHost: this.config.proxy.upstreamHost
450
551
  },
451
552
  releases: [...this.releases.values()].map((release) => release.status()),
452
553
  services: [...this.services.entries()].map(([id, processInstance]) => ({
@@ -485,6 +586,37 @@ function requiredString(value, key) {
485
586
  return value
486
587
  }
487
588
 
589
+ /**
590
+ * @typedef {{releaseId: string, state: string, stoppedAt: string | undefined}} PrunableRelease
591
+ */
592
+
593
+ /**
594
+ * Selects stopped releases to prune by the retention policy, keeping the most recent.
595
+ * @param {PrunableRelease[]} releases - Status of all tracked releases, in deploy order (oldest first).
596
+ * @param {import("./config.js").ReleaseRetentionConfig} policy - Retention policy.
597
+ * @param {number} now - Current epoch milliseconds.
598
+ * @returns {string[]} Release ids to remove.
599
+ */
600
+ export function releasesToPrune(releases, policy, now) {
601
+ const stopped = releases
602
+ .filter((release) => release.state === "stopped")
603
+ .map((release, index) => ({deployOrder: index, releaseId: release.releaseId, stoppedAtMs: release.stoppedAt ? Date.parse(release.stoppedAt) : 0}))
604
+ // Most recent first; ties (same stoppedAt millisecond) prefer the later-deployed release.
605
+ .sort((first, second) => second.stoppedAtMs - first.stoppedAtMs || second.deployOrder - first.deployOrder)
606
+
607
+ /** @type {string[]} */
608
+ const remove = []
609
+
610
+ stopped.forEach((release, index) => {
611
+ const beyondKeep = index >= policy.keep
612
+ const tooOld = policy.maxAgeMs > 0 && release.stoppedAtMs > 0 && now - release.stoppedAtMs > policy.maxAgeMs
613
+
614
+ if (beyondKeep || tooOld) remove.push(release.releaseId)
615
+ })
616
+
617
+ return remove
618
+ }
619
+
488
620
  /**
489
621
  * @typedef {{alive: boolean, application?: string, activeReleaseId?: string | null}} ControlSocketInspection
490
622
  */
@@ -512,7 +644,7 @@ function controlSocketBusyMessage(socketPath, inspection) {
512
644
  * @param {number} [timeoutMs] - How long to wait for a status response before treating the socket as busy.
513
645
  * @returns {Promise<ControlSocketInspection>} Whether the socket is live and, when it is Rollbridge, its identity.
514
646
  */
515
- async function inspectControlSocket(socketPath, timeoutMs = 1000) {
647
+ export async function inspectControlSocket(socketPath, timeoutMs = 1000) {
516
648
  return await new Promise((resolve, reject) => {
517
649
  const socket = net.createConnection(socketPath)
518
650
  let buffer = ""