diff --git a/package-lock.json b/package-lock.json index d1497b6c..105dd4c8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "src-tauri/yaak_plugin_runtime", "src-tauri/yaak_sync", "src-tauri/yaak_templates", + "src-tauri/yaak_sse", "src-web" ], "devDependencies": { @@ -2650,6 +2651,33 @@ "react": "^18 || ^19" } }, + "node_modules/@tanstack/react-virtual": { + "version": "3.10.8", + "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.10.8.tgz", + "integrity": "sha512-VbzbVGSsZlQktyLrP5nxE+vE1ZR+U0NFAWPbJLoG2+DKPwd2D7dVICTVIIaYlJqX1ZCEnYDbaOpmMwbsyhBoIA==", + "license": "MIT", + "dependencies": { + "@tanstack/virtual-core": "3.10.8" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, + "node_modules/@tanstack/virtual-core": { + "version": "3.10.8", + "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.10.8.tgz", + "integrity": "sha512-PBu00mtt95jbKFi6Llk9aik8bnR3tR/oQP1o3TSi+iG//+Q2RTIzCEgKkHG8BB86kxMNW6O8wku+Lmi+QFR6jA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@tauri-apps/api": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.0.2.tgz", @@ -3345,6 +3373,10 @@ "resolved": "plugin-runtime", "link": true }, + "node_modules/@yaakapp-internal/sse": { + "resolved": "src-tauri/yaak_sse", + "link": true + }, "node_modules/@yaakapp-internal/template": { "resolved": "src-tauri/yaak_templates", "link": true @@ -10922,6 +10954,19 @@ "react-dom": "*" } }, + "node_modules/react-virtuoso": { + "version": "4.10.4", + "resolved": "https://registry.npmjs.org/react-virtuoso/-/react-virtuoso-4.10.4.tgz", + "integrity": "sha512-G/gprhTbK+lzMxoo/iStcZxVEGph/cIhc3WANEpt92RuMw+LiCZOmBfKoeoZOHlm/iyftTrDJhGaTCpxyucnkQ==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "react": ">=16 || >=17 || >= 18", + "react-dom": ">=16 || >=17 || >= 18" + } + }, "node_modules/read-cache": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", @@ -13738,6 +13783,10 @@ "name": "@yaakapp-internal/plugin", "version": "1.0.0" }, + "src-tauri/yaak_sse": { + "name": "@yaakapp-internal/sse", + "version": "1.0.0" + }, "src-tauri/yaak_templates": { "name": "@yaakapp-internal/template", "version": "1.0.0" @@ -13757,6 +13806,7 @@ "@react-hook/resize-observer": "^2.0.2", "@tailwindcss/container-queries": "^0.1.1", "@tanstack/react-query": "^5.55.4", + "@tanstack/react-virtual": "^3.10.8", "@tauri-apps/api": "^2.0.1", "@tauri-apps/plugin-clipboard-manager": "^2.0.0", "@tauri-apps/plugin-dialog": "^2.0.0", @@ -13788,6 +13838,7 @@ "react-pdf": "^9.1.0", "react-router-dom": "^6.26.2", "react-use": "^17.5.1", + "react-virtuoso": "^4.10.4", "slugify": "^1.6.6", "uuid": "^10.0.0", "xml-formatter": "^3.6.3" diff --git a/package.json b/package.json index 67f7aca1..530b6482 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "src-tauri/yaak_plugin_runtime", "src-tauri/yaak_sync", "src-tauri/yaak_templates", + "src-tauri/yaak_sse", "src-web" ], "scripts": { diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 91cef4ca..62f7e2ad 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1623,6 +1623,21 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "eventsource-client" +version = "0.13.0" +source = "git+https://github.com/yaakapp/rust-eventsource-client#e9e1e52421f11f0409179389b997aa49275a8461" +dependencies = [ + "futures", + "hyper 0.14.30", + "hyper-rustls 0.24.2", + "hyper-timeout 0.4.1", + "log", + "pin-project", + "rand 0.8.5", + "tokio", +] + [[package]] name = "exr" version = "1.72.0" @@ -1807,6 +1822,21 @@ dependencies = [ "new_debug_unreachable", ] +[[package]] +name = "futures" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "645c6916888f6cb6350d2550b80fb63e734897a8498abe35cfb732b6487804b0" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + [[package]] name = "futures-channel" version = "0.3.30" @@ -1893,6 +1923,7 @@ version = "0.3.30" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d6401deb83407ab3da39eba7e33987a73c3df0c82b4bb5813ee871c19c41d48" dependencies = [ + "futures-channel", "futures-core", "futures-io", "futures-macro", @@ -7823,6 +7854,7 @@ dependencies = [ "chrono", "cocoa 0.26.0", "datetime", + "eventsource-client", "hex_color", "http 1.1.0", "log", @@ -7854,6 +7886,7 @@ dependencies = [ "yaak_grpc", "yaak_models", "yaak_plugin_runtime", + "yaak_sse", "yaak_templates", ] @@ -7926,6 +7959,15 @@ dependencies = [ "yaak_models", ] +[[package]] +name = "yaak_sse" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", + "ts-rs", +] + [[package]] name = "yaak_templates" version = "0.1.0" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 06d98c40..2da42f85 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = ["yaak_grpc", "yaak_templates", "yaak_plugin_runtime", "yaak_models"] +members = ["yaak_grpc", "yaak_templates", "yaak_plugin_runtime", "yaak_models", "yaak_sse"] [package] name = "yaak-app" @@ -30,6 +30,7 @@ yaak_grpc = { path = "yaak_grpc" } yaak_templates = { path = "yaak_templates" } yaak_plugin_runtime = { workspace = true } yaak_models = { workspace = true } +yaak_sse = { path = "yaak_sse" } anyhow = "1.0.86" base64 = "0.22.0" chrono = { version = "0.4.31", features = ["serde"] } @@ -59,6 +60,7 @@ uuid = "1.7.0" thiserror = "1.0.61" mime_guess = "2.0.5" urlencoding = "2.1.3" +eventsource-client = { git = "https://github.com/yaakapp/rust-eventsource-client", version = "0.13.0" } [workspace.dependencies] yaak_models = { path = "yaak_models" } diff --git a/src-tauri/gen/schemas/capabilities.json b/src-tauri/gen/schemas/capabilities.json index a5a6c5bc..1e3e6769 100644 --- a/src-tauri/gen/schemas/capabilities.json +++ b/src-tauri/gen/schemas/capabilities.json @@ -1 +1 @@ -{"main":{"identifier":"main","description":"Main permissions","local":true,"windows":["*"],"permissions":["core:event:allow-emit","core:event:allow-listen","core:event:allow-unlisten","os:allow-os-type","clipboard-manager:allow-clear","clipboard-manager:allow-write-text","clipboard-manager:allow-read-text","dialog:allow-open","dialog:allow-save","fs:allow-read-file","fs:allow-read-text-file",{"identifier":"fs:scope","allow":[{"path":"$APPDATA"},{"path":"$APPDATA/**"}]},"shell:allow-open","core:webview:allow-set-webview-zoom","core:window:allow-close","core:window:allow-is-fullscreen","core:window:allow-maximize","core:window:allow-minimize","core:window:allow-set-decorations","core:window:allow-set-title","core:window:allow-show","core:window:allow-start-dragging","core:window:allow-theme","core:window:allow-toggle-maximize","core:window:allow-internal-toggle-maximize","core:window:allow-unmaximize","clipboard-manager:allow-read-text","clipboard-manager:allow-write-text"]}} \ No newline at end of file +{"main":{"identifier":"main","description":"Main permissions","local":true,"windows":["*"],"permissions":["core:event:allow-emit","core:event:allow-listen","core:event:allow-unlisten","os:allow-os-type","clipboard-manager:allow-clear","clipboard-manager:allow-write-text","clipboard-manager:allow-read-text","dialog:allow-open","dialog:allow-save","fs:allow-read-file","fs:allow-read-text-file",{"identifier":"fs:scope","allow":[{"path":"$APPDATA"},{"path":"$APPDATA/**"}]},"shell:allow-open","core:webview:allow-set-webview-zoom","core:window:allow-close","core:window:allow-internal-toggle-maximize","core:window:allow-is-fullscreen","core:window:allow-maximize","core:window:allow-minimize","core:window:allow-set-decorations","core:window:allow-set-title","core:window:allow-show","core:window:allow-start-dragging","core:window:allow-theme","core:window:allow-toggle-maximize","core:window:allow-unmaximize","clipboard-manager:allow-read-text","clipboard-manager:allow-write-text"]}} \ No newline at end of file diff --git a/src-tauri/src/http_request.rs b/src-tauri/src/http_request.rs index 85002c0d..28f644fd 100644 --- a/src-tauri/src/http_request.rs +++ b/src-tauri/src/http_request.rs @@ -21,19 +21,21 @@ use tauri::{Manager, Runtime, WebviewWindow}; use tokio::fs; use tokio::fs::{create_dir_all, File}; use tokio::io::AsyncWriteExt; -use tokio::sync::oneshot; use tokio::sync::watch::Receiver; +use tokio::sync::{oneshot, Mutex}; use yaak_models::models::{ Cookie, CookieJar, Environment, HttpRequest, HttpResponse, HttpResponseHeader, HttpResponseState, }; -use yaak_models::queries::{get_workspace, update_response_if_id, upsert_cookie_jar}; +use yaak_models::queries::{ + get_http_response, get_workspace, update_response_if_id, upsert_cookie_jar, +}; use yaak_plugin_runtime::events::{RenderPurpose, WindowContext}; pub async fn send_http_request( window: &WebviewWindow, request: &HttpRequest, - response: &HttpResponse, + og_response: &HttpResponse, environment: Option, cookie_jar: Option, cancelled_rx: &mut Receiver, @@ -47,6 +49,9 @@ pub async fn send_http_request( RenderPurpose::Send, ); + let response_id = og_response.id.clone(); + let response = Arc::new(Mutex::new(og_response.clone())); + let rendered_request = render_http_request(&request, &workspace, environment.as_ref(), &cb).await; @@ -116,7 +121,7 @@ pub async fn send_http_request( Ok(u) => u, Err(e) => { return Ok(response_err( - response, + &*response.lock().await, format!("Failed to parse URL \"{}\": {}", url_string, e.to_string()), window, ) @@ -128,7 +133,7 @@ pub async fn send_http_request( Ok(u) => u, Err(e) => { return Ok(response_err( - response, + &*response.lock().await, format!("Failed to parse URL \"{}\": {}", url_string, e.to_string()), window, ) @@ -275,7 +280,7 @@ pub async fn send_http_request( request_builder = request_builder.body(f); } Err(e) => { - return Ok(response_err(response, e, window).await); + return Ok(response_err(&*response.lock().await, e, window).await); } } } else if body_type == "multipart/form-data" && request_body.contains_key("form") { @@ -301,9 +306,12 @@ pub async fn send_http_request( match fs::read(file_path.clone()).await { Ok(f) => multipart::Part::bytes(f), Err(e) => { - return Ok( - response_err(response, e.to_string(), window).await - ); + return Ok(response_err( + &*response.lock().await, + e.to_string(), + window, + ) + .await); } } }; @@ -351,7 +359,7 @@ pub async fn send_http_request( let sendable_req = match request_builder.build() { Ok(r) => r, Err(e) => { - return Ok(response_err(response, e.to_string(), window).await); + return Ok(response_err(&*response.lock().await, e.to_string(), window).await); } }; @@ -368,62 +376,60 @@ pub async fn send_http_request( Ok(r) = resp_rx => r, _ = cancelled_rx.changed() => { debug!("Request cancelled"); - return Ok(response_err(response, "Request was cancelled".to_string(), window).await); + return Ok(response_err(&*response.lock().await, "Request was cancelled".to_string(), window).await); } }; { let window = window.clone(); - let response = response.clone(); let cancelled_rx = cancelled_rx.clone(); + let response_id = response_id.clone(); + let response = response.clone(); tokio::spawn(async move { - let result = match raw_response { + match raw_response { Ok(mut v) => { - let mut response = response.clone(); + let content_length = v.content_length(); let response_headers = v.headers().clone(); - response.elapsed_headers = start.elapsed().as_millis() as i32; - response.status = v.status().as_u16() as i32; - response.status_reason = v.status().canonical_reason().map(|s| s.to_string()); - response.headers = response_headers - .iter() - .map(|(k, v)| HttpResponseHeader { - name: k.as_str().to_string(), - value: v.to_str().unwrap_or_default().to_string(), - }) - .collect(); - response.url = v.url().to_string(); - response.remote_addr = v.remote_addr().map(|a| a.to_string()); - response.version = match v.version() { - reqwest::Version::HTTP_09 => Some("HTTP/0.9".to_string()), - reqwest::Version::HTTP_10 => Some("HTTP/1.0".to_string()), - reqwest::Version::HTTP_11 => Some("HTTP/1.1".to_string()), - reqwest::Version::HTTP_2 => Some("HTTP/2".to_string()), - reqwest::Version::HTTP_3 => Some("HTTP/3".to_string()), - _ => None, - }; let dir = window.app_handle().path().app_data_dir().unwrap(); let base_dir = dir.join("responses"); create_dir_all(base_dir.clone()) .await .expect("Failed to create responses dir"); - let body_path = if response.id.is_empty() { - base_dir.join(response.id.clone()) + let body_path = if response_id.is_empty() { + base_dir.join(response_id.clone()) } else { base_dir.join(uuid::Uuid::new_v4().to_string()) }; - response.body_path = Some( - body_path - .to_str() - .expect("Failed to get body path") - .to_string(), - ); + { + let mut r = response.lock().await; + r.body_path = Some(body_path.to_str().unwrap().to_string()); + r.elapsed_headers = start.elapsed().as_millis() as i32; + r.status = v.status().as_u16() as i32; + r.status_reason = v.status().canonical_reason().map(|s| s.to_string()); + r.headers = response_headers + .iter() + .map(|(k, v)| HttpResponseHeader { + name: k.as_str().to_string(), + value: v.to_str().unwrap_or_default().to_string(), + }) + .collect(); + r.url = v.url().to_string(); + r.remote_addr = v.remote_addr().map(|a| a.to_string()); + r.version = match v.version() { + reqwest::Version::HTTP_09 => Some("HTTP/0.9".to_string()), + reqwest::Version::HTTP_10 => Some("HTTP/1.0".to_string()), + reqwest::Version::HTTP_11 => Some("HTTP/1.1".to_string()), + reqwest::Version::HTTP_2 => Some("HTTP/2".to_string()), + reqwest::Version::HTTP_3 => Some("HTTP/3".to_string()), + _ => None, + }; - let content_length = v.content_length(); - response.state = HttpResponseState::Connected; - response = update_response_if_id(&window, &response) - .await - .expect("Failed to update response after connected"); + r.state = HttpResponseState::Connected; + update_response_if_id(&window, &r) + .await + .expect("Failed to update response after connected"); + } // Write body to FS let mut f = File::options() @@ -446,9 +452,10 @@ pub async fn send_http_request( f.write_all(&bytes).await.expect("Failed to write to file"); f.flush().await.expect("Failed to flush file"); written_bytes += bytes.len(); - response.elapsed = start.elapsed().as_millis() as i32; - response.content_length = Some(written_bytes as i32); - response = update_response_if_id(&window, &response) + let mut r = response.lock().await; + r.elapsed = start.elapsed().as_millis() as i32; + r.content_length = Some(written_bytes as i32); + update_response_if_id(&window, &r) .await .expect("Failed to update response"); } @@ -456,21 +463,24 @@ pub async fn send_http_request( break; } Err(e) => { - response = response_err(&response, e.to_string(), &window).await; + response_err(&*response.lock().await, e.to_string(), &window).await; break; } } } // Set final content length - response.content_length = match content_length { - Some(l) => Some(l as i32), - None => Some(written_bytes as i32), + { + let mut r = response.lock().await; + r.content_length = match content_length { + Some(l) => Some(l as i32), + None => Some(written_bytes as i32), + }; + r.state = HttpResponseState::Closed; + update_response_if_id(&window, &r) + .await + .expect("Failed to update response"); }; - response.state = HttpResponseState::Closed; - response = update_response_if_id(&window, &response) - .await - .expect("Failed to update response"); // Add cookie store if specified if let Some((cookie_store, mut cookie_jar)) = maybe_cookie_manager { @@ -497,19 +507,29 @@ pub async fn send_http_request( error!("Failed to update cookie jar: {}", e); }; } - response } - Err(e) => response_err(&response, e.to_string(), &window).await, + Err(e) => { + response_err(&*response.lock().await, e.to_string(), &window).await; + } }; - done_tx.send(result.clone()).unwrap(); + let r = response.lock().await.clone(); + done_tx.send(r).unwrap(); }); }; Ok(tokio::select! { Ok(r) = done_rx => r, _ = cancelled_rx.changed() => { - response_err(&response, "Request was cancelled".to_string(), &window).await + match get_http_response(window, response_id.as_str()).await { + Ok(mut r) => { + r.state = HttpResponseState::Closed; + update_response_if_id(&window, &r).await.expect("Failed to update response") + }, + _ => { + response_err(&*response.lock().await, "Ephemeral request was cancelled".to_string(), &window).await + }.clone(), + } } }) } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index f4acb9fd..a1b87e1e 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -3,7 +3,7 @@ extern crate core; extern crate objc; use std::collections::BTreeMap; -use std::fs::{create_dir_all, read_to_string, File}; +use std::fs::{create_dir_all, File}; use std::path::PathBuf; use std::process::exit; use std::str::FromStr; @@ -13,6 +13,7 @@ use std::{fs, panic}; use base64::prelude::BASE64_STANDARD; use base64::Engine; use chrono::Utc; +use eventsource_client::{EventParser, SSE}; use fern::colors::ColoredLevelConfig; use log::{debug, error, info, warn}; use rand::random; @@ -27,6 +28,7 @@ use tauri::{Manager, WindowEvent}; use tauri_plugin_clipboard_manager::ClipboardExt; use tauri_plugin_log::{fern, Target, TargetKind}; use tauri_plugin_shell::ShellExt; +use tokio::fs::read_to_string; use tokio::sync::Mutex; use yaak_grpc::manager::{DynamicMessage, GrpcHandle}; @@ -69,6 +71,7 @@ use yaak_plugin_runtime::events::{ WindowContext, }; use yaak_plugin_runtime::plugin_handle::PluginHandle; +use yaak_sse::sse::ServerSentEvent; use yaak_templates::{Parser, Tokens}; mod analytics; @@ -337,7 +340,7 @@ async fn cmd_grpc_go( &GrpcConnection { elapsed: start.elapsed().as_millis() as i32, error: Some(err.clone()), - state: GrpcConnectionState::Initialized, + state: GrpcConnectionState::Closed, ..conn.clone() }, ) @@ -797,7 +800,7 @@ async fn cmd_filter_response( } } - let body = read_to_string(response.body_path.unwrap()).unwrap(); + let body = read_to_string(response.body_path.unwrap()).await.unwrap(); // TODO: Have plugins register their own content type (regex?) plugin_manager @@ -806,14 +809,36 @@ async fn cmd_filter_response( .map_err(|e| e.to_string()) } +#[tauri::command] +async fn cmd_get_sse_events(file_path: &str) -> Result, String> { + let body = fs::read(file_path).map_err(|e| e.to_string())?; + let mut p = EventParser::new(); + p.process_bytes(body.into()).map_err(|e| e.to_string())?; + + let mut events = Vec::new(); + while let Some(e) = p.get_event() { + if let SSE::Event(e) = e { + events.push(ServerSentEvent { + event_type: e.event_type, + data: e.data, + id: e.id, + retry: e.retry, + }); + } + } + + Ok(events) +} + #[tauri::command] async fn cmd_import_data( window: WebviewWindow, plugin_manager: State<'_, PluginManager>, file_path: &str, ) -> Result { - let file = - read_to_string(file_path).unwrap_or_else(|_| panic!("Unable to read file {}", file_path)); + let file = read_to_string(file_path) + .await + .unwrap_or_else(|_| panic!("Unable to read file {}", file_path)); let file_contents = file.as_str(); let (import_result, plugin_name) = plugin_manager .import_data(&window, file_contents) @@ -1801,6 +1826,7 @@ pub fn run() { ]) .level_for("plugin_runtime", log::LevelFilter::Info) .level_for("cookie_store", log::LevelFilter::Info) + .level_for("eventsource_client::event_parser", log::LevelFilter::Info) .level_for("h2", log::LevelFilter::Info) .level_for("hyper", log::LevelFilter::Info) .level_for("hyper_util", log::LevelFilter::Info) @@ -1901,6 +1927,7 @@ pub fn run() { cmd_get_folder, cmd_get_grpc_request, cmd_get_http_request, + cmd_get_sse_events, cmd_get_key_value, cmd_get_settings, cmd_get_workspace, diff --git a/src-tauri/yaak_models/src/queries.rs b/src-tauri/yaak_models/src/queries.rs index 63d438e5..5f6b2cbb 100644 --- a/src-tauri/yaak_models/src/queries.rs +++ b/src-tauri/yaak_models/src/queries.rs @@ -488,6 +488,7 @@ pub async fn upsert_grpc_connection( GrpcConnectionIden::Method, GrpcConnectionIden::Elapsed, GrpcConnectionIden::Status, + GrpcConnectionIden::State, GrpcConnectionIden::Error, GrpcConnectionIden::Trailers, GrpcConnectionIden::Url, diff --git a/src-tauri/yaak_sse/Cargo.toml b/src-tauri/yaak_sse/Cargo.toml new file mode 100644 index 00000000..7a11506d --- /dev/null +++ b/src-tauri/yaak_sse/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "yaak_sse" +version = "0.1.0" +edition = "2021" + +[dependencies] +serde = { version = "1.0.204", features = ["derive"] } +serde_json = "1.0.122" +ts-rs = { version = "10.0.0", features = ["serde-json-impl"] } diff --git a/src-tauri/yaak_sse/bindings/sse.ts b/src-tauri/yaak_sse/bindings/sse.ts new file mode 100644 index 00000000..e70fa19b --- /dev/null +++ b/src-tauri/yaak_sse/bindings/sse.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type ServerSentEvent = { eventType: string, data: string, id: string | null, retry: bigint | null, }; diff --git a/src-tauri/yaak_sse/index.ts b/src-tauri/yaak_sse/index.ts new file mode 100644 index 00000000..3b4faf38 --- /dev/null +++ b/src-tauri/yaak_sse/index.ts @@ -0,0 +1 @@ +export * from './bindings/sse'; diff --git a/src-tauri/yaak_sse/package.json b/src-tauri/yaak_sse/package.json new file mode 100644 index 00000000..5ca3b1aa --- /dev/null +++ b/src-tauri/yaak_sse/package.json @@ -0,0 +1,6 @@ +{ + "name": "@yaakapp-internal/sse", + "private": true, + "version": "1.0.0", + "main": "index.ts" +} diff --git a/src-tauri/yaak_sse/src/lib.rs b/src-tauri/yaak_sse/src/lib.rs new file mode 100644 index 00000000..b980979a --- /dev/null +++ b/src-tauri/yaak_sse/src/lib.rs @@ -0,0 +1 @@ +pub mod sse; diff --git a/src-tauri/yaak_sse/src/sse.rs b/src-tauri/yaak_sse/src/sse.rs new file mode 100644 index 00000000..e8860a45 --- /dev/null +++ b/src-tauri/yaak_sse/src/sse.rs @@ -0,0 +1,12 @@ +use serde::{Deserialize, Serialize}; +use ts_rs::TS; + +#[derive(Debug, Clone, Serialize, Deserialize, Default, TS)] +#[serde(default, rename_all = "camelCase")] +#[ts(export, export_to = "sse.ts")] +pub struct ServerSentEvent { + pub event_type: String, + pub data: String, + pub id: Option, + pub retry: Option, +} diff --git a/src-web/components/GrpcConnectionMessagesPane.tsx b/src-web/components/GrpcConnectionMessagesPane.tsx index f1890c62..5ea7f7c7 100644 --- a/src-web/components/GrpcConnectionMessagesPane.tsx +++ b/src-web/components/GrpcConnectionMessagesPane.tsx @@ -188,7 +188,7 @@ function EventRow({ className={classNames( 'w-full grid grid-cols-[auto_minmax(0,3fr)_auto] gap-2 items-center text-left', 'px-1.5 py-1 font-mono cursor-default group focus:outline-none rounded', - isActive && '!bg-surface-highlight !text', + isActive && '!bg-surface-highlight !text-text', 'text-text-subtle hover:text', )} > diff --git a/src-web/components/ResponsePane.tsx b/src-web/components/ResponsePane.tsx index 500490d8..e23f0e75 100644 --- a/src-web/components/ResponsePane.tsx +++ b/src-web/components/ResponsePane.tsx @@ -23,6 +23,7 @@ import { ResponseHeaders } from './ResponseHeaders'; import { ResponseInfo } from './ResponseInfo'; import { AudioViewer } from './responseViewers/AudioViewer'; import { CsvViewer } from './responseViewers/CsvViewer'; +import { EventStreamViewer } from './responseViewers/EventStreamViewer'; import { HTMLOrTextViewer } from './responseViewers/HTMLOrTextViewer'; import { ImageViewer } from './responseViewers/ImageViewer'; import { PdfViewer } from './responseViewers/PdfViewer'; @@ -163,15 +164,17 @@ export const ResponsePane = memo(function ResponsePane({ style, className, activ
Empty Body
- ) : contentType?.startsWith('image') ? ( + ) : contentType?.match(/^text\/event-stream$/i) && viewMode === 'pretty' ? ( + + ) : contentType?.match(/^image/i) ? ( - ) : contentType?.startsWith('audio') ? ( + ) : contentType?.match(/^audio/i) ? ( - ) : contentType?.startsWith('video') ? ( + ) : contentType?.match(/^video/i) ? ( - ) : contentType?.match(/pdf/) ? ( + ) : contentType?.match(/pdf/i) ? ( - ) : contentType?.match(/csv|tab-separated/) ? ( + ) : contentType?.match(/csv|tab-separated/i) ? ( ) : ( // ) : viewMode === 'pretty' && contentType?.includes('json') ? ( diff --git a/src-web/components/core/InlineCode.tsx b/src-web/components/core/InlineCode.tsx index 6eb62196..f96efea6 100644 --- a/src-web/components/core/InlineCode.tsx +++ b/src-web/components/core/InlineCode.tsx @@ -6,7 +6,6 @@ export function InlineCode({ className, ...props }: HTMLAttributes + + + ); +} + +function ActualEventStreamViewer({ response }: Props) { + const [showLarge, setShowLarge] = useState(false); + const [showingLarge, setShowingLarge] = useState(false); + const [activeEventIndex, setActiveEventIndex] = useState(null); + const events = useResponseBodyEventSource(response); + const activeEvent = useMemo( + () => (activeEventIndex == null ? null : events.data?.[activeEventIndex]), + [activeEventIndex, events], + ); + + const language = useMemo<'text' | 'json'>(() => { + if (!activeEvent?.data) return 'text'; + return isJSON(activeEvent?.data) ? 'json' : 'text'; + }, [activeEvent?.data]); + + return ( + ( + + )} + secondSlot={ + activeEvent + ? () => ( +
+
+ +
+
+
Message Received
+ {!showLarge && activeEvent.data.length > 1000 * 1000 ? ( + + Message previews larger than 1MB are hidden +
+ +
+
+ ) : ( + + )} +
+
+ ) + : null + } + /> + ); +} + +function EventStreamEventsVirtual({ + events, + activeEventIndex, + setActiveEventIndex, +}: { + events: ServerSentEvent[]; + activeEventIndex: number | null; + setActiveEventIndex: (eventId: number | null) => void; +}) { + // The scrollable element for your list + const parentRef = useRef(null); + + // The virtualizer + const rowVirtualizer = useVirtualizer({ + count: events.length, + getScrollElement: () => parentRef.current, + estimateSize: () => 28, // react-virtual requires a height, so we'll give it one + }); + + return ( +
+
+ {rowVirtualizer.getVirtualItems().map((virtualItem) => { + const event = events[virtualItem.index]!; + return ( +
+ { + if (virtualItem.index === activeEventIndex) setActiveEventIndex(null); + else setActiveEventIndex(virtualItem.index); + }} + /> +
+ ); + })} +
+
+ ); +} + +function EventStreamEvent({ + onClick, + isActive, + event, + className, +}: { + onClick: () => void; + isActive: boolean; + event: ServerSentEvent; + className?: string; +}) { + return ( + + + + {event.eventType && ( + + {event.eventType} + + )} + {event.id && ( + + {event.id} + + )} + +
{event.data.slice(0, 1000)}
+
+ ); +} diff --git a/src-web/hooks/useResponseBodyEventSource.ts b/src-web/hooks/useResponseBodyEventSource.ts new file mode 100644 index 00000000..084bc5ed --- /dev/null +++ b/src-web/hooks/useResponseBodyEventSource.ts @@ -0,0 +1,12 @@ +import { useQuery } from '@tanstack/react-query'; +import type { HttpResponse } from '@yaakapp-internal/models'; +import type { ServerSentEvent } from '@yaakapp-internal/sse'; +import { getResponseBodyEventSource } from '../lib/responseBody'; + +export function useResponseBodyEventSource(response: HttpResponse) { + return useQuery({ + placeholderData: (prev) => prev, // Keep previous data on refetch + queryKey: ['response-body-event-source', response.id, response.contentLength], + queryFn: () => getResponseBodyEventSource(response), + }); +} diff --git a/src-web/lib/contentType.ts b/src-web/lib/contentType.ts index 780194a3..c817ebf3 100644 --- a/src-web/lib/contentType.ts +++ b/src-web/lib/contentType.ts @@ -35,3 +35,15 @@ function detectFromContent( return fallback; } + +export function isJSON(content: string | null | undefined): boolean { + if (typeof content !== 'string') return false; + + try { + JSON.parse(content) + return true; + // eslint-disable-next-line @typescript-eslint/no-unused-vars + } catch (err) { + return false; + } +} diff --git a/src-web/lib/responseBody.ts b/src-web/lib/responseBody.ts index a54b24f9..314bbdef 100644 --- a/src-web/lib/responseBody.ts +++ b/src-web/lib/responseBody.ts @@ -1,26 +1,34 @@ import { readFile } from '@tauri-apps/plugin-fs'; import type { HttpResponse } from '@yaakapp-internal/models'; +import type { ServerSentEvent } from '@yaakapp-internal/sse'; import { getCharsetFromContentType } from './model_util'; +import { invokeCmd } from './tauri'; export async function getResponseBodyText(response: HttpResponse): Promise { - if (response.bodyPath) { - const bytes = await readFile(response.bodyPath); - const charset = getCharsetFromContentType(response.headers); + if (!response.bodyPath) return null; - try { - return new TextDecoder(charset ?? 'utf-8', { fatal: true }).decode(bytes); - // eslint-disable-next-line @typescript-eslint/no-unused-vars - } catch (err) { - // Failed to decode as text, so return null - return null; - } + const bytes = await readFile(response.bodyPath); + const charset = getCharsetFromContentType(response.headers); + + try { + return new TextDecoder(charset ?? 'utf-8', { fatal: true }).decode(bytes); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + } catch (err) { + // Failed to decode as text, so return null + return null; } - return null; } export async function getResponseBodyBlob(response: HttpResponse): Promise { - if (response.bodyPath) { - return readFile(response.bodyPath); - } - return null; + if (!response.bodyPath) return null; + return readFile(response.bodyPath); +} + +export async function getResponseBodyEventSource( + response: HttpResponse, +): Promise { + if (!response.bodyPath) return []; + return invokeCmd('cmd_get_sse_events', { + filePath: response.bodyPath, + }); } diff --git a/src-web/lib/tauri.ts b/src-web/lib/tauri.ts index f875da7a..1442e9e4 100644 --- a/src-web/lib/tauri.ts +++ b/src-web/lib/tauri.ts @@ -32,6 +32,7 @@ type TauriCmd = | 'cmd_get_folder' | 'cmd_get_grpc_request' | 'cmd_get_http_request' + | 'cmd_get_sse_events' | 'cmd_get_key_value' | 'cmd_get_settings' | 'cmd_get_workspace' diff --git a/src-web/main.css b/src-web/main.css index 590fd69c..638d4c2d 100644 --- a/src-web/main.css +++ b/src-web/main.css @@ -19,22 +19,14 @@ } /* Disable user selection to make it more "app-like" */ - h1, - h2, - h3, - h4, - h5, - h6, - p, - blockquote, - label, - code, - pre, - li { + :not(a), + :not(input):not(textarea), + :not(input):not(textarea)::after, + :not(input):not(textarea)::before { @apply select-none cursor-default; } - input, + input, textarea { &::placeholder { @apply text-placeholder; diff --git a/src-web/package.json b/src-web/package.json index 1af32899..e32db902 100644 --- a/src-web/package.json +++ b/src-web/package.json @@ -20,6 +20,7 @@ "@react-hook/resize-observer": "^2.0.2", "@tailwindcss/container-queries": "^0.1.1", "@tanstack/react-query": "^5.55.4", + "@tanstack/react-virtual": "^3.10.8", "@tauri-apps/api": "^2.0.1", "@tauri-apps/plugin-clipboard-manager": "^2.0.0", "@tauri-apps/plugin-dialog": "^2.0.0",