diff --git a/.gitignore b/.gitignore index a0a25619..e4995499 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,4 @@ out .tmp tmp .zed +codebook.toml diff --git a/src-tauri/src/http_request.rs b/src-tauri/src/http_request.rs index 8e197d6d..cc4ad156 100644 --- a/src-tauri/src/http_request.rs +++ b/src-tauri/src/http_request.rs @@ -19,8 +19,8 @@ use yaak_http::sender::ReqwestSender; use yaak_http::transaction::HttpTransaction; use yaak_http::types::{SendableHttpRequest, SendableHttpRequestOptions, append_query_params}; use yaak_models::models::{ - CookieJar, Environment, HttpRequest, HttpResponse, HttpResponseHeader, HttpResponseState, - ProxySetting, ProxySettingAuth, + CookieJar, Environment, HttpRequest, HttpResponse, HttpResponseEvent, HttpResponseHeader, + HttpResponseState, ProxySetting, ProxySettingAuth, }; use yaak_models::query_manager::QueryManagerExt; use yaak_models::util::UpdateSource; @@ -271,10 +271,30 @@ async fn execute_transaction( app_handle.db().update_http_response_if_id(&r, &update_source)?; } + // Create channel for receiving events and spawn a task to store them in DB + let (event_tx, mut event_rx) = + tokio::sync::mpsc::unbounded_channel::(); + + // Write events to DB in a task + { + let response_id = response_id.clone(); + let workspace_id = response.lock().await.workspace_id.clone(); + let app_handle = app_handle.clone(); + let update_source = update_source.clone(); + tokio::spawn(async move { + while let Some(event) = event_rx.recv().await { + let db_event = HttpResponseEvent::new(&response_id, &workspace_id, event.into()); + let _ = app_handle.db().upsert(&db_event, &update_source); + } + }); + }; + // Execute the transaction with cancellation support // This returns the response with headers, but body is not yet consumed - let (mut http_response, _events) = - transaction.execute_with_cancellation(sendable_request, cancelled_rx.clone()).await?; + // Events (headers, settings, chunks) are sent through the channel + let mut http_response = transaction + .execute_with_cancellation(sendable_request, cancelled_rx.clone(), event_tx) + .await?; // Prepare the response path before consuming the body let dir = app_handle.path().app_data_dir()?; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 49e5f773..b1f77b4c 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -34,8 +34,8 @@ use yaak_grpc::{Code, ServiceDefinition, serialize_message}; use yaak_mac_window::AppHandleMacWindowExt; use yaak_models::models::{ AnyModel, CookieJar, Environment, GrpcConnection, GrpcConnectionState, GrpcEvent, - GrpcEventType, GrpcRequest, HttpRequest, HttpResponse, HttpResponseState, Plugin, Workspace, - WorkspaceMeta, + GrpcEventType, GrpcRequest, HttpRequest, HttpResponse, HttpResponseEvent, HttpResponseState, + Plugin, Workspace, WorkspaceMeta, }; use yaak_models::query_manager::QueryManagerExt; use yaak_models::util::{BatchUpsertResult, UpdateSource, get_workspace_export_resources}; @@ -830,6 +830,17 @@ async fn cmd_get_sse_events(file_path: &str) -> YaakResult> Ok(events) } +#[tauri::command] +async fn cmd_get_http_response_events( + app_handle: AppHandle, + response_id: &str, +) -> YaakResult> { + use yaak_models::models::HttpResponseEventIden; + let events: Vec = + app_handle.db().find_many(HttpResponseEventIden::ResponseId, response_id, None)?; + Ok(events) +} + #[tauri::command] async fn cmd_import_data( window: WebviewWindow, @@ -1462,6 +1473,7 @@ pub fn run() { cmd_get_http_authentication_summaries, cmd_get_http_authentication_config, cmd_get_sse_events, + cmd_get_http_response_events, cmd_get_workspace_meta, cmd_grpc_go, cmd_grpc_reflect, diff --git a/src-tauri/yaak-http/src/sender.rs b/src-tauri/yaak-http/src/sender.rs index 54c9b390..d6cb88ea 100644 --- a/src-tauri/yaak-http/src/sender.rs +++ b/src-tauri/yaak-http/src/sender.rs @@ -7,20 +7,45 @@ use reqwest::{Client, Method, Version}; use std::collections::HashMap; use std::fmt::Display; use std::pin::Pin; +use std::task::{Context, Poll}; use std::time::Duration; -use tokio::io::{AsyncRead, AsyncReadExt, BufReader}; +use tokio::io::{AsyncRead, AsyncReadExt, BufReader, ReadBuf}; +use tokio::sync::mpsc; use tokio_util::io::StreamReader; -#[derive(Debug)] +#[derive(Debug, Clone)] +pub enum RedirectBehavior { + /// 307/308: Method and body are preserved + Preserve, + /// 303 or 301/302 with POST: Method changed to GET, body dropped + DropBody, +} + +#[derive(Debug, Clone)] pub enum HttpResponseEvent { Setting(String, String), Info(String), - SendUrl { method: String, path: String }, - ReceiveUrl { version: Version, status: String }, + Redirect { + url: String, + status: u16, + behavior: RedirectBehavior, + }, + SendUrl { + method: String, + path: String, + }, + ReceiveUrl { + version: Version, + status: String, + }, HeaderUp(String, String), HeaderDown(String, String), - HeaderUpDone, - HeaderDownDone, + ChunkSent { + bytes: usize, + }, + ChunkReceived { + bytes: usize, + }, } impl Display for HttpResponseEvent { @@ -28,14 +53,47 @@ impl Display for HttpResponseEvent { match self { HttpResponseEvent::Setting(name, value) => write!(f, "* Setting {}={}", name, value), HttpResponseEvent::Info(s) => write!(f, "* {}", s), + HttpResponseEvent::Redirect { url, status, behavior } => { + let behavior_str = match behavior { + RedirectBehavior::Preserve => "preserve", + RedirectBehavior::DropBody => "drop body", + }; + write!(f, "* Redirect {} -> {} ({})", status, url, behavior_str) + } HttpResponseEvent::SendUrl { method, path } => write!(f, "> {} {}", method, path), HttpResponseEvent::ReceiveUrl { version, status } => { write!(f, "< {} {}", version_to_str(version), status) } HttpResponseEvent::HeaderUp(name, value) => write!(f, "> {}: {}", name, value), - HttpResponseEvent::HeaderUpDone => write!(f, ">"), HttpResponseEvent::HeaderDown(name, value) => write!(f, "< {}: {}", name, value), - HttpResponseEvent::HeaderDownDone => write!(f, "<"), + HttpResponseEvent::ChunkSent { bytes } => write!(f, "> [{} bytes sent]", bytes), + HttpResponseEvent::ChunkReceived { bytes } => write!(f, "< [{} bytes received]", bytes), + } + } +} + +impl From for yaak_models::models::HttpResponseEventData { + fn from(event: HttpResponseEvent) -> Self { + use yaak_models::models::HttpResponseEventData as D; + match event { + HttpResponseEvent::Setting(name, value) => D::Setting { name, value }, + HttpResponseEvent::Info(message) => D::Info { message }, + HttpResponseEvent::Redirect { url, status, behavior } => D::Redirect { + url, + status, + behavior: match behavior { + RedirectBehavior::Preserve => "preserve".to_string(), + RedirectBehavior::DropBody => "drop_body".to_string(), + }, + }, + HttpResponseEvent::SendUrl { method, path } => D::SendUrl { method, path }, + HttpResponseEvent::ReceiveUrl { version, status } => { + D::ReceiveUrl { version: format!("{:?}", version), status } + } + HttpResponseEvent::HeaderUp(name, value) => D::HeaderUp { name, value }, + HttpResponseEvent::HeaderDown(name, value) => D::HeaderDown { name, value }, + HttpResponseEvent::ChunkSent { bytes } => D::ChunkSent { bytes }, + HttpResponseEvent::ChunkReceived { bytes } => D::ChunkReceived { bytes }, } } } @@ -49,6 +107,40 @@ pub struct BodyStats { pub size_decompressed: u64, } +/// An AsyncRead wrapper that sends chunk events as data is read +pub struct TrackingRead { + inner: R, + event_tx: mpsc::UnboundedSender, + ended: bool, +} + +impl TrackingRead { + pub fn new(inner: R, event_tx: mpsc::UnboundedSender) -> Self { + Self { inner, event_tx, ended: false } + } +} + +impl AsyncRead for TrackingRead { + fn poll_read( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &mut ReadBuf<'_>, + ) -> Poll> { + let before = buf.filled().len(); + let result = Pin::new(&mut self.inner).poll_read(cx, buf); + if let Poll::Ready(Ok(())) = &result { + let bytes_read = buf.filled().len() - before; + if bytes_read > 0 { + // Ignore send errors - receiver may have been dropped + let _ = self.event_tx.send(HttpResponseEvent::ChunkReceived { bytes: bytes_read }); + } else if !self.ended { + self.ended = true; + } + } + result + } +} + /// Type alias for the body stream type BodyStream = Pin>; @@ -215,10 +307,11 @@ impl HttpResponse { pub trait HttpSender: Send + Sync { /// Send an HTTP request and return the response with headers. /// The body is not consumed until you call bytes(), text(), write_to_file(), or drain(). + /// Events are sent through the provided channel. async fn send( &self, request: SendableHttpRequest, - events: &mut Vec, + event_tx: mpsc::UnboundedSender, ) -> Result; } @@ -245,8 +338,13 @@ impl HttpSender for ReqwestSender { async fn send( &self, request: SendableHttpRequest, - events: &mut Vec, + event_tx: mpsc::UnboundedSender, ) -> Result { + // Helper to send events (ignores errors if receiver is dropped) + let send_event = |event: HttpResponseEvent| { + let _ = event_tx.send(event); + }; + // Parse the HTTP method let method = Method::from_bytes(request.method.as_bytes()) .map_err(|e| Error::RequestError(format!("Invalid HTTP method: {}", e)))?; @@ -282,7 +380,7 @@ impl HttpSender for ReqwestSender { // Send the request let sendable_req = req_builder.build()?; - events.push(HttpResponseEvent::Setting( + send_event(HttpResponseEvent::Setting( "timeout".to_string(), if request.options.timeout.unwrap_or_default().is_zero() { "Infinity".to_string() @@ -291,7 +389,7 @@ impl HttpSender for ReqwestSender { }, )); - events.push(HttpResponseEvent::SendUrl { + send_event(HttpResponseEvent::SendUrl { path: sendable_req.url().path().to_string(), method: sendable_req.method().to_string(), }); @@ -300,10 +398,9 @@ impl HttpSender for ReqwestSender { for (name, value) in sendable_req.headers() { let v = value.to_str().unwrap_or_default().to_string(); request_headers.insert(name.to_string(), v.clone()); - events.push(HttpResponseEvent::HeaderUp(name.to_string(), v)); + send_event(HttpResponseEvent::HeaderUp(name.to_string(), v)); } - events.push(HttpResponseEvent::HeaderUpDone); - events.push(HttpResponseEvent::Info("Sending request to server".to_string())); + send_event(HttpResponseEvent::Info("Sending request to server".to_string())); // Map some errors to our own, so they look nicer let response = self.client.execute(sendable_req).await.map_err(|e| { @@ -323,7 +420,7 @@ impl HttpSender for ReqwestSender { let version = Some(version_to_str(&response.version())); let content_length = response.content_length(); - events.push(HttpResponseEvent::ReceiveUrl { + send_event(HttpResponseEvent::ReceiveUrl { version: response.version(), status: response.status().to_string(), }); @@ -332,11 +429,10 @@ impl HttpSender for ReqwestSender { let mut headers = HashMap::new(); for (key, value) in response.headers() { if let Ok(v) = value.to_str() { - events.push(HttpResponseEvent::HeaderDown(key.to_string(), v.to_string())); + send_event(HttpResponseEvent::HeaderDown(key.to_string(), v.to_string())); headers.insert(key.to_string(), v.to_string()); } } - events.push(HttpResponseEvent::HeaderDownDone); // Determine content encoding for decompression // HTTP headers are case-insensitive, so we need to search for any casing @@ -355,7 +451,9 @@ impl HttpSender for ReqwestSender { byte_stream.map(|result| result.map_err(|e| std::io::Error::other(e))), ); - let body_stream: BodyStream = Box::pin(stream_reader); + // Wrap the stream with tracking to emit chunk received events via the same channel + let tracking_reader = TrackingRead::new(stream_reader, event_tx); + let body_stream: BodyStream = Box::pin(tracking_reader); Ok(HttpResponse::new( status, diff --git a/src-tauri/yaak-http/src/transaction.rs b/src-tauri/yaak-http/src/transaction.rs index 7c688138..d779e9f5 100644 --- a/src-tauri/yaak-http/src/transaction.rs +++ b/src-tauri/yaak-http/src/transaction.rs @@ -1,6 +1,7 @@ use crate::error::Result; -use crate::sender::{HttpResponse, HttpResponseEvent, HttpSender}; +use crate::sender::{HttpResponse, HttpResponseEvent, HttpSender, RedirectBehavior}; use crate::types::SendableHttpRequest; +use tokio::sync::mpsc; use tokio::sync::watch::Receiver; /// HTTP Transaction that manages the lifecycle of a request, including redirect handling @@ -22,17 +23,23 @@ impl HttpTransaction { /// Execute the request with cancellation support. /// Returns an HttpResponse with unconsumed body - caller decides how to consume it. + /// Events are sent through the provided channel. pub async fn execute_with_cancellation( &self, request: SendableHttpRequest, mut cancelled_rx: Receiver, - ) -> Result<(HttpResponse, Vec)> { + event_tx: mpsc::UnboundedSender, + ) -> Result { let mut redirect_count = 0; let mut current_url = request.url; let mut current_method = request.method; let mut current_headers = request.headers; let mut current_body = request.body; - let mut events = Vec::new(); + + // Helper to send events (ignores errors if receiver is dropped) + let send_event = |event: HttpResponseEvent| { + let _ = event_tx.send(event); + }; loop { // Check for cancellation before each request @@ -50,14 +57,14 @@ impl HttpTransaction { }; // Send the request - events.push(HttpResponseEvent::Setting( + send_event(HttpResponseEvent::Setting( "redirects".to_string(), request.options.follow_redirects.to_string(), )); // Execute with cancellation support let response = tokio::select! { - result = self.sender.send(req, &mut events) => result?, + result = self.sender.send(req, event_tx.clone()) => result?, _ = cancelled_rx.changed() => { return Err(crate::error::Error::RequestCanceledError); } @@ -65,12 +72,12 @@ impl HttpTransaction { if !Self::is_redirect(response.status) { // Not a redirect - return the response for caller to consume body - return Ok((response, events)); + return Ok(response); } if !request.options.follow_redirects { // Redirects disabled - return the redirect response as-is - return Ok((response, events)); + return Ok(response); } // Check if we've exceeded max redirects @@ -99,7 +106,7 @@ impl HttpTransaction { // Also get status before draining let status = response.status; - events.push(HttpResponseEvent::Info("Ignoring the response body".to_string())); + send_event(HttpResponseEvent::Info("Ignoring the response body".to_string())); // Drain the redirect response body before following response.drain().await?; @@ -118,38 +125,36 @@ impl HttpTransaction { format!("{}/{}", base_path, location) }; - events.push(HttpResponseEvent::Info(format!( - "Issuing redirect {} to: {}", - redirect_count + 1, - current_url - ))); + // Determine redirect behavior based on status code and method + let behavior = if status == 303 { + // 303 See Other always changes to GET + RedirectBehavior::DropBody + } else if (status == 301 || status == 302) && current_method == "POST" { + // For 301/302, change POST to GET (common browser behavior) + RedirectBehavior::DropBody + } else { + // For 307 and 308, the method and body are preserved + // Also for 301/302 with non-POST methods + RedirectBehavior::Preserve + }; + + send_event(HttpResponseEvent::Redirect { + url: current_url.clone(), + status, + behavior: behavior.clone(), + }); // Handle method changes for certain redirect codes - if status == 303 { - // 303 See Other always changes to GET + if matches!(behavior, RedirectBehavior::DropBody) { if current_method != "GET" { current_method = "GET".to_string(); - events.push(HttpResponseEvent::Info("Changing method to GET".to_string())); } // Remove content-related headers current_headers.retain(|h| { let name_lower = h.0.to_lowercase(); !name_lower.starts_with("content-") && name_lower != "transfer-encoding" }); - } else if status == 301 || status == 302 { - // For 301/302, change POST to GET (common browser behavior) - // but keep other methods as-is - if current_method == "POST" { - events.push(HttpResponseEvent::Info("Changing method to GET".to_string())); - current_method = "GET".to_string(); - // Remove content-related headers - current_headers.retain(|h| { - let name_lower = h.0.to_lowercase(); - !name_lower.starts_with("content-") && name_lower != "transfer-encoding" - }); - } } - // For 307 and 308, the method and body are preserved // Reset body for next iteration (since it was moved in the send call) // For redirects that change method to GET or for all redirects since body was consumed @@ -231,7 +236,7 @@ mod tests { async fn send( &self, _request: SendableHttpRequest, - _events: &mut Vec, + _event_tx: mpsc::UnboundedSender, ) -> Result { let mut responses = self.responses.lock().await; if responses.is_empty() { @@ -271,7 +276,8 @@ mod tests { }; let (_tx, rx) = tokio::sync::watch::channel(false); - let (result, _) = transaction.execute_with_cancellation(request, rx).await.unwrap(); + let (event_tx, _event_rx) = mpsc::unbounded_channel(); + let result = transaction.execute_with_cancellation(request, rx, event_tx).await.unwrap(); assert_eq!(result.status, 200); // Consume the body to verify it @@ -303,7 +309,8 @@ mod tests { }; let (_tx, rx) = tokio::sync::watch::channel(false); - let (result, _) = transaction.execute_with_cancellation(request, rx).await.unwrap(); + let (event_tx, _event_rx) = mpsc::unbounded_channel(); + let result = transaction.execute_with_cancellation(request, rx, event_tx).await.unwrap(); assert_eq!(result.status, 200); let (body, _) = result.bytes().await.unwrap(); @@ -334,7 +341,8 @@ mod tests { }; let (_tx, rx) = tokio::sync::watch::channel(false); - let result = transaction.execute_with_cancellation(request, rx).await; + let (event_tx, _event_rx) = mpsc::unbounded_channel(); + let result = transaction.execute_with_cancellation(request, rx, event_tx).await; if let Err(crate::error::Error::RequestError(msg)) = result { assert!(msg.contains("Maximum redirect limit")); } else { diff --git a/src-tauri/yaak-models/bindings/gen_models.ts b/src-tauri/yaak-models/bindings/gen_models.ts index 055e5e35..f3742077 100644 --- a/src-tauri/yaak-models/bindings/gen_models.ts +++ b/src-tauri/yaak-models/bindings/gen_models.ts @@ -1,6 +1,6 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type AnyModel = CookieJar | Environment | Folder | GraphQlIntrospection | GrpcConnection | GrpcEvent | GrpcRequest | HttpRequest | HttpResponse | KeyValue | Plugin | Settings | SyncState | WebsocketConnection | WebsocketEvent | WebsocketRequest | Workspace | WorkspaceMeta; +export type AnyModel = CookieJar | Environment | Folder | GraphQlIntrospection | GrpcConnection | GrpcEvent | GrpcRequest | HttpRequest | HttpResponse | HttpResponseEvent | KeyValue | Plugin | Settings | SyncState | WebsocketConnection | WebsocketEvent | WebsocketRequest | Workspace | WorkspaceMeta; export type ClientCertificate = { host: string, port: number | null, crtFile: string | null, keyFile: string | null, pfxFile: string | null, passphrase: string | null, enabled?: boolean, }; @@ -40,6 +40,15 @@ export type HttpRequestHeader = { enabled?: boolean, name: string, value: string export type HttpResponse = { model: "http_response", id: string, createdAt: string, updatedAt: string, workspaceId: string, requestId: string, bodyPath: string | null, contentLength: number | null, contentLengthCompressed: number | null, elapsed: number, elapsedHeaders: number, error: string | null, headers: Array, remoteAddr: string | null, requestHeaders: Array, status: number, statusReason: string | null, state: HttpResponseState, url: string, version: string | null, }; +export type HttpResponseEvent = { model: "http_response_event", id: string, createdAt: string, updatedAt: string, workspaceId: string, responseId: string, event: HttpResponseEventData, }; + +/** + * Serializable representation of HTTP response events for DB storage. + * This mirrors `yaak_http::sender::HttpResponseEvent` but with serde support. + * The `From` impl is in yaak-http to avoid circular dependencies. + */ +export type HttpResponseEventData = { "type": "start_request" } | { "type": "end_request" } | { "type": "setting", name: string, value: string, } | { "type": "info", message: string, } | { "type": "redirect", url: string, status: number, behavior: string, } | { "type": "send_url", method: string, path: string, } | { "type": "receive_url", version: string, status: string, } | { "type": "header_up", name: string, value: string, } | { "type": "header_down", name: string, value: string, } | { "type": "chunk_sent", bytes: number, } | { "type": "chunk_received", bytes: number, }; + export type HttpResponseHeader = { name: string, value: string, }; export type HttpResponseState = "initialized" | "connected" | "closed"; diff --git a/src-tauri/yaak-models/guest-js/atoms.ts b/src-tauri/yaak-models/guest-js/atoms.ts index fc12373d..da3ad730 100644 --- a/src-tauri/yaak-models/guest-js/atoms.ts +++ b/src-tauri/yaak-models/guest-js/atoms.ts @@ -15,6 +15,7 @@ export const grpcEventsAtom = createOrderedModelAtom('grpc_event', 'createdAt', export const grpcRequestsAtom = createModelAtom('grpc_request'); export const httpRequestsAtom = createModelAtom('http_request'); export const httpResponsesAtom = createOrderedModelAtom('http_response', 'createdAt', 'desc'); +export const httpResponseEventsAtom = createOrderedModelAtom('http_response_event', 'createdAt', 'asc'); export const keyValuesAtom = createModelAtom('key_value'); export const pluginsAtom = createModelAtom('plugin'); export const settingsAtom = createSingularModelAtom('settings'); diff --git a/src-tauri/yaak-models/guest-js/util.ts b/src-tauri/yaak-models/guest-js/util.ts index 412fa98b..81981114 100644 --- a/src-tauri/yaak-models/guest-js/util.ts +++ b/src-tauri/yaak-models/guest-js/util.ts @@ -11,6 +11,7 @@ export function newStoreData(): ModelStoreData { grpc_request: {}, http_request: {}, http_response: {}, + http_response_event: {}, key_value: {}, plugin: {}, settings: {}, diff --git a/src-tauri/yaak-models/migrations/20251221000000_http-response-events.sql b/src-tauri/yaak-models/migrations/20251221000000_http-response-events.sql new file mode 100644 index 00000000..ad9f0fa1 --- /dev/null +++ b/src-tauri/yaak-models/migrations/20251221000000_http-response-events.sql @@ -0,0 +1,15 @@ +CREATE TABLE http_response_events +( + id TEXT NOT NULL + PRIMARY KEY, + model TEXT DEFAULT 'http_response_event' NOT NULL, + workspace_id TEXT NOT NULL + REFERENCES workspaces + ON DELETE CASCADE, + response_id TEXT NOT NULL + REFERENCES http_responses + ON DELETE CASCADE, + created_at DATETIME DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')) NOT NULL, + updated_at DATETIME DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')) NOT NULL, + event TEXT NOT NULL +); diff --git a/src-tauri/yaak-models/src/models.rs b/src-tauri/yaak-models/src/models.rs index f3f96dac..012a40fd 100644 --- a/src-tauri/yaak-models/src/models.rs +++ b/src-tauri/yaak-models/src/models.rs @@ -1439,6 +1439,143 @@ impl UpsertModelInfo for HttpResponse { } } +/// Serializable representation of HTTP response events for DB storage. +/// This mirrors `yaak_http::sender::HttpResponseEvent` but with serde support. +/// The `From` impl is in yaak-http to avoid circular dependencies. +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[serde(tag = "type", rename_all = "snake_case")] +#[ts(export, export_to = "gen_models.ts")] +pub enum HttpResponseEventData { + Setting { + name: String, + value: String, + }, + Info { + message: String, + }, + Redirect { + url: String, + status: u16, + behavior: String, + }, + SendUrl { + method: String, + path: String, + }, + ReceiveUrl { + version: String, + status: String, + }, + HeaderUp { + name: String, + value: String, + }, + HeaderDown { + name: String, + value: String, + }, + ChunkSent { + bytes: usize, + }, + ChunkReceived { + bytes: usize, + }, +} + +impl Default for HttpResponseEventData { + fn default() -> Self { + Self::Info { message: String::new() } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default, TS)] +#[serde(default, rename_all = "camelCase")] +#[ts(export, export_to = "gen_models.ts")] +#[enum_def(table_name = "http_response_events")] +pub struct HttpResponseEvent { + #[ts(type = "\"http_response_event\"")] + pub model: String, + pub id: String, + pub created_at: NaiveDateTime, + pub updated_at: NaiveDateTime, + pub workspace_id: String, + pub response_id: String, + pub event: HttpResponseEventData, +} + +impl UpsertModelInfo for HttpResponseEvent { + fn table_name() -> impl IntoTableRef + IntoIden { + HttpResponseEventIden::Table + } + + fn id_column() -> impl IntoIden + Eq + Clone { + HttpResponseEventIden::Id + } + + fn generate_id() -> String { + generate_prefixed_id("re") + } + + fn order_by() -> (impl IntoColumnRef, Order) { + (HttpResponseEventIden::CreatedAt, Order::Asc) + } + + fn get_id(&self) -> String { + self.id.clone() + } + + fn insert_values( + self, + source: &UpdateSource, + ) -> Result)>> { + use HttpResponseEventIden::*; + Ok(vec![ + (CreatedAt, upsert_date(source, self.created_at)), + (UpdatedAt, upsert_date(source, self.updated_at)), + (WorkspaceId, self.workspace_id.into()), + (ResponseId, self.response_id.into()), + (Event, serde_json::to_string(&self.event)?.into()), + ]) + } + + fn update_columns() -> Vec { + vec![ + HttpResponseEventIden::UpdatedAt, + HttpResponseEventIden::Event, + ] + } + + fn from_row(r: &Row) -> rusqlite::Result + where + Self: Sized, + { + let event: String = r.get("event")?; + Ok(Self { + id: r.get("id")?, + model: r.get("model")?, + workspace_id: r.get("workspace_id")?, + response_id: r.get("response_id")?, + created_at: r.get("created_at")?, + updated_at: r.get("updated_at")?, + event: serde_json::from_str(&event).unwrap_or_default(), + }) + } +} + +impl HttpResponseEvent { + pub fn new(response_id: &str, workspace_id: &str, event: HttpResponseEventData) -> Self { + Self { + model: "http_response_event".to_string(), + id: Self::generate_id(), + created_at: Utc::now().naive_utc(), + updated_at: Utc::now().naive_utc(), + workspace_id: workspace_id.to_string(), + response_id: response_id.to_string(), + event, + } + } +} + #[derive(Debug, Clone, Serialize, Deserialize, Default, TS)] #[serde(default, rename_all = "camelCase")] #[ts(export, export_to = "gen_models.ts")] @@ -2189,6 +2326,7 @@ define_any_model! { GrpcRequest, HttpRequest, HttpResponse, + HttpResponseEvent, KeyValue, Plugin, Settings, diff --git a/src-tauri/yaak-sync/src/models.rs b/src-tauri/yaak-sync/src/models.rs index 41d1fff9..9c3c75fe 100644 --- a/src-tauri/yaak-sync/src/models.rs +++ b/src-tauri/yaak-sync/src/models.rs @@ -208,6 +208,7 @@ impl TryFrom for SyncModel { AnyModel::GrpcConnection(m) => return Err(UnknownModel(m.model)), AnyModel::GrpcEvent(m) => return Err(UnknownModel(m.model)), AnyModel::HttpResponse(m) => return Err(UnknownModel(m.model)), + AnyModel::HttpResponseEvent(m) => return Err(UnknownModel(m.model)), AnyModel::KeyValue(m) => return Err(UnknownModel(m.model)), AnyModel::Plugin(m) => return Err(UnknownModel(m.model)), AnyModel::Settings(m) => return Err(UnknownModel(m.model)), diff --git a/src-web/components/HttpResponsePane.tsx b/src-web/components/HttpResponsePane.tsx index 6aea7918..c2d23e30 100644 --- a/src-web/components/HttpResponsePane.tsx +++ b/src-web/components/HttpResponsePane.tsx @@ -4,6 +4,7 @@ import type { ComponentType, CSSProperties } from 'react'; import { lazy, Suspense, useCallback, useMemo } from 'react'; import { useLocalStorage } from 'react-use'; import { useCancelHttpResponse } from '../hooks/useCancelHttpResponse'; +import { useHttpResponseEvents } from '../hooks/useHttpResponseEvents'; import { usePinnedHttpResponse } from '../hooks/usePinnedHttpResponse'; import { useResponseViewMode } from '../hooks/useResponseViewMode'; import { getMimeTypeFromContentType } from '../lib/contentType'; @@ -23,6 +24,7 @@ import { TabContent, Tabs } from './core/Tabs/Tabs'; import { EmptyStateText } from './EmptyStateText'; import { ErrorBoundary } from './ErrorBoundary'; import { RecentHttpResponsesDropdown } from './RecentHttpResponsesDropdown'; +import { ResponseEvents } from './ResponseEvents'; import { ResponseHeaders } from './ResponseHeaders'; import { ResponseInfo } from './ResponseInfo'; import { AudioViewer } from './responseViewers/AudioViewer'; @@ -46,6 +48,7 @@ interface Props { const TAB_BODY = 'body'; const TAB_HEADERS = 'headers'; const TAB_INFO = 'info'; +const TAB_EVENTS = 'events'; export function HttpResponsePane({ style, className, activeRequestId }: Props) { const { activeResponse, setPinnedResponseId, responses } = usePinnedHttpResponse(activeRequestId); @@ -57,12 +60,13 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) { const contentType = getContentTypeFromHeaders(activeResponse?.headers ?? null); const mimeType = contentType == null ? null : getMimeTypeFromContentType(contentType).essence; + const responseEvents = useHttpResponseEvents(activeResponse); + const tabs = useMemo( () => [ { value: TAB_BODY, label: 'Preview Mode', - hidden: (activeResponse?.contentLength || 0) === 0, options: { value: viewMode, onChange: setViewMode, @@ -82,6 +86,11 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) { /> ), }, + { + value: TAB_EVENTS, + label: 'Timeline', + rightSlot: , + }, { value: TAB_INFO, label: 'Info', @@ -93,7 +102,7 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) { setViewMode, viewMode, activeResponse?.requestHeaders.length, - activeResponse?.contentLength, + responseEvents.data?.length, ], ); const activeTab = activeTabs?.[activeRequestId]; @@ -224,6 +233,9 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) { + + + diff --git a/src-web/components/ResponseEvents.tsx b/src-web/components/ResponseEvents.tsx new file mode 100644 index 00000000..fd32f569 --- /dev/null +++ b/src-web/components/ResponseEvents.tsx @@ -0,0 +1,341 @@ +import type { + HttpResponse, + HttpResponseEvent, + HttpResponseEventData, +} from '@yaakapp-internal/models'; +import classNames from 'classnames'; +import { format } from 'date-fns'; +import { Fragment, type ReactNode, useMemo, useState } from 'react'; +import { useHttpResponseEvents } from '../hooks/useHttpResponseEvents'; +import { AutoScroller } from './core/AutoScroller'; +import { Banner } from './core/Banner'; +import { HttpMethodTagRaw } from './core/HttpMethodTag'; +import { HttpStatusTagRaw } from './core/HttpStatusTag'; +import { Icon, type IconProps } from './core/Icon'; +import { KeyValueRow, KeyValueRows } from './core/KeyValueRow'; +import { Separator } from './core/Separator'; +import { SplitLayout } from './core/SplitLayout'; + +interface Props { + response: HttpResponse; +} + +export function ResponseEvents({ response }: Props) { + return ( + + + + ); +} + +function ActualResponseEvents({ response }: Props) { + const [activeEventIndex, setActiveEventIndex] = useState(null); + const { data: events, error, isLoading } = useHttpResponseEvents(response); + + const activeEvent = useMemo( + () => (activeEventIndex == null ? null : events?.[activeEventIndex]), + [activeEventIndex, events], + ); + + if (isLoading) { + return
Loading events...
; + } + + if (error) { + return ( + + {String(error)} + + ); + } + + if (!events || events.length === 0) { + return
No events recorded
; + } + + return ( + ( + ( + { + if (i === activeEventIndex) setActiveEventIndex(null); + else setActiveEventIndex(i); + }} + /> + )} + /> + )} + secondSlot={ + activeEvent + ? () => ( +
+
+ +
+
+ +
+
+ ) + : null + } + /> + ); +} + +function EventRow({ + onClick, + isActive, + event, +}: { + onClick: () => void; + isActive: boolean; + event: HttpResponseEvent; +}) { + const display = getEventDisplay(event.event); + const { icon, color, summary } = display; + + return ( +
+ +
+ ); +} + +function formatBytes(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + +function EventDetails({ event }: { event: HttpResponseEvent }) { + const { label } = getEventDisplay(event.event); + const timestamp = format(new Date(`${event.createdAt}Z`), 'HH:mm:ss.SSS'); + const e = event.event; + + // Headers - show name and value with Editor for JSON + if (e.type === 'header_up' || e.type === 'header_down') { + return ( +
+ + + {e.name} + {e.value} + +
+ ); + } + + // Request URL - show method and path separately + if (e.type === 'send_url') { + return ( +
+ + + + + + {e.path} + +
+ ); + } + + // Response status - show version and status separately + if (e.type === 'receive_url') { + return ( +
+ + + {e.version} + + + + +
+ ); + } + + // Redirect - show status, URL, and behavior + if (e.type === 'redirect') { + return ( +
+ + + + + + {e.url} + + {e.behavior === 'drop_body' ? 'Drop body, change to GET' : 'Preserve method and body'} + + +
+ ); + } + + // Settings - show as key/value + if (e.type === 'setting') { + return ( +
+ + + {e.name} + {e.value} + +
+ ); + } + + // Chunks - show formatted bytes + if (e.type === 'chunk_sent' || e.type === 'chunk_received') { + const direction = e.type === 'chunk_sent' ? 'Sent' : 'Received'; + return ( +
+ +
{formatBytes(e.bytes)}
+
+ ); + } + + // Default - use summary + const { summary } = getEventDisplay(event.event); + return ( +
+ +
{summary}
+
+ ); +} + +function DetailHeader({ title, timestamp }: { title: string; timestamp: string }) { + return ( +
+

{title}

+ {timestamp} +
+ ); +} + +type EventDisplay = { + icon: IconProps['icon']; + color: IconProps['color']; + label: string; + summary: ReactNode; +}; + +function getEventDisplay(event: HttpResponseEventData): EventDisplay { + switch (event.type) { + case 'start_request': + return { + icon: 'info', + color: 'secondary', + label: 'Start', + summary: 'Request started', + }; + case 'end_request': + return { + icon: 'info', + color: 'secondary', + label: 'End', + summary: 'Request complete', + }; + case 'setting': + return { + icon: 'settings', + color: 'secondary', + label: 'Setting', + summary: `${event.name} = ${event.value}`, + }; + case 'info': + return { + icon: 'info', + color: 'secondary', + label: 'Info', + summary: event.message, + }; + case 'redirect': + return { + icon: 'arrow_big_right_dash', + color: 'warning', + label: 'Redirect', + summary: `Redirecting ${event.status} ${event.url}${event.behavior === 'drop_body' ? ' (drop body)' : ''}`, + }; + case 'send_url': + return { + icon: 'arrow_big_up_dash', + color: 'primary', + label: 'Request', + summary: `${event.method} ${event.path}`, + }; + case 'receive_url': + return { + icon: 'arrow_big_down_dash', + color: 'info', + label: 'Response', + summary: `${event.version} ${event.status}`, + }; + case 'header_up': + return { + icon: 'arrow_big_up_dash', + color: 'primary', + label: 'Header', + summary: `${event.name}: ${event.value}`, + }; + case 'header_down': + return { + icon: 'arrow_big_down_dash', + color: 'info', + label: 'Header', + summary: `${event.name}: ${event.value}`, + }; + + case 'chunk_sent': + return { + icon: 'info', + color: 'secondary', + label: 'Chunk', + summary: `${event.bytes} bytes sent`, + }; + case 'chunk_received': + return { + icon: 'info', + color: 'secondary', + label: 'Chunk', + summary: `${event.bytes} bytes received`, + }; + default: + return { + icon: 'info', + color: 'secondary', + label: 'Unknown', + summary: 'Unknown event', + }; + } +} diff --git a/src-web/components/core/HttpMethodTag.tsx b/src-web/components/core/HttpMethodTag.tsx index 486e1163..bdb0c832 100644 --- a/src-web/components/core/HttpMethodTag.tsx +++ b/src-web/components/core/HttpMethodTag.tsx @@ -41,10 +41,12 @@ export function HttpMethodTagRaw({ className, method, short, + forceColor, }: { method: string; className?: string; short?: boolean; + forceColor?: boolean; }) { let label = method.toUpperCase(); if (short) { @@ -54,7 +56,8 @@ export function HttpMethodTagRaw({ const m = method.toUpperCase(); - const colored = useAtomValue(settingsAtom).coloredMethods; + const settings = useAtomValue(settingsAtom); + const colored = forceColor || settings.coloredMethods; return ( ; +} +export function HttpStatusTagRaw({ + status, + state, + className, + showReason, + statusReason, + short, +}: Omit & { + status: number | string; + state?: HttpResponseState; + statusReason?: string | null; +}) { let colorClass: string; let label = `${status}`; + const statusN = typeof status === 'number' ? status : parseInt(status, 10); if (state === 'initialized') { label = short ? 'CONN' : 'CONNECTING'; colorClass = 'text-text-subtle'; - } else if (status < 100) { + } else if (statusN < 100) { label = short ? 'ERR' : 'ERROR'; colorClass = 'text-danger'; - } else if (status < 200) { + } else if (statusN < 200) { colorClass = 'text-info'; - } else if (status < 300) { + } else if (statusN < 300) { colorClass = 'text-success'; - } else if (status < 400) { + } else if (statusN < 400) { colorClass = 'text-primary'; - } else if (status < 500) { + } else if (statusN < 500) { colorClass = 'text-warning'; } else { colorClass = 'text-danger'; @@ -34,7 +50,7 @@ export function HttpStatusTag({ response, className, showReason, short }: Props) return ( - {label} {showReason && 'statusReason' in response ? response.statusReason : null} + {label} {showReason && statusReason} ); } diff --git a/src-web/hooks/useHttpResponseEvents.ts b/src-web/hooks/useHttpResponseEvents.ts new file mode 100644 index 00000000..6e0dff79 --- /dev/null +++ b/src-web/hooks/useHttpResponseEvents.ts @@ -0,0 +1,28 @@ +import { invoke } from '@tauri-apps/api/core'; +import type { HttpResponse, HttpResponseEvent } from '@yaakapp-internal/models'; +import { httpResponseEventsAtom, replaceModelsInStore } from '@yaakapp-internal/models'; +import { useAtomValue } from 'jotai'; +import { useEffect, useMemo } from 'react'; + +export function useHttpResponseEvents(response: HttpResponse | null) { + const allEvents = useAtomValue(httpResponseEventsAtom); + + useEffect(() => { + if (response?.id == null) { + replaceModelsInStore('http_response_event', []); + return; + } + + invoke('cmd_get_http_response_events', { responseId: response.id }).then( + (events) => replaceModelsInStore('http_response_event', events), + ); + }, [response?.id]); + + // Filter events for the current response + const events = useMemo( + () => allEvents.filter((e) => e.responseId === response?.id), + [allEvents, response?.id], + ); + + return { data: events, error: null, isLoading: false }; +} diff --git a/src-web/lib/contentType.ts b/src-web/lib/contentType.ts index c2558281..c4fd82ec 100644 --- a/src-web/lib/contentType.ts +++ b/src-web/lib/contentType.ts @@ -13,7 +13,7 @@ export function languageFromContentType( return 'xml'; } if (justContentType.includes('html')) { - const detected = detectFromContent(content); + const detected = languageFromContent(content); if (detected === 'xml') { // If it's detected as XML, but is already HTML, don't change it return 'html'; @@ -22,16 +22,16 @@ export function languageFromContentType( } if (justContentType.includes('javascript')) { // Sometimes `application/javascript` returns JSON, so try detecting that - return detectFromContent(content, 'javascript'); + return languageFromContent(content, 'javascript'); } if (justContentType.includes('markdown')) { return 'markdown'; } - return detectFromContent(content, 'text'); + return languageFromContent(content, 'text'); } -function detectFromContent( +export function languageFromContent( content: string | null, fallback?: EditorProps['language'], ): EditorProps['language'] { diff --git a/src-web/lib/tauri.ts b/src-web/lib/tauri.ts index 124ea691..41d6a166 100644 --- a/src-web/lib/tauri.ts +++ b/src-web/lib/tauri.ts @@ -17,6 +17,7 @@ type TauriCmd = | 'cmd_format_json' | 'cmd_get_http_authentication_config' | 'cmd_get_http_authentication_summaries' + | 'cmd_get_http_response_events' | 'cmd_get_sse_events' | 'cmd_get_themes' | 'cmd_get_workspace_meta'