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.
@@ -0,0 +1,99 @@
1
+ // @ts-check
2
+
3
+ import assert from "node:assert/strict"
4
+ import fs from "node:fs/promises"
5
+ import os from "node:os"
6
+ import path from "node:path"
7
+ import test from "node:test"
8
+ import {fileURLToPath} from "node:url"
9
+ import RollbridgeDaemon from "../src/daemon.js"
10
+ import {normalizeConfig} from "../src/config.js"
11
+ import {formatLogSources, runCli} from "../src/cli.js"
12
+
13
+ const currentDir = path.dirname(fileURLToPath(import.meta.url))
14
+ const dummyAppPath = path.join(currentDir, "fixtures", "dummy-app.js")
15
+
16
+ test("formatLogSources renders a section per process with timestamped lines", () => {
17
+ const output = formatLogSources([
18
+ {id: "web", logs: [{at: "2026-05-22T00:00:00.000Z", line: "listening", stream: "stdout"}], source: "release v1 (active)"},
19
+ {id: "beacon", logs: [], source: "service"}
20
+ ], undefined)
21
+
22
+ assert.match(output, /== web \[release v1 \(active\)\] ==/)
23
+ assert.match(output, /2026-05-22T00:00:00\.000Z \[stdout\] listening/)
24
+ assert.match(output, /== beacon \[service\] ==/)
25
+ assert.match(output, /\(no recent output\)/)
26
+ })
27
+
28
+ test("formatLogSources filters to a single process id", () => {
29
+ const sources = [
30
+ {id: "web", logs: [], source: "release v1 (active)"},
31
+ {id: "beacon", logs: [], source: "service"}
32
+ ]
33
+
34
+ const output = formatLogSources(sources, "web")
35
+
36
+ assert.match(output, /== web /)
37
+ assert.doesNotMatch(output, /beacon/)
38
+ })
39
+
40
+ test("formatLogSources reports when there are no processes or no match", () => {
41
+ assert.equal(formatLogSources([], undefined), "No managed processes.")
42
+ assert.equal(
43
+ formatLogSources([{id: "web", logs: [], source: "release v1 (active)"}], "missing"),
44
+ 'No process found with id "missing".'
45
+ )
46
+ })
47
+
48
+ test("logs CLI prints captured output per managed process", async () => {
49
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-logs-"))
50
+ const socketPath = path.join(root, "rollbridge.sock")
51
+ const rawConfig = {
52
+ application: "rollbridge-logs-test",
53
+ control: {path: socketPath},
54
+ processes: [
55
+ {
56
+ command: `${JSON.stringify(process.execPath)} ${JSON.stringify(dummyAppPath)}`,
57
+ health: {intervalMs: 50, path: "/ping", timeoutMs: 3000},
58
+ id: "web",
59
+ policy: "proxied",
60
+ port: {from: 0, to: 0}
61
+ }
62
+ ],
63
+ proxy: {host: "127.0.0.1", port: 0}
64
+ }
65
+
66
+ // CommonJS config file so the CLI can load it from a temp dir on any Node version.
67
+ await fs.writeFile(path.join(root, "rollbridge.js"), `module.exports = ${JSON.stringify(rawConfig)}\n`)
68
+
69
+ const daemon = new RollbridgeDaemon({config: normalizeConfig(rawConfig), logger: () => {}})
70
+
71
+ await daemon.start()
72
+
73
+ const originalLog = console.log
74
+ /** @type {string[]} */
75
+ const lines = []
76
+
77
+ console.log = (/** @type {string[]} */ ...args) => { lines.push(args.map((arg) => String(arg)).join(" ")) }
78
+
79
+ try {
80
+ await daemon.deploy({releaseId: "v1", releasePath: root, revision: "v1"})
81
+ await runCli(["node", "rollbridge", "logs", "-c", path.join(root, "rollbridge.js")])
82
+
83
+ assert.match(lines.join("\n"), /== web \[release v1 \(active\)\] ==/)
84
+
85
+ lines.length = 0
86
+ await runCli(["node", "rollbridge", "logs", "--json", "-c", path.join(root, "rollbridge.js")])
87
+
88
+ const parsed = JSON.parse(lines.join("\n"))
89
+ const web = parsed.find((/** @type {{id: string, logs: import("../src/managed-process.js").ManagedProcessLog[], source: string}} */ entry) => entry.id === "web")
90
+
91
+ assert.ok(web, "expected a web entry in the JSON output")
92
+ assert.match(web.source, /release v1 \(active\)/)
93
+ assert.ok(Array.isArray(web.logs))
94
+ } finally {
95
+ console.log = originalLog
96
+ await daemon.shutdown()
97
+ await fs.rm(root, {force: true, recursive: true})
98
+ }
99
+ })
@@ -1,9 +1,13 @@
1
1
  // @ts-check
2
2
 
3
3
  import assert from "node:assert/strict"
4
+ import path from "node:path"
4
5
  import test from "node:test"
6
+ import {fileURLToPath} from "node:url"
5
7
  import ManagedProcess from "../src/managed-process.js"
6
8
 
9
+ const crasherPath = path.join(path.dirname(fileURLToPath(import.meta.url)), "fixtures", "crasher.js")
10
+
7
11
  /**
8
12
  * Builds a managed process that is never spawned, for exercising output retention directly.
9
13
  * @param {number} outputLines - Recent output lines to retain and report.
@@ -23,6 +27,21 @@ function buildProcess(outputLines) {
23
27
  })
24
28
  }
25
29
 
30
+ /**
31
+ * @param {() => boolean} callback - Probe.
32
+ * @returns {Promise<void>} Resolves once the probe returns true.
33
+ */
34
+ async function waitFor(callback) {
35
+ const deadline = Date.now() + 3000
36
+
37
+ while (Date.now() < deadline) {
38
+ if (callback()) return
39
+ await new Promise((resolve) => setTimeout(resolve, 25))
40
+ }
41
+
42
+ throw new Error("Timed out waiting for condition")
43
+ }
44
+
26
45
  test("retains and reports only the configured number of recent output lines", () => {
27
46
  const managed = buildProcess(3)
28
47
 
@@ -44,3 +63,44 @@ test("keeps every output line when fewer than the retention limit are produced",
44
63
 
45
64
  assert.deepEqual(logs.map((entry) => entry.line), ["one", "two"])
46
65
  })
66
+
67
+ test("reports zeroed restart and uptime fields before the process starts", () => {
68
+ const status = buildProcess(50).status()
69
+
70
+ assert.equal(status.restarts, 0)
71
+ assert.equal(status.startedAt, undefined)
72
+ assert.equal(status.uptimeMs, undefined)
73
+ assert.equal(status.state, "stopped")
74
+ })
75
+
76
+ test("counts automatic restarts and reports startedAt and uptime while running", async () => {
77
+ const managed = new ManagedProcess({
78
+ command: `${JSON.stringify(process.execPath)} ${JSON.stringify(crasherPath)}`,
79
+ cwd: undefined,
80
+ env: {},
81
+ id: "crasher",
82
+ logger: () => {},
83
+ outputLines: 50,
84
+ restartDelayMs: 20,
85
+ shouldRestart: () => true,
86
+ stopTimeoutMs: 500
87
+ })
88
+
89
+ try {
90
+ await managed.start()
91
+
92
+ const initial = managed.status()
93
+
94
+ assert.equal(initial.restarts, 0)
95
+ assert.equal(initial.state, "running")
96
+ assert.equal(typeof initial.startedAt, "string")
97
+ assert.ok(typeof initial.uptimeMs === "number" && initial.uptimeMs >= 0)
98
+
99
+ // The fixture exits non-zero ~40ms after each start, so it keeps auto-restarting.
100
+ await waitFor(() => managed.status().restarts >= 2)
101
+
102
+ assert.ok(managed.status().restarts >= 2)
103
+ } finally {
104
+ await managed.stop()
105
+ }
106
+ })
@@ -0,0 +1,29 @@
1
+ // @ts-check
2
+
3
+ import assert from "node:assert/strict"
4
+ import fs from "node:fs/promises"
5
+ import path from "node:path"
6
+ import test from "node:test"
7
+ import {fileURLToPath} from "node:url"
8
+
9
+ const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..")
10
+
11
+ test("package.json declares publish metadata", async () => {
12
+ const pkg = JSON.parse(await fs.readFile(path.join(repoRoot, "package.json"), "utf8"))
13
+
14
+ assert.equal(pkg.name, "rollbridge")
15
+ assert.equal(pkg.license, "MIT")
16
+ assert.equal(pkg.homepage, "https://github.com/kaspernj/rollbridge#readme")
17
+ assert.equal(pkg.bugs.url, "https://github.com/kaspernj/rollbridge/issues")
18
+ assert.equal(pkg.repository.type, "git")
19
+ assert.match(pkg.repository.url, /github\.com\/kaspernj\/rollbridge/)
20
+ assert.ok(typeof pkg.author === "string" && pkg.author.length > 0)
21
+ assert.ok(Array.isArray(pkg.keywords) && pkg.keywords.length > 0)
22
+ })
23
+
24
+ test("a LICENSE file matching the declared license exists", async () => {
25
+ const license = await fs.readFile(path.join(repoRoot, "LICENSE"), "utf8")
26
+
27
+ assert.match(license, /MIT License/)
28
+ assert.match(license, /Copyright \(c\) \d{4} kaspernj/)
29
+ })
@@ -0,0 +1,107 @@
1
+ // @ts-check
2
+
3
+ import assert from "node:assert/strict"
4
+ import fs from "node:fs/promises"
5
+ import os from "node:os"
6
+ import path from "node:path"
7
+ import test from "node:test"
8
+ import {fileURLToPath} from "node:url"
9
+ import RollbridgeDaemon, {releasesToPrune} from "../src/daemon.js"
10
+ import {normalizeConfig} from "../src/config.js"
11
+
12
+ const currentDir = path.dirname(fileURLToPath(import.meta.url))
13
+ const dummyAppPath = path.join(currentDir, "fixtures", "dummy-app.js")
14
+
15
+ test("releasesToPrune keeps the most recent stopped releases and never active or draining ones", () => {
16
+ const releases = [
17
+ {releaseId: "active", state: "active", stoppedAt: undefined},
18
+ {releaseId: "draining", state: "draining", stoppedAt: undefined},
19
+ {releaseId: "v3", state: "stopped", stoppedAt: "2026-05-22T00:00:03.000Z"},
20
+ {releaseId: "v2", state: "stopped", stoppedAt: "2026-05-22T00:00:02.000Z"},
21
+ {releaseId: "v1", state: "stopped", stoppedAt: "2026-05-22T00:00:01.000Z"}
22
+ ]
23
+
24
+ const remove = releasesToPrune(releases, {keep: 1, maxAgeMs: 0}, Date.parse("2026-05-22T00:00:10.000Z"))
25
+
26
+ assert.deepEqual([...remove].sort(), ["v1", "v2"])
27
+ })
28
+
29
+ test("releasesToPrune keeps the later-deployed release when stoppedAt ties", () => {
30
+ const sameTime = "2026-05-22T00:00:05.000Z"
31
+ // Deploy order (oldest first): v1 then v2, both stopped in the same millisecond.
32
+ const releases = [
33
+ {releaseId: "v1", state: "stopped", stoppedAt: sameTime},
34
+ {releaseId: "v2", state: "stopped", stoppedAt: sameTime}
35
+ ]
36
+
37
+ const remove = releasesToPrune(releases, {keep: 1, maxAgeMs: 0}, Date.parse("2026-05-22T00:00:10.000Z"))
38
+
39
+ assert.deepEqual(remove, ["v1"])
40
+ })
41
+
42
+ test("releasesToPrune prunes stopped releases older than maxAgeMs", () => {
43
+ const now = Date.parse("2026-05-22T00:01:00.000Z")
44
+ const releases = [
45
+ {releaseId: "fresh", state: "stopped", stoppedAt: new Date(now - 1000).toISOString()},
46
+ {releaseId: "old", state: "stopped", stoppedAt: new Date(now - 60000).toISOString()}
47
+ ]
48
+
49
+ const remove = releasesToPrune(releases, {keep: 100, maxAgeMs: 30000}, now)
50
+
51
+ assert.deepEqual(remove, ["old"])
52
+ })
53
+
54
+ test("the daemon prunes stopped releases beyond the retention count across deploys", async () => {
55
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-retention-"))
56
+ const config = normalizeConfig({
57
+ application: "rollbridge-retention-test",
58
+ control: {path: path.join(root, "rollbridge.sock")},
59
+ processes: [
60
+ {
61
+ command: `${JSON.stringify(process.execPath)} ${JSON.stringify(dummyAppPath)}`,
62
+ health: {intervalMs: 50, path: "/ping", timeoutMs: 3000},
63
+ id: "web",
64
+ policy: "proxied",
65
+ port: {from: 0, to: 0}
66
+ }
67
+ ],
68
+ proxy: {drainTimeoutMs: 200, forceStopTimeoutMs: 200, host: "127.0.0.1", port: 0},
69
+ releaseRetention: {keep: 1}
70
+ })
71
+ const daemon = new RollbridgeDaemon({config, logger: () => {}})
72
+
73
+ await daemon.start()
74
+
75
+ try {
76
+ await daemon.deploy({releaseId: "v1", releasePath: root, revision: "v1"})
77
+ await daemon.deploy({releaseId: "v2", releasePath: root, revision: "v2"})
78
+ await daemon.deploy({releaseId: "v3", releasePath: root, revision: "v3"})
79
+
80
+ // Older stopped releases are pruned once they drain; keep:1 retains only the most recent stopped one.
81
+ await waitFor(() => !daemon.status().releases.some((release) => release.releaseId === "v1"))
82
+
83
+ const ids = daemon.status().releases.map((release) => release.releaseId)
84
+
85
+ assert.ok(ids.includes("v3"), `active release should be retained, got ${JSON.stringify(ids)}`)
86
+ assert.ok(!ids.includes("v1"), `oldest stopped release should be pruned, got ${JSON.stringify(ids)}`)
87
+ assert.ok(ids.length <= 2, `expected at most the active release plus one stopped, got ${JSON.stringify(ids)}`)
88
+ } finally {
89
+ await daemon.shutdown()
90
+ await fs.rm(root, {force: true, recursive: true})
91
+ }
92
+ })
93
+
94
+ /**
95
+ * @param {() => boolean} callback - Probe.
96
+ * @returns {Promise<void>} Resolves once the probe returns true.
97
+ */
98
+ async function waitFor(callback) {
99
+ const deadline = Date.now() + 3000
100
+
101
+ while (Date.now() < deadline) {
102
+ if (callback()) return
103
+ await new Promise((resolve) => setTimeout(resolve, 25))
104
+ }
105
+
106
+ throw new Error("Timed out waiting for condition")
107
+ }
@@ -64,6 +64,57 @@ test("failed health check leaves the previous release active", async () => {
64
64
  }
65
65
  })
66
66
 
67
+ test("wildcard proxy bind host targets release processes through loopback", async () => {
68
+ const fixture = await createFixture({proxyHost: "0.0.0.0"})
69
+ const daemon = await startDaemon(fixture.config)
70
+
71
+ try {
72
+ await daemon.deploy({releaseId: "v1", releasePath: fixture.root, revision: "v1"})
73
+
74
+ const status = daemon.status()
75
+ const release = statusRelease(daemon, "v1")
76
+
77
+ assert.ok(daemon.activeRelease, "expected active release")
78
+ assert.equal(status.proxy.host, "0.0.0.0")
79
+ assert.equal(status.proxy.upstreamHost, "127.0.0.1")
80
+ assert.equal(daemon.activeRelease.proxyTarget().target, `http://127.0.0.1:${release.ports.web}`)
81
+ assert.equal(await fetchText(daemon, "/release"), "v1")
82
+ } finally {
83
+ await daemon.shutdown()
84
+ await fs.rm(fixture.root, {force: true, recursive: true})
85
+ }
86
+ })
87
+
88
+ test("failed release startup logs process output before cleanup", async () => {
89
+ const fixture = await createFixture({webCommand: `${JSON.stringify(process.execPath)} -e "console.log('startup stdout'); console.error('startup stderr'); const http = require('node:http'); http.createServer((_request, response) => { response.writeHead(500); response.end('bad') }).listen(Number(process.env.ROLLBRIDGE_PORT), '127.0.0.1')"`, webHealthTimeoutMs: 500})
90
+ /** @type {Array<{data?: Record<string, import("../src/json.js").JsonValue>, message: string}>} */
91
+ const logs = []
92
+ const daemon = new RollbridgeDaemon({
93
+ config: fixture.config,
94
+ logger: (message, data = {}) => logs.push({data, message})
95
+ })
96
+
97
+ await daemon.start()
98
+
99
+ try {
100
+ await assert.rejects(
101
+ () => daemon.deploy({releaseId: "bad", releasePath: fixture.root, revision: "bad"}),
102
+ /Health check failed/
103
+ )
104
+
105
+ const processStatusLog = logs.find((entry) => entry.message === "release startup process status" && entry.data?.processId === "web")
106
+
107
+ assert.ok(processStatusLog, "expected failed web process diagnostics to be logged")
108
+ assert.ok(processStatusLog.data, "expected diagnostic data")
109
+ assert.ok(Array.isArray(processStatusLog.data.logs), "expected retained process output in diagnostics")
110
+ assert.ok(processStatusLog.data.logs.some((entry) => typeof entry === "object" && entry && "line" in entry && entry.line === "startup stdout"))
111
+ assert.ok(processStatusLog.data.logs.some((entry) => typeof entry === "object" && entry && "line" in entry && entry.line === "startup stderr"))
112
+ } finally {
113
+ await daemon.shutdown()
114
+ await fs.rm(fixture.root, {force: true, recursive: true})
115
+ }
116
+ })
117
+
67
118
  test("singleton processes restart without overlap during deploy", async () => {
68
119
  const fixture = await createFixture({includeSingleton: true})
69
120
  const daemon = await startDaemon(fixture.config)
@@ -285,7 +336,7 @@ test("deploy can ensure the daemon before sending the release command", async ()
285
336
  })
286
337
 
287
338
  /**
288
- * @param {{includeService?: boolean, includeSingleton?: boolean, webDependsOnService?: boolean}} [options] - Fixture options.
339
+ * @param {{includeService?: boolean, includeSingleton?: boolean, proxyHost?: string, webCommand?: string, webDependsOnService?: boolean, webHealthTimeoutMs?: number}} [options] - Fixture options.
289
340
  * @returns {Promise<{config: import("../src/config.js").RollbridgeConfig, root: string, serviceLogPath: string, singletonLogPath: string}>} Fixture data.
290
341
  */
291
342
  async function createFixture(options = {}) {
@@ -309,13 +360,13 @@ async function createFixture(options = {}) {
309
360
  }
310
361
 
311
362
  processes.push({
312
- command: options.webDependsOnService
363
+ command: options.webCommand || (options.webDependsOnService
313
364
  ? `${JSON.stringify(process.execPath)} ${JSON.stringify(dependentAppPath)}`
314
- : `${JSON.stringify(process.execPath)} ${JSON.stringify(dummyAppPath)}`,
365
+ : `${JSON.stringify(process.execPath)} ${JSON.stringify(dummyAppPath)}`),
315
366
  health: {
316
367
  intervalMs: 50,
317
368
  path: "/ping",
318
- timeoutMs: 3000
369
+ timeoutMs: options.webHealthTimeoutMs || 3000
319
370
  },
320
371
  id: "web",
321
372
  policy: "proxied",
@@ -344,7 +395,7 @@ async function createFixture(options = {}) {
344
395
  forceStopTimeoutMs: 500,
345
396
  healthPath: "/ping",
346
397
  healthTimeoutMs: 3000,
347
- host: "127.0.0.1",
398
+ host: options.proxyHost || "127.0.0.1",
348
399
  port: 0
349
400
  }
350
401
  })