From b82eef5974437ac289623d08ec478c0a8b9b0b28 Mon Sep 17 00:00:00 2001 From: Joshua Yoes <37849890+joshuayoes@users.noreply.github.com> Date: Tue, 7 Oct 2025 13:18:38 -0700 Subject: [PATCH 1/3] feat(reactotron-core-server): add CLI support and update package structure - Introduced a new CLI for running the Reactotron server with options for port and TLS configuration. - Updated `package.json` to include a `bin` entry for the CLI. - Added a new `cli.ts` file for argument parsing and server initialization. - Created a new executable script in `bin/reactotron-core-server.js`. - Enhanced the README with usage instructions and examples for the CLI. --- lib/reactotron-core-server/README.md | 48 +++++ .../bin/reactotron-core-server.js | 2 + lib/reactotron-core-server/package.json | 4 +- lib/reactotron-core-server/src/cli.ts | 185 ++++++++++++++++++ yarn.lock | 2 + 5 files changed, 240 insertions(+), 1 deletion(-) create mode 100755 lib/reactotron-core-server/bin/reactotron-core-server.js create mode 100644 lib/reactotron-core-server/src/cli.ts diff --git a/lib/reactotron-core-server/README.md b/lib/reactotron-core-server/README.md index d5758157c..538c01bf6 100644 --- a/lib/reactotron-core-server/README.md +++ b/lib/reactotron-core-server/README.md @@ -6,6 +6,54 @@ It is used by [`reactotron-app`](https://github.com/infinitered/reactotron) and # Usage +## CLI + +You can run the Reactotron server directly via npx: + +```bash +npx reactotron-core-server --port 9090 +``` + +### Options + +- `-p, --port ` - Port to listen on (default: 9090) +- `-h, --help` - Show help message + +### TLS/WSS Options + +- `--wss-pfx ` - Path to PFX certificate file +- `--wss-cert ` - Path to certificate file +- `--wss-key ` - Path to key file +- `--wss-passphrase ` - Passphrase for certificate + +### Examples + +**Basic usage:** + +```bash +npx reactotron-core-server --port 9090 +``` + +**TLS with PFX certificate:** + +```bash +npx reactotron-core-server --port 9090 --wss-pfx ./server.pfx --wss-passphrase mypassphrase +``` + +**TLS with certificate and key files:** + +```bash +npx reactotron-core-server --port 9090 --wss-cert ./cert.pem --wss-key ./key.pem +``` + +**TLS with certificate, key, and passphrase:** + +```bash +npx reactotron-core-server --port 9090 --wss-cert ./cert.pem --wss-key ./key.pem --wss-passphrase mypassphrase +``` + +## Programmatic Usage + ```js import { createServer } from "reactotron-core-server" diff --git a/lib/reactotron-core-server/bin/reactotron-core-server.js b/lib/reactotron-core-server/bin/reactotron-core-server.js new file mode 100755 index 000000000..86d0dc187 --- /dev/null +++ b/lib/reactotron-core-server/bin/reactotron-core-server.js @@ -0,0 +1,2 @@ +#!/usr/bin/env node +require("../dist/commonjs/cli.js").run() diff --git a/lib/reactotron-core-server/package.json b/lib/reactotron-core-server/package.json index 02dc40cb8..a3e6d4327 100644 --- a/lib/reactotron-core-server/package.json +++ b/lib/reactotron-core-server/package.json @@ -11,8 +11,10 @@ "repository": "https://github.com/infinitered/reactotron/tree/master/lib/reactotron-core-server", "files": [ "dist", - "src" + "src", + "bin" ], + "bin": "./bin/reactotron-core-server.js", "main": "./dist/commonjs/index.js", "module": "./dist/module/index.js", "types": "./dist/typescript/commonjs/src/index.d.ts", diff --git a/lib/reactotron-core-server/src/cli.ts b/lib/reactotron-core-server/src/cli.ts new file mode 100644 index 000000000..4fae28d36 --- /dev/null +++ b/lib/reactotron-core-server/src/cli.ts @@ -0,0 +1,185 @@ +import { createServer } from "./index" +import type { ServerOptions, WssServerOptions } from "reactotron-core-contract" + +interface ParsedArgs { + port: number + wss?: WssServerOptions + help: boolean +} + +function parseArgs(args: string[]): ParsedArgs { + const result: ParsedArgs = { + port: 9090, + help: false, + } + + const wssOptions: any = {} + + for (let i = 0; i < args.length; i++) { + const arg = args[i] + + switch (arg) { + case "--help": + case "-h": + result.help = true + break + + case "--port": + case "-p": + if (i + 1 < args.length) { + result.port = parseInt(args[++i], 10) + if (isNaN(result.port)) { + console.error(`Invalid port: ${args[i]}`) + process.exit(1) + } + } else { + console.error("--port requires a value") + process.exit(1) + } + break + + case "--wss-pfx": + if (i + 1 < args.length) { + wssOptions.pathToPfx = args[++i] + } else { + console.error("--wss-pfx requires a path") + process.exit(1) + } + break + + case "--wss-cert": + if (i + 1 < args.length) { + wssOptions.pathToCert = args[++i] + } else { + console.error("--wss-cert requires a path") + process.exit(1) + } + break + + case "--wss-key": + if (i + 1 < args.length) { + wssOptions.pathToKey = args[++i] + } else { + console.error("--wss-key requires a path") + process.exit(1) + } + break + + case "--wss-passphrase": + if (i + 1 < args.length) { + wssOptions.passphrase = args[++i] + } else { + console.error("--wss-passphrase requires a value") + process.exit(1) + } + break + + default: + console.error(`Unknown argument: ${arg}`) + process.exit(1) + } + } + + // Only add wss if at least one wss option was provided + if (Object.keys(wssOptions).length > 0) { + result.wss = wssOptions as WssServerOptions + } + + return result +} + +function printHelp() { + console.log(` +Reactotron Server CLI + +Usage: + reactotron-core-server [options] + +Options: + -p, --port Port to listen on (default: 9090) + -h, --help Show this help message + +TLS/WSS Options: + --wss-pfx Path to PFX certificate file + --wss-cert Path to certificate file + --wss-key Path to key file + --wss-passphrase Passphrase for certificate + +Examples: + reactotron-core-server --port 9090 + reactotron-core-server --port 9090 --wss-pfx ./server.pfx --wss-passphrase mypass + reactotron-core-server --port 9090 --wss-cert ./cert.pem --wss-key ./key.pem +`) +} + +export async function run() { + const args = process.argv.slice(2) + const { port, wss, help } = parseArgs(args) + + if (help) { + printHelp() + process.exit(0) + } + + const options: ServerOptions = { port } + if (wss) { + options.wss = wss + } + + const server = createServer(options) + + server.on("start", () => { + console.log(`✓ Reactotron server listening on port ${port}`) + if (wss) { + console.log("✓ TLS/WSS enabled") + } + }) + + server.on("portUnavailable", (unavailablePort) => { + console.error(`✗ Port ${unavailablePort} is already in use`) + process.exit(1) + }) + + server.on("connect", () => { + console.log("→ Client connecting...") + }) + + server.on("connectionEstablished", (conn) => { + console.log(`✓ Connection established: ${conn.name || "Unknown"} (${conn.address})`) + }) + + server.on("disconnect", (conn) => { + console.log(`✗ Client disconnected: ${conn.name || "Unknown"}`) + }) + + server.on("command", (cmd) => { + // Log commands in a compact format + if (cmd.type !== "client.intro") { + console.log(` ${cmd.type}`) + } + }) + + server.on("stop", () => { + console.log("✓ Reactotron server stopped") + }) + + // Handle graceful shutdown + const shutdown = () => { + console.log("\n→ Shutting down...") + server.stop() + process.exit(0) + } + + process.on("SIGINT", shutdown) + process.on("SIGTERM", shutdown) + + server.start() +} + +// Allow running directly with ts-node or node +if (require.main === module) { + run().catch((error) => { + console.error("Error starting server:", error) + process.exit(1) + }) +} diff --git a/yarn.lock b/yarn.lock index 0cc22b228..05d33250f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -25759,6 +25759,8 @@ __metadata: ts-jest: "npm:^29.1.1" typescript: "npm:^4.9.5" ws: "npm:^8.14.2" + bin: + reactotron-core-server: ./bin/reactotron-core-server.js languageName: unknown linkType: soft From d662a40b3423986eda99746e9d30055a2cc1fc99 Mon Sep 17 00:00:00 2001 From: Joshua Yoes <37849890+joshuayoes@users.noreply.github.com> Date: Tue, 7 Oct 2025 13:22:52 -0700 Subject: [PATCH 2/3] test(reactotron-core-server): add CLI tests for help, server start, and error handling - Introduced a new test suite for the Reactotron CLI, covering help command, server startup on default and custom ports, graceful shutdown, and error handling for invalid arguments. - Implemented tests to ensure correct behavior for various CLI options and error scenarios. - Enhanced test coverage for TLS/WSS options and argument validation. --- lib/reactotron-core-server/test/cli.test.ts | 253 ++++++++++++++++++++ 1 file changed, 253 insertions(+) create mode 100644 lib/reactotron-core-server/test/cli.test.ts diff --git a/lib/reactotron-core-server/test/cli.test.ts b/lib/reactotron-core-server/test/cli.test.ts new file mode 100644 index 000000000..bbfb1e384 --- /dev/null +++ b/lib/reactotron-core-server/test/cli.test.ts @@ -0,0 +1,253 @@ +import { spawn, ChildProcess } from "child_process" +import { join } from "path" +import { getPort } from "get-port-please" + +const BIN_PATH = join(__dirname, "..", "bin", "reactotron-core-server.js") + +interface SpawnResult { + stdout: string + stderr: string + exitCode: number | null +} + +/** + * Helper to spawn the CLI and collect output + */ +function spawnCli(args: string[], timeout = 3000): Promise { + return new Promise((resolve) => { + const child = spawn("node", [BIN_PATH, ...args]) + let stdout = "" + let stderr = "" + + child.stdout?.on("data", (data) => { + stdout += data.toString() + }) + + child.stderr?.on("data", (data) => { + stderr += data.toString() + }) + + const timer = setTimeout(() => { + child.kill("SIGTERM") + }, timeout) + + child.on("close", (exitCode) => { + clearTimeout(timer) + resolve({ stdout, stderr, exitCode }) + }) + }) +} + +/** + * Helper to spawn the CLI and keep it running until manually killed + */ +function spawnCliKeepAlive(args: string[]): { + child: ChildProcess + stdout: string[] + stderr: string[] + waitForOutput: (pattern: string | RegExp, timeout?: number) => Promise + kill: () => Promise +} { + const child = spawn("node", [BIN_PATH, ...args]) + const stdout: string[] = [] + const stderr: string[] = [] + + child.stdout?.on("data", (data) => { + stdout.push(data.toString()) + }) + + child.stderr?.on("data", (data) => { + stderr.push(data.toString()) + }) + + const waitForOutput = (pattern: string | RegExp, timeout = 5000): Promise => { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + reject(new Error(`Timeout waiting for pattern: ${pattern}`)) + }, timeout) + + const checkOutput = () => { + const fullOutput = stdout.join("") + const matches = + typeof pattern === "string" ? fullOutput.includes(pattern) : pattern.test(fullOutput) + if (matches) { + clearTimeout(timer) + resolve() + } + } + + // Check existing output + checkOutput() + + // Listen for new output + const listener = () => checkOutput() + child.stdout?.on("data", listener) + + // Clean up listener when done + setTimeout(() => { + child.stdout?.off("data", listener) + }, timeout) + }) + } + + const kill = (): Promise => { + return new Promise((resolve) => { + child.on("close", (exitCode) => { + resolve(exitCode) + }) + child.kill("SIGTERM") + }) + } + + return { child, stdout, stderr, waitForOutput, kill } +} + +describe("CLI bin", () => { + describe("--help", () => { + it("displays help text and exits with code 0", async () => { + const result = await spawnCli(["--help"], 2000) + + expect(result.exitCode).toBe(0) + expect(result.stdout).toContain("Reactotron Server CLI") + expect(result.stdout).toContain("Usage:") + expect(result.stdout).toContain("--port") + expect(result.stdout).toContain("--help") + expect(result.stdout).toContain("TLS/WSS Options") + expect(result.stdout).toContain("Examples:") + }) + + it("displays help with -h shorthand", async () => { + const result = await spawnCli(["-h"], 2000) + + expect(result.exitCode).toBe(0) + expect(result.stdout).toContain("Reactotron Server CLI") + }) + }) + + describe("server start", () => { + it("starts server on default port 9090", async () => { + const process = spawnCliKeepAlive([]) + + await process.waitForOutput("Reactotron server listening on port 9090") + + const fullOutput = process.stdout.join("") + expect(fullOutput).toContain("✓ Reactotron server listening on port 9090") + + const exitCode = await process.kill() + expect(exitCode).toBe(0) + }, 10000) + + it("starts server on custom port", async () => { + const port = await getPort({ random: true }) + const process = spawnCliKeepAlive(["--port", port.toString()]) + + await process.waitForOutput(`Reactotron server listening on port ${port}`) + + const fullOutput = process.stdout.join("") + expect(fullOutput).toContain(`✓ Reactotron server listening on port ${port}`) + + await process.kill() + }, 10000) + + it("starts server with -p shorthand", async () => { + const port = await getPort({ random: true }) + const process = spawnCliKeepAlive(["-p", port.toString()]) + + await process.waitForOutput(`Reactotron server listening on port ${port}`) + + const fullOutput = process.stdout.join("") + expect(fullOutput).toContain(`✓ Reactotron server listening on port ${port}`) + + await process.kill() + }, 10000) + + it("handles graceful shutdown", async () => { + const port = await getPort({ random: true }) + const process = spawnCliKeepAlive(["--port", port.toString()]) + + await process.waitForOutput(`Reactotron server listening on port ${port}`) + + const exitCode = await process.kill() + + const fullOutput = process.stdout.join("") + expect(fullOutput).toContain("→ Shutting down...") + expect(fullOutput).toContain("✓ Reactotron server stopped") + expect(exitCode).toBe(0) + }, 10000) + }) + + describe("error handling", () => { + it("exits with error on invalid port", async () => { + const result = await spawnCli(["--port", "invalid"], 2000) + + expect(result.exitCode).toBe(1) + expect(result.stderr).toContain("Invalid port") + }) + + it("exits with error when port is missing value", async () => { + const result = await spawnCli(["--port"], 2000) + + expect(result.exitCode).toBe(1) + expect(result.stderr).toContain("--port requires a value") + }) + + it("exits with error on unknown argument", async () => { + const result = await spawnCli(["--unknown-flag"], 2000) + + expect(result.exitCode).toBe(1) + expect(result.stderr).toContain("Unknown argument: --unknown-flag") + }) + + it("exits with error when port is unavailable", async () => { + const port = await getPort({ random: true }) + + // Start first server + const firstProcess = spawnCliKeepAlive(["--port", port.toString()]) + await firstProcess.waitForOutput(`Reactotron server listening on port ${port}`) + + // Try to start second server on same port + const result = await spawnCli(["--port", port.toString()], 3000) + + expect(result.exitCode).toBe(1) + expect(result.stderr).toContain(`Port ${port} is already in use`) + + // Clean up first server + await firstProcess.kill() + }, 15000) + }) + + describe("TLS/WSS options", () => { + it("accepts wss-pfx flag without error", async () => { + // Note: This will fail to start due to invalid cert, but we're testing arg parsing + const result = await spawnCli(["--port", "9999", "--wss-pfx", "./fake.pfx"], 2000) + + // Should not error on unknown argument + expect(result.stderr).not.toContain("Unknown argument") + }) + + it("accepts wss-cert and wss-key flags", async () => { + const result = await spawnCli( + ["--port", "9999", "--wss-cert", "./fake.pem", "--wss-key", "./fake.key"], + 2000 + ) + + expect(result.stderr).not.toContain("Unknown argument") + }) + + it("accepts wss-passphrase flag", async () => { + const result = await spawnCli( + ["--port", "9999", "--wss-pfx", "./fake.pfx", "--wss-passphrase", "test"], + 2000 + ) + + expect(result.stderr).not.toContain("Unknown argument") + }) + + it("exits with error when wss-pfx is missing value", async () => { + const result = await spawnCli(["--wss-pfx"], 2000) + + expect(result.exitCode).toBe(1) + expect(result.stderr).toContain("--wss-pfx requires a path") + }) + }) +}) From 35828b948829e5ac36da02bcb5b76f65b6dc69fe Mon Sep 17 00:00:00 2001 From: Joshua Yoes <37849890+joshuayoes@users.noreply.github.com> Date: Tue, 7 Oct 2025 13:29:05 -0700 Subject: [PATCH 3/3] fix(package.validate): update validation for "files" field to include "bin" for reactotron-core-server - Adjusted the validation logic to dynamically check the "files" field in package.json based on the workspace name. - Ensured that the expected files array includes "bin" for the reactotron-core-server package, improving validation accuracy. --- scripts/package.validate.mjs | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/scripts/package.validate.mjs b/scripts/package.validate.mjs index 13e1eaa83..b4119f2cb 100644 --- a/scripts/package.validate.mjs +++ b/scripts/package.validate.mjs @@ -94,14 +94,17 @@ for (const workspacePath of workspacePaths) { // assert "files" field should be ["dist", "src"] // LICENSE, README, and package.json are implicitly included https://docs.npmjs.com/cli/v10/configuring-npm/package-json#files + // Exception: reactotron-core-server also includes "bin" directory + const expectedFiles = + workspaceName === "reactotron-core-server" ? ["dist", "src", "bin"] : ["dist", "src"] + if ( !Array.isArray(packageJson.files) || - packageJson.files.length !== 2 || - packageJson.files[0] !== "dist" || - packageJson.files[1] !== "src" + packageJson.files.length !== expectedFiles.length || + !expectedFiles.every((file, index) => packageJson.files[index] === file) ) { errors.push( - `Invalid files field: ${JSON.stringify(packageJson.files)} (expected ["dist", "src"])` + `Invalid files field: ${JSON.stringify(packageJson.files)} (expected ${JSON.stringify(expectedFiles)})` ) }