@moejay/wrightty 0.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/.github/workflows/ci.yml +90 -0
  2. package/.github/workflows/release.yml +177 -0
  3. package/Cargo.lock +2662 -0
  4. package/Cargo.toml +38 -0
  5. package/PROTOCOL.md +1351 -0
  6. package/README.md +386 -0
  7. package/agents/ceo/AGENTS.md +24 -0
  8. package/agents/ceo/HEARTBEAT.md +72 -0
  9. package/agents/ceo/SOUL.md +33 -0
  10. package/agents/ceo/TOOLS.md +3 -0
  11. package/agents/founding-engineer/AGENTS.md +44 -0
  12. package/crates/wrightty/Cargo.toml +43 -0
  13. package/crates/wrightty/src/client_cmds.rs +366 -0
  14. package/crates/wrightty/src/discover.rs +78 -0
  15. package/crates/wrightty/src/main.rs +100 -0
  16. package/crates/wrightty/src/server.rs +100 -0
  17. package/crates/wrightty/src/term.rs +338 -0
  18. package/crates/wrightty-bridge-ghostty/Cargo.toml +27 -0
  19. package/crates/wrightty-bridge-ghostty/src/ghostty.rs +422 -0
  20. package/crates/wrightty-bridge-ghostty/src/lib.rs +2 -0
  21. package/crates/wrightty-bridge-ghostty/src/main.rs +146 -0
  22. package/crates/wrightty-bridge-ghostty/src/rpc.rs +307 -0
  23. package/crates/wrightty-bridge-kitty/Cargo.toml +26 -0
  24. package/crates/wrightty-bridge-kitty/src/kitty.rs +269 -0
  25. package/crates/wrightty-bridge-kitty/src/lib.rs +2 -0
  26. package/crates/wrightty-bridge-kitty/src/main.rs +124 -0
  27. package/crates/wrightty-bridge-kitty/src/rpc.rs +304 -0
  28. package/crates/wrightty-bridge-tmux/Cargo.toml +26 -0
  29. package/crates/wrightty-bridge-tmux/src/lib.rs +2 -0
  30. package/crates/wrightty-bridge-tmux/src/main.rs +119 -0
  31. package/crates/wrightty-bridge-tmux/src/rpc.rs +291 -0
  32. package/crates/wrightty-bridge-tmux/src/tmux.rs +215 -0
  33. package/crates/wrightty-bridge-wezterm/Cargo.toml +26 -0
  34. package/crates/wrightty-bridge-wezterm/src/lib.rs +2 -0
  35. package/crates/wrightty-bridge-wezterm/src/main.rs +119 -0
  36. package/crates/wrightty-bridge-wezterm/src/rpc.rs +339 -0
  37. package/crates/wrightty-bridge-wezterm/src/wezterm.rs +190 -0
  38. package/crates/wrightty-bridge-zellij/Cargo.toml +27 -0
  39. package/crates/wrightty-bridge-zellij/src/lib.rs +2 -0
  40. package/crates/wrightty-bridge-zellij/src/main.rs +125 -0
  41. package/crates/wrightty-bridge-zellij/src/rpc.rs +328 -0
  42. package/crates/wrightty-bridge-zellij/src/zellij.rs +199 -0
  43. package/crates/wrightty-client/Cargo.toml +16 -0
  44. package/crates/wrightty-client/src/client.rs +254 -0
  45. package/crates/wrightty-client/src/lib.rs +2 -0
  46. package/crates/wrightty-core/Cargo.toml +21 -0
  47. package/crates/wrightty-core/src/input.rs +212 -0
  48. package/crates/wrightty-core/src/lib.rs +4 -0
  49. package/crates/wrightty-core/src/screen.rs +325 -0
  50. package/crates/wrightty-core/src/session.rs +249 -0
  51. package/crates/wrightty-core/src/session_manager.rs +77 -0
  52. package/crates/wrightty-protocol/Cargo.toml +13 -0
  53. package/crates/wrightty-protocol/src/error.rs +8 -0
  54. package/crates/wrightty-protocol/src/events.rs +138 -0
  55. package/crates/wrightty-protocol/src/lib.rs +4 -0
  56. package/crates/wrightty-protocol/src/methods.rs +321 -0
  57. package/crates/wrightty-protocol/src/types.rs +201 -0
  58. package/crates/wrightty-server/Cargo.toml +23 -0
  59. package/crates/wrightty-server/src/lib.rs +2 -0
  60. package/crates/wrightty-server/src/main.rs +65 -0
  61. package/crates/wrightty-server/src/rpc.rs +455 -0
  62. package/crates/wrightty-server/src/state.rs +39 -0
  63. package/examples/basic_command.py +53 -0
  64. package/examples/interactive_tui.py +86 -0
  65. package/examples/record_session.py +96 -0
  66. package/install.sh +81 -0
  67. package/package.json +24 -0
  68. package/sdks/node/package-lock.json +85 -0
  69. package/sdks/node/package.json +44 -0
  70. package/sdks/node/src/client.ts +94 -0
  71. package/sdks/node/src/index.ts +19 -0
  72. package/sdks/node/src/terminal.ts +258 -0
  73. package/sdks/node/src/types.ts +105 -0
  74. package/sdks/node/tsconfig.json +17 -0
  75. package/sdks/python/README.md +96 -0
  76. package/sdks/python/pyproject.toml +42 -0
  77. package/sdks/python/wrightty/__init__.py +6 -0
  78. package/sdks/python/wrightty/cli.py +210 -0
  79. package/sdks/python/wrightty/client.py +136 -0
  80. package/sdks/python/wrightty/mcp_server.py +434 -0
  81. package/sdks/python/wrightty/terminal.py +333 -0
  82. package/skills/wrightty/SKILL.md +261 -0
  83. package/src/lib.rs +1 -0
  84. package/tests/integration_test.rs +618 -0
@@ -0,0 +1,366 @@
1
+ use std::collections::HashMap;
2
+
3
+ use clap::{Args, Subcommand};
4
+ use wrightty_client::WrighttyClient;
5
+ use wrightty_protocol::types::*;
6
+
7
+ use crate::server::{PORT_RANGE_START, PORT_RANGE_END};
8
+
9
+ /// Convert `Box<dyn Error>` from WrighttyClient into anyhow::Error.
10
+ fn e<T>(r: Result<T, Box<dyn std::error::Error>>) -> anyhow::Result<T> {
11
+ r.map_err(|e| anyhow::anyhow!("{e}"))
12
+ }
13
+
14
+ // --- Shared connection logic ---
15
+
16
+ #[derive(Args, Clone)]
17
+ pub struct ConnectOpts {
18
+ /// Server URL (default: auto-discover)
19
+ #[arg(long, global = true)]
20
+ url: Option<String>,
21
+
22
+ /// Session ID (default: first available)
23
+ #[arg(long, global = true)]
24
+ session: Option<String>,
25
+ }
26
+
27
+ async fn connect(opts: &ConnectOpts) -> anyhow::Result<(WrighttyClient, String)> {
28
+ let url = match &opts.url {
29
+ Some(u) => u.clone(),
30
+ None => discover_first().await?,
31
+ };
32
+
33
+ let client = e(WrighttyClient::connect(&url).await)
34
+ .map_err(|err| anyhow::anyhow!("Failed to connect to {url}: {err}"))?;
35
+
36
+ let session_id = match &opts.session {
37
+ Some(s) => s.clone(),
38
+ None => {
39
+ let sessions = e(client.session_list().await)?;
40
+ sessions
41
+ .first()
42
+ .map(|s| s.session_id.clone())
43
+ .unwrap_or_else(|| "0".to_string())
44
+ }
45
+ };
46
+
47
+ Ok((client, session_id))
48
+ }
49
+
50
+ async fn discover_first() -> anyhow::Result<String> {
51
+ use jsonrpsee::core::client::ClientT;
52
+ use jsonrpsee::core::params::ObjectParams;
53
+ use jsonrpsee::ws_client::WsClientBuilder;
54
+
55
+ for port in PORT_RANGE_START..=PORT_RANGE_END {
56
+ let url = format!("ws://127.0.0.1:{port}");
57
+ let Ok(client) = WsClientBuilder::default()
58
+ .connection_timeout(std::time::Duration::from_millis(100))
59
+ .build(&url)
60
+ .await
61
+ else {
62
+ continue;
63
+ };
64
+ let Ok(_): Result<serde_json::Value, _> =
65
+ client.request("Wrightty.getInfo", ObjectParams::new()).await
66
+ else {
67
+ continue;
68
+ };
69
+ return Ok(url);
70
+ }
71
+
72
+ anyhow::bail!(
73
+ "No wrightty server found on ports {PORT_RANGE_START}-{PORT_RANGE_END}.\n\
74
+ Start one with:\n \
75
+ wrightty term --headless\n \
76
+ wrightty term --bridge-tmux\n \
77
+ wrightty term --bridge-wezterm"
78
+ )
79
+ }
80
+
81
+ // --- Commands ---
82
+
83
+ #[derive(Args)]
84
+ pub struct RunArgs {
85
+ /// The command to run
86
+ command: String,
87
+
88
+ /// Timeout in seconds
89
+ #[arg(long, default_value_t = 30)]
90
+ timeout: u64,
91
+
92
+ #[command(flatten)]
93
+ connect: ConnectOpts,
94
+ }
95
+
96
+ pub async fn run_cmd(args: RunArgs) -> anyhow::Result<()> {
97
+ let (client, session_id) = connect(&args.connect).await?;
98
+
99
+ // Send command
100
+ e(client
101
+ .send_text(&session_id, &format!("{}\n", args.command))
102
+ .await)?;
103
+
104
+ // Wait for prompt
105
+ let _result = e(client
106
+ .wait_for_text(&session_id, r"[$#>%]\s*$", true, args.timeout * 1000)
107
+ .await)?;
108
+
109
+ // Read screen and extract output
110
+ let text = e(client.get_text(&session_id).await)?;
111
+ let lines: Vec<&str> = text.trim().split('\n').collect();
112
+
113
+ let mut output_lines = Vec::new();
114
+ let mut found_cmd = false;
115
+ for line in &lines {
116
+ if !found_cmd {
117
+ if line.contains(&args.command) {
118
+ found_cmd = true;
119
+ }
120
+ continue;
121
+ }
122
+ if is_prompt_line(line) {
123
+ break;
124
+ }
125
+ output_lines.push(*line);
126
+ }
127
+
128
+ if output_lines.is_empty() && !found_cmd {
129
+ println!("{text}");
130
+ } else {
131
+ println!("{}", output_lines.join("\n"));
132
+ }
133
+
134
+ Ok(())
135
+ }
136
+
137
+ fn is_prompt_line(text: &str) -> bool {
138
+ let t = text.trim_end();
139
+ t.ends_with('$') || t.ends_with('#') || t.ends_with('>') || t.ends_with('%')
140
+ }
141
+
142
+ #[derive(Args)]
143
+ pub struct ReadArgs {
144
+ #[command(flatten)]
145
+ connect: ConnectOpts,
146
+ }
147
+
148
+ pub async fn read_cmd(args: ReadArgs) -> anyhow::Result<()> {
149
+ let (client, session_id) = connect(&args.connect).await?;
150
+ let text = e(client.get_text(&session_id).await)?;
151
+ println!("{text}");
152
+ Ok(())
153
+ }
154
+
155
+ #[derive(Args)]
156
+ pub struct SendTextArgs {
157
+ /// Text to send (use \\n for newline)
158
+ text: String,
159
+
160
+ #[command(flatten)]
161
+ connect: ConnectOpts,
162
+ }
163
+
164
+ pub async fn send_text_cmd(args: SendTextArgs) -> anyhow::Result<()> {
165
+ let (client, session_id) = connect(&args.connect).await?;
166
+ let text = args.text.replace("\\n", "\n");
167
+ e(client.send_text(&session_id, &text).await)?;
168
+ Ok(())
169
+ }
170
+
171
+ #[derive(Args)]
172
+ pub struct SendKeysArgs {
173
+ /// Keys to send (e.g. Ctrl+c Escape Enter)
174
+ keys: Vec<String>,
175
+
176
+ #[command(flatten)]
177
+ connect: ConnectOpts,
178
+ }
179
+
180
+ pub async fn send_keys_cmd(args: SendKeysArgs) -> anyhow::Result<()> {
181
+ let (client, session_id) = connect(&args.connect).await?;
182
+ let keys: Vec<KeyInput> = args
183
+ .keys
184
+ .iter()
185
+ .map(|k| KeyInput::Shorthand(k.clone()))
186
+ .collect();
187
+ e(client.send_keys(&session_id, keys).await)?;
188
+ Ok(())
189
+ }
190
+
191
+ #[derive(Args)]
192
+ pub struct ScreenshotArgs {
193
+ /// Format: text, svg, png, json
194
+ #[arg(long, default_value = "text")]
195
+ format: String,
196
+
197
+ /// Output file (default: stdout)
198
+ #[arg(short, long)]
199
+ output: Option<String>,
200
+
201
+ #[command(flatten)]
202
+ connect: ConnectOpts,
203
+ }
204
+
205
+ pub async fn screenshot_cmd(args: ScreenshotArgs) -> anyhow::Result<()> {
206
+ let (client, session_id) = connect(&args.connect).await?;
207
+
208
+ let format = match args.format.as_str() {
209
+ "text" => ScreenshotFormat::Text,
210
+ "svg" => ScreenshotFormat::Svg,
211
+ "png" => ScreenshotFormat::Png,
212
+ "json" => ScreenshotFormat::Json,
213
+ other => anyhow::bail!("Unknown format: {other}"),
214
+ };
215
+
216
+ let result = e(client.screenshot(&session_id, format).await)?;
217
+
218
+ match args.output {
219
+ Some(path) => {
220
+ std::fs::write(&path, &result.data)?;
221
+ println!("Screenshot saved to {path}");
222
+ }
223
+ None => print!("{}", result.data),
224
+ }
225
+ Ok(())
226
+ }
227
+
228
+ #[derive(Args)]
229
+ pub struct WaitForArgs {
230
+ /// Pattern to wait for
231
+ pattern: String,
232
+
233
+ /// Timeout in seconds
234
+ #[arg(long, default_value_t = 30)]
235
+ timeout: u64,
236
+
237
+ /// Treat pattern as regex
238
+ #[arg(long)]
239
+ regex: bool,
240
+
241
+ #[command(flatten)]
242
+ connect: ConnectOpts,
243
+ }
244
+
245
+ pub async fn wait_for_cmd(args: WaitForArgs) -> anyhow::Result<()> {
246
+ let (client, session_id) = connect(&args.connect).await?;
247
+
248
+ let result = e(client
249
+ .wait_for_text(
250
+ &session_id,
251
+ &args.pattern,
252
+ args.regex,
253
+ args.timeout * 1000,
254
+ )
255
+ .await)?;
256
+
257
+ if result.found {
258
+ let text = e(client.get_text(&session_id).await)?;
259
+ println!("{text}");
260
+ } else {
261
+ eprintln!(
262
+ "Timeout: '{}' not found within {}s",
263
+ args.pattern, args.timeout
264
+ );
265
+ std::process::exit(1);
266
+ }
267
+ Ok(())
268
+ }
269
+
270
+ #[derive(Args)]
271
+ pub struct InfoArgs {
272
+ #[command(flatten)]
273
+ connect: ConnectOpts,
274
+ }
275
+
276
+ pub async fn info_cmd(args: InfoArgs) -> anyhow::Result<()> {
277
+ let (client, _) = connect(&args.connect).await?;
278
+ let info = e(client.get_info().await)?;
279
+ println!("{}", serde_json::to_string_pretty(&info)?);
280
+ Ok(())
281
+ }
282
+
283
+ #[derive(Args)]
284
+ pub struct SizeArgs {
285
+ #[command(flatten)]
286
+ connect: ConnectOpts,
287
+ }
288
+
289
+ pub async fn size_cmd(args: SizeArgs) -> anyhow::Result<()> {
290
+ let (client, session_id) = connect(&args.connect).await?;
291
+ let (cols, rows) = e(client.get_size(&session_id).await)?;
292
+ println!("{cols}x{rows}");
293
+ Ok(())
294
+ }
295
+
296
+ #[derive(Args)]
297
+ pub struct SessionArgs {
298
+ #[command(subcommand)]
299
+ command: SessionCommand,
300
+
301
+ #[command(flatten)]
302
+ connect: ConnectOpts,
303
+ }
304
+
305
+ #[derive(Subcommand)]
306
+ pub enum SessionCommand {
307
+ /// List all sessions
308
+ List,
309
+ /// Create a new session (headless mode only)
310
+ Create {
311
+ /// Shell to use
312
+ #[arg(long)]
313
+ shell: Option<String>,
314
+ /// Columns
315
+ #[arg(long, default_value_t = 120)]
316
+ cols: u16,
317
+ /// Rows
318
+ #[arg(long, default_value_t = 40)]
319
+ rows: u16,
320
+ },
321
+ /// Destroy a session
322
+ Destroy {
323
+ /// Session ID to destroy
324
+ id: String,
325
+ },
326
+ }
327
+
328
+ pub async fn session_cmd(args: SessionArgs) -> anyhow::Result<()> {
329
+ let (client, _) = connect(&args.connect).await?;
330
+
331
+ match args.command {
332
+ SessionCommand::List => {
333
+ let sessions = e(client.session_list().await)?;
334
+ if sessions.is_empty() {
335
+ println!("No sessions.");
336
+ } else {
337
+ for s in sessions {
338
+ println!(
339
+ " {} {}x{} {}",
340
+ s.session_id,
341
+ s.cols,
342
+ s.rows,
343
+ s.title
344
+ );
345
+ }
346
+ }
347
+ }
348
+ SessionCommand::Create { shell, cols, rows } => {
349
+ let params = wrightty_protocol::methods::SessionCreateParams {
350
+ shell,
351
+ args: vec![],
352
+ cols,
353
+ rows,
354
+ env: HashMap::new(),
355
+ cwd: None,
356
+ };
357
+ let id = e(client.session_create(params).await)?;
358
+ println!("{id}");
359
+ }
360
+ SessionCommand::Destroy { id } => {
361
+ e(client.session_destroy(&id).await)?;
362
+ println!("Destroyed session {id}");
363
+ }
364
+ }
365
+ Ok(())
366
+ }
@@ -0,0 +1,78 @@
1
+ use clap::Args;
2
+
3
+ use crate::server::{PORT_RANGE_START, PORT_RANGE_END};
4
+
5
+ #[derive(Args)]
6
+ pub struct DiscoverArgs {
7
+ /// Host to scan
8
+ #[arg(long, default_value = "127.0.0.1")]
9
+ host: String,
10
+
11
+ /// Output as JSON
12
+ #[arg(long)]
13
+ json: bool,
14
+ }
15
+
16
+ pub async fn run(args: DiscoverArgs) -> anyhow::Result<()> {
17
+ let mut found = Vec::new();
18
+
19
+ for port in PORT_RANGE_START..=PORT_RANGE_END {
20
+ let url = format!("ws://{}:{}", args.host, port);
21
+ match try_connect(&url).await {
22
+ Some(info) => found.push(info),
23
+ None => continue,
24
+ }
25
+ }
26
+
27
+ if args.json {
28
+ println!("{}", serde_json::to_string_pretty(&found)?);
29
+ return Ok(());
30
+ }
31
+
32
+ if found.is_empty() {
33
+ println!("No wrightty servers found on ports {PORT_RANGE_START}-{PORT_RANGE_END}.");
34
+ println!();
35
+ println!("Start one with:");
36
+ println!(" wrightty term --headless");
37
+ println!(" wrightty term --bridge-tmux");
38
+ println!(" wrightty term --bridge-wezterm");
39
+ return Ok(());
40
+ }
41
+
42
+ for s in &found {
43
+ println!(
44
+ " {} {} v{}",
45
+ s["url"].as_str().unwrap_or(""),
46
+ s["implementation"].as_str().unwrap_or(""),
47
+ s["version"].as_str().unwrap_or(""),
48
+ );
49
+ }
50
+
51
+ Ok(())
52
+ }
53
+
54
+ async fn try_connect(url: &str) -> Option<serde_json::Value> {
55
+ use jsonrpsee::core::client::ClientT;
56
+ use jsonrpsee::core::params::ObjectParams;
57
+ use jsonrpsee::ws_client::WsClientBuilder;
58
+
59
+ let client = WsClientBuilder::default()
60
+ .connection_timeout(std::time::Duration::from_millis(200))
61
+ .build(url)
62
+ .await
63
+ .ok()?;
64
+
65
+ let info: serde_json::Value = client
66
+ .request("Wrightty.getInfo", ObjectParams::new())
67
+ .await
68
+ .ok()?;
69
+
70
+ let mut result = serde_json::json!({ "url": url });
71
+ if let Some(obj) = info.as_object() {
72
+ for (k, v) in obj {
73
+ result[k] = v.clone();
74
+ }
75
+ }
76
+
77
+ Some(result)
78
+ }
@@ -0,0 +1,100 @@
1
+ use clap::{Parser, Subcommand};
2
+ use tracing_subscriber::EnvFilter;
3
+
4
+ mod discover;
5
+ mod server;
6
+ mod term;
7
+
8
+ #[cfg(feature = "client")]
9
+ mod client_cmds;
10
+
11
+ #[derive(Parser)]
12
+ #[command(
13
+ name = "wrightty",
14
+ about = "Wrightty — Playwright for terminals",
15
+ version,
16
+ after_help = "Examples:\n wrightty term --headless Start headless terminal server\n wrightty term --bridge-tmux Bridge to a running tmux session\n wrightty run \"ls -la\" Run a command and print output\n wrightty discover Find running wrightty servers"
17
+ )]
18
+ struct Cli {
19
+ #[command(subcommand)]
20
+ command: Commands,
21
+ }
22
+
23
+ #[derive(Subcommand)]
24
+ enum Commands {
25
+ /// Start a wrightty terminal server or bridge
26
+ Term(term::TermArgs),
27
+
28
+ /// Discover running wrightty servers
29
+ Discover(discover::DiscoverArgs),
30
+
31
+ /// Run a command and print its output
32
+ #[cfg(feature = "client")]
33
+ Run(client_cmds::RunArgs),
34
+
35
+ /// Read the current terminal screen
36
+ #[cfg(feature = "client")]
37
+ Read(client_cmds::ReadArgs),
38
+
39
+ /// Send raw text to the terminal
40
+ #[cfg(feature = "client")]
41
+ SendText(client_cmds::SendTextArgs),
42
+
43
+ /// Send keystrokes to the terminal
44
+ #[cfg(feature = "client")]
45
+ SendKeys(client_cmds::SendKeysArgs),
46
+
47
+ /// Take a terminal screenshot
48
+ #[cfg(feature = "client")]
49
+ Screenshot(client_cmds::ScreenshotArgs),
50
+
51
+ /// Wait until text appears on screen
52
+ #[cfg(feature = "client")]
53
+ WaitFor(client_cmds::WaitForArgs),
54
+
55
+ /// Show server info and capabilities
56
+ #[cfg(feature = "client")]
57
+ Info(client_cmds::InfoArgs),
58
+
59
+ /// Get terminal dimensions
60
+ #[cfg(feature = "client")]
61
+ Size(client_cmds::SizeArgs),
62
+
63
+ /// Manage sessions
64
+ #[cfg(feature = "client")]
65
+ Session(client_cmds::SessionArgs),
66
+ }
67
+
68
+ #[tokio::main]
69
+ async fn main() -> anyhow::Result<()> {
70
+ tracing_subscriber::fmt()
71
+ .with_env_filter(
72
+ EnvFilter::from_default_env().add_directive("wrightty=info".parse()?),
73
+ )
74
+ .init();
75
+
76
+ let cli = Cli::parse();
77
+
78
+ match cli.command {
79
+ Commands::Term(args) => term::run(args).await,
80
+ Commands::Discover(args) => discover::run(args).await,
81
+ #[cfg(feature = "client")]
82
+ Commands::Run(args) => client_cmds::run_cmd(args).await,
83
+ #[cfg(feature = "client")]
84
+ Commands::Read(args) => client_cmds::read_cmd(args).await,
85
+ #[cfg(feature = "client")]
86
+ Commands::SendText(args) => client_cmds::send_text_cmd(args).await,
87
+ #[cfg(feature = "client")]
88
+ Commands::SendKeys(args) => client_cmds::send_keys_cmd(args).await,
89
+ #[cfg(feature = "client")]
90
+ Commands::Screenshot(args) => client_cmds::screenshot_cmd(args).await,
91
+ #[cfg(feature = "client")]
92
+ Commands::WaitFor(args) => client_cmds::wait_for_cmd(args).await,
93
+ #[cfg(feature = "client")]
94
+ Commands::Info(args) => client_cmds::info_cmd(args).await,
95
+ #[cfg(feature = "client")]
96
+ Commands::Size(args) => client_cmds::size_cmd(args).await,
97
+ #[cfg(feature = "client")]
98
+ Commands::Session(args) => client_cmds::session_cmd(args).await,
99
+ }
100
+ }
@@ -0,0 +1,100 @@
1
+ //! Shared server infrastructure for all wrightty server modes.
2
+
3
+ use std::future::Future;
4
+ use std::net::{SocketAddr, TcpListener};
5
+ use std::time::Duration;
6
+
7
+ use jsonrpsee::server::Server;
8
+ use jsonrpsee::RpcModule;
9
+
10
+ /// Port range for wrightty servers (covers all modes).
11
+ pub const PORT_RANGE_START: u16 = 9420;
12
+ pub const PORT_RANGE_END: u16 = 9520;
13
+
14
+ /// Find the next available port in a range.
15
+ pub fn find_available_port(host: &str, start: u16, end: u16) -> Option<u16> {
16
+ for port in start..=end {
17
+ if TcpListener::bind(format!("{host}:{port}")).is_ok() {
18
+ return Some(port);
19
+ }
20
+ }
21
+ None
22
+ }
23
+
24
+ /// Start a JSON-RPC WebSocket server and block until it stops.
25
+ pub async fn start_server<S: Clone + Send + Sync + 'static>(
26
+ host: &str,
27
+ port: u16,
28
+ name: &str,
29
+ module: RpcModule<S>,
30
+ ) -> anyhow::Result<()> {
31
+ let addr: SocketAddr = format!("{host}:{port}").parse()?;
32
+ let server = Server::builder().build(addr).await?;
33
+ let handle = server.start(module);
34
+
35
+ tracing::info!("{name} listening on ws://{addr}");
36
+ println!("{name} listening on ws://{addr}");
37
+
38
+ handle.stopped().await;
39
+ Ok(())
40
+ }
41
+
42
+ /// Start a server with a watchdog that periodically checks health.
43
+ ///
44
+ /// If `health_check` fails 3 consecutive times, the server shuts down.
45
+ pub async fn start_server_with_watchdog<S, F, Fut>(
46
+ host: &str,
47
+ port: u16,
48
+ name: &str,
49
+ module: RpcModule<S>,
50
+ watchdog_interval: u64,
51
+ health_check: F,
52
+ ) -> anyhow::Result<()>
53
+ where
54
+ S: Clone + Send + Sync + 'static,
55
+ F: Fn() -> Fut + Send + 'static,
56
+ Fut: Future<Output = Result<(), Box<dyn std::error::Error>>> + Send,
57
+ {
58
+ let addr: SocketAddr = format!("{host}:{port}").parse()?;
59
+ let server = Server::builder().build(addr).await?;
60
+ let handle = server.start(module);
61
+
62
+ tracing::info!("{name} listening on ws://{addr}");
63
+ println!("{name} listening on ws://{addr}");
64
+
65
+ if watchdog_interval > 0 {
66
+ let interval = Duration::from_secs(watchdog_interval);
67
+ let server_handle = handle.clone();
68
+ let bridge_name = name.to_string();
69
+ tokio::spawn(async move {
70
+ let mut consecutive_failures = 0u32;
71
+ loop {
72
+ tokio::time::sleep(interval).await;
73
+ match health_check().await {
74
+ Ok(()) => {
75
+ if consecutive_failures > 0 {
76
+ tracing::info!("{bridge_name} reconnected");
77
+ consecutive_failures = 0;
78
+ }
79
+ }
80
+ Err(e) => {
81
+ consecutive_failures += 1;
82
+ tracing::warn!(
83
+ "{bridge_name} health check failed ({consecutive_failures}): {e}"
84
+ );
85
+ if consecutive_failures >= 3 {
86
+ tracing::error!(
87
+ "{bridge_name} unreachable after {consecutive_failures} checks, shutting down"
88
+ );
89
+ server_handle.stop().unwrap();
90
+ return;
91
+ }
92
+ }
93
+ }
94
+ }
95
+ });
96
+ }
97
+
98
+ handle.stopped().await;
99
+ Ok(())
100
+ }