diff --git a/.gitattributes b/.gitattributes index 84042659..0b6a9cb7 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,5 +1,7 @@ src-tauri/vendored/**/* linguist-generated=true src-tauri/gen/schemas/**/* linguist-generated=true +**/bindings/* linguist-generated=true +src-tauri/yaak-templates/pkg/* linguist-generated=true # Ensure consistent line endings for test files that check exact content src-tauri/yaak-http/tests/test.txt text eol=lf diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml deleted file mode 100644 index 8452b0f2..00000000 --- a/.github/workflows/claude-code-review.yml +++ /dev/null @@ -1,57 +0,0 @@ -name: Claude Code Review - -on: - pull_request: - types: [opened, synchronize] - # Optional: Only run on specific file changes - # paths: - # - "src/**/*.ts" - # - "src/**/*.tsx" - # - "src/**/*.js" - # - "src/**/*.jsx" - -jobs: - claude-review: - # Optional: Filter by PR author - # if: | - # github.event.pull_request.user.login == 'external-contributor' || - # github.event.pull_request.user.login == 'new-developer' || - # github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR' - - runs-on: ubuntu-latest - permissions: - contents: read - pull-requests: read - issues: read - id-token: write - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - with: - fetch-depth: 1 - - - name: Run Claude Code Review - id: claude-review - uses: anthropics/claude-code-action@v1 - with: - claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} - prompt: | - REPO: ${{ github.repository }} - PR NUMBER: ${{ github.event.pull_request.number }} - - Please review this pull request and provide feedback on: - - Code quality and best practices - - Potential bugs or issues - - Performance considerations - - Security concerns - - Test coverage - - Use the repository's CLAUDE.md for guidance on style and conventions. Be constructive and helpful in your feedback. - - Use `gh pr comment` with your Bash tool to leave your review as a comment on the PR. - - # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md - # or https://code.claude.com/docs/en/cli-reference for available options - claude_args: '--allowed-tools "Bash(gh issue view:*),Bash(gh search:*),Bash(gh issue list:*),Bash(gh pr comment:*),Bash(gh pr diff:*),Bash(gh pr view:*),Bash(gh pr list:*)"' - diff --git a/packages/plugin-runtime-types/src/bindings/gen_models.ts b/packages/plugin-runtime-types/src/bindings/gen_models.ts index 6b2eb5c8..454903fe 100644 --- a/packages/plugin-runtime-types/src/bindings/gen_models.ts +++ b/packages/plugin-runtime-types/src/bindings/gen_models.ts @@ -12,7 +12,7 @@ export type HttpRequest = { model: "http_request", id: string, createdAt: string export type HttpRequestHeader = { enabled?: boolean, name: string, value: string, id?: string, }; -export type HttpResponse = { model: "http_response", id: string, createdAt: string, updatedAt: string, workspaceId: string, requestId: string, bodyPath: string | null, contentLength: number | null, elapsed: number, elapsedHeaders: number, error: string | null, headers: Array, remoteAddr: string | null, status: number, statusReason: string | null, state: HttpResponseState, url: string, version: string | null, }; +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, requestContentLength: number | null, requestHeaders: Array, status: number, statusReason: string | null, state: HttpResponseState, url: string, version: string | null, }; export type HttpResponseHeader = { name: string, value: string, }; diff --git a/src-tauri/src/http_request.rs b/src-tauri/src/http_request.rs index 97715188..c465e2e4 100644 --- a/src-tauri/src/http_request.rs +++ b/src-tauri/src/http_request.rs @@ -1,23 +1,27 @@ use crate::error::Error::GenericError; use crate::error::Result; use crate::render::render_http_request; -use crate::response_err; use log::{debug, warn}; use reqwest_cookie_store::{CookieStore, CookieStoreMutex}; +use std::pin::Pin; use std::sync::Arc; use std::time::{Duration, Instant}; use tauri::{AppHandle, Manager, Runtime, WebviewWindow}; use tokio::fs::{File, create_dir_all}; -use tokio::io::{AsyncReadExt, AsyncWriteExt}; -use tokio::sync::Mutex; +use tokio::io::{AsyncRead, AsyncReadExt, AsyncWriteExt}; use tokio::sync::watch::Receiver; +use tokio_util::bytes::Bytes; use yaak_http::client::{ HttpConnectionOptions, HttpConnectionProxySetting, HttpConnectionProxySettingAuth, }; use yaak_http::manager::HttpConnectionManager; use yaak_http::sender::ReqwestSender; +use yaak_http::tee_reader::TeeReader; use yaak_http::transaction::HttpTransaction; -use yaak_http::types::{SendableHttpRequest, SendableHttpRequestOptions, append_query_params}; +use yaak_http::types::{ + SendableBody, SendableHttpRequest, SendableHttpRequestOptions, append_query_params, +}; +use yaak_models::blob_manager::{BlobManagerExt, BodyChunk}; use yaak_models::models::{ Cookie, CookieJar, Environment, HttpRequest, HttpResponse, HttpResponseEvent, HttpResponseHeader, HttpResponseState, ProxySetting, ProxySettingAuth, @@ -32,6 +36,55 @@ use yaak_plugins::template_callback::PluginTemplateCallback; use yaak_templates::RenderOptions; use yaak_tls::find_client_certificate; +/// Chunk size for storing request bodies (1MB) +const REQUEST_BODY_CHUNK_SIZE: usize = 1024 * 1024; + +/// Context for managing response state during HTTP transactions. +/// Handles both persisted responses (stored in DB) and ephemeral responses (in-memory only). +struct ResponseContext { + app_handle: AppHandle, + response: HttpResponse, + update_source: UpdateSource, +} + +impl ResponseContext { + fn new(app_handle: AppHandle, response: HttpResponse, update_source: UpdateSource) -> Self { + Self { app_handle, response, update_source } + } + + /// Whether this response is persisted (has a non-empty ID) + fn is_persisted(&self) -> bool { + !self.response.id.is_empty() + } + + /// Update the response state. For persisted responses, fetches from DB, applies the + /// closure, and updates the DB. For ephemeral responses, just applies the closure + /// to the in-memory response. + fn update(&mut self, func: F) -> Result<()> + where + F: FnOnce(&mut HttpResponse), + { + if self.is_persisted() { + let r = self.app_handle.with_tx(|tx| { + let mut r = tx.get_http_response(&self.response.id)?; + func(&mut r); + tx.update_http_response_if_id(&r, &self.update_source)?; + Ok(r) + })?; + self.response = r; + Ok(()) + } else { + func(&mut self.response); + Ok(()) + } + } + + /// Get the current response state + fn response(&self) -> &HttpResponse { + &self.response + } +} + pub async fn send_http_request( window: &WebviewWindow, unrendered_request: &HttpRequest, @@ -62,25 +115,38 @@ pub async fn send_http_request_with_context( plugin_context: &PluginContext, ) -> Result { let app_handle = window.app_handle().clone(); - let response = Arc::new(Mutex::new(og_response.clone())); let update_source = UpdateSource::from_window(window); + let mut response_ctx = + ResponseContext::new(app_handle.clone(), og_response.clone(), update_source); // Execute the inner send logic and handle errors consistently + let start = Instant::now(); let result = send_http_request_inner( window, unrendered_request, - og_response, environment, cookie_jar, cancelled_rx, plugin_context, + &mut response_ctx, ) .await; match result { Ok(response) => Ok(response), Err(e) => { - Ok(response_err(&app_handle, &*response.lock().await, e.to_string(), &update_source)) + let error = e.to_string(); + let elapsed = start.elapsed().as_millis() as i32; + warn!("Failed to send request: {error:?}"); + let _ = response_ctx.update(|r| { + r.state = HttpResponseState::Closed; + r.elapsed = elapsed; + if r.elapsed_headers == 0 { + r.elapsed_headers = elapsed; + } + r.error = Some(error); + }); + Ok(response_ctx.response().clone()) } } } @@ -88,26 +154,24 @@ pub async fn send_http_request_with_context( async fn send_http_request_inner( window: &WebviewWindow, unrendered_request: &HttpRequest, - og_response: &HttpResponse, environment: Option, cookie_jar: Option, cancelled_rx: &Receiver, plugin_context: &PluginContext, + response_ctx: &mut ResponseContext, ) -> Result { let app_handle = window.app_handle().clone(); let plugin_manager = app_handle.state::(); let connection_manager = app_handle.state::(); let settings = window.db().get_settings(); - let wrk_id = &unrendered_request.workspace_id; - let fld_id = unrendered_request.folder_id.as_deref(); - let env_id = environment.map(|e| e.id); - let resp_id = og_response.id.clone(); - let workspace = window.db().get_workspace(wrk_id)?; - let response = Arc::new(Mutex::new(og_response.clone())); - let update_source = UpdateSource::from_window(window); + let workspace_id = &unrendered_request.workspace_id; + let folder_id = unrendered_request.folder_id.as_deref(); + let environment_id = environment.map(|e| e.id); + let workspace = window.db().get_workspace(workspace_id)?; let (resolved, auth_context_id) = resolve_http_request(window, unrendered_request)?; let cb = PluginTemplateCallback::new(window.app_handle(), &plugin_context, RenderPurpose::Send); - let env_chain = window.db().resolve_environments(&workspace.id, fld_id, env_id.as_deref())?; + let env_chain = + window.db().resolve_environments(&workspace.id, folder_id, environment_id.as_deref())?; let request = render_http_request(&resolved, env_chain, &cb, &RenderOptions::throw()).await?; // Build the sendable request using the new SendableHttpRequest type @@ -195,17 +259,30 @@ async fn send_http_request_inner( ) .await?; - let start_for_cancellation = Instant::now(); - let final_resp = execute_transaction( - client, - sendable_request, - response.clone(), - &resp_id, - &app_handle, - &update_source, - cancelled_rx.clone(), - ) - .await; + let result = + execute_transaction(client, sendable_request, response_ctx, cancelled_rx.clone()).await; + + // Wait for blob writing to complete and check for errors + let final_result = match result { + Ok((response, maybe_blob_write_handle)) => { + // Check if blob writing failed + if let Some(handle) = maybe_blob_write_handle { + if let Ok(Err(e)) = handle.await { + // Update response with the storage error + let _ = response_ctx.update(|r| { + let error_msg = + format!("Request succeeded but failed to store request body: {}", e); + r.error = Some(match &r.error { + Some(existing) => format!("{}; {}", existing, error_msg), + None => error_msg, + }); + }); + } + } + Ok(response) + } + Err(e) => Err(e), + }; // Persist cookies back to the database after the request completes if let Some((cookie_store, mut cj)) = maybe_cookie_manager { @@ -220,7 +297,7 @@ async fn send_http_request_inner( }) .collect(); cj.cookies = cookies; - if let Err(e) = window.db().upsert_cookie_jar(&cj, &update_source) { + if let Err(e) = window.db().upsert_cookie_jar(&cj, &UpdateSource::Background) { warn!("Failed to persist cookies to database: {}", e); } } @@ -230,23 +307,7 @@ async fn send_http_request_inner( } } - match final_resp { - Ok(r) => Ok(r), - Err(e) => match app_handle.db().get_http_response(&resp_id) { - Ok(mut r) => { - r.state = HttpResponseState::Closed; - r.elapsed = start_for_cancellation.elapsed().as_millis() as i32; - r.elapsed_headers = start_for_cancellation.elapsed().as_millis() as i32; - r.error = Some(e.to_string()); - app_handle - .db() - .update_http_response_if_id(&r, &UpdateSource::from_window(window)) - .expect("Failed to update response"); - Ok(r) - } - _ => Err(GenericError("Ephemeral request was cancelled".to_string())), - }, - } + final_result } pub fn resolve_http_request( @@ -268,13 +329,15 @@ pub fn resolve_http_request( async fn execute_transaction( client: reqwest::Client, - sendable_request: SendableHttpRequest, - response: Arc>, - response_id: &String, - app_handle: &AppHandle, - update_source: &UpdateSource, + mut sendable_request: SendableHttpRequest, + response_ctx: &mut ResponseContext, mut cancelled_rx: Receiver, -) -> Result { +) -> Result<(HttpResponse, Option>>)> { + let app_handle = &response_ctx.app_handle.clone(); + let response_id = response_ctx.response().id.clone(); + let workspace_id = response_ctx.response().workspace_id.clone(); + let is_persisted = response_ctx.is_persisted(); + let sender = ReqwestSender::with_client(client); let transaction = HttpTransaction::new(sender); let start = Instant::now(); @@ -286,30 +349,85 @@ async fn execute_transaction( .map(|(name, value)| HttpResponseHeader { name: name.clone(), value: value.clone() }) .collect(); - { - // Update response with headers info and mark as connected - let mut r = response.lock().await; + // Update response with headers info + response_ctx.update(|r| { r.url = sendable_request.url.clone(); - r.request_headers = request_headers.clone(); - app_handle.db().update_http_response_if_id(&r, &update_source)?; - } + r.request_headers = request_headers; + })?; - // Create channel for receiving events and spawn a task to store them in DB + // Create bounded channel for receiving events and spawn a task to store them in DB + // Buffer size of 100 events provides backpressure if DB writes are slow let (event_tx, mut event_rx) = - tokio::sync::mpsc::unbounded_channel::(); + tokio::sync::mpsc::channel::(100); - // Write events to DB in a task - { + // Write events to DB in a task (only for persisted responses) + if is_persisted { 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(); + let update_source = response_ctx.update_source.clone(); + let workspace_id = workspace_id.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); + let _ = app_handle.db().upsert_http_response_event(&db_event, &update_source); } }); + } else { + // For ephemeral responses, just drain the events + tokio::spawn(async move { while event_rx.recv().await.is_some() {} }); + }; + + // Capture request body as it's sent (only for persisted responses) + let body_id = format!("{}.request", response_id); + let maybe_blob_write_handle = match sendable_request.body { + Some(SendableBody::Bytes(bytes)) => { + if is_persisted { + write_bytes_to_db_sync(response_ctx, &body_id, bytes.clone())?; + } + sendable_request.body = Some(SendableBody::Bytes(bytes)); + None + } + Some(SendableBody::Stream(stream)) => { + // Wrap stream with TeeReader to capture data as it's read + // Bounded channel with buffer size of 10 chunks (~10MB) provides backpressure + let (body_chunk_tx, body_chunk_rx) = tokio::sync::mpsc::channel::>(10); + let tee_reader = TeeReader::new(stream, body_chunk_tx); + let pinned: Pin> = Box::pin(tee_reader); + + let handle = if is_persisted { + // Spawn task to write request body chunks to blob DB + let app_handle = app_handle.clone(); + let response_id = response_id.clone(); + let workspace_id = workspace_id.clone(); + let body_id = body_id.clone(); + let update_source = response_ctx.update_source.clone(); + Some(tauri::async_runtime::spawn(async move { + write_stream_chunks_to_db( + app_handle, + &body_id, + &workspace_id, + &response_id, + &update_source, + body_chunk_rx, + ) + .await + })) + } else { + // For ephemeral responses, just drain the body chunks + tauri::async_runtime::spawn(async move { + let mut rx = body_chunk_rx; + while rx.recv().await.is_some() {} + }); + None + }; + + sendable_request.body = Some(SendableBody::Stream(pinned)); + handle + } + None => { + sendable_request.body = None; + None + } }; // Execute the transaction with cancellation support @@ -320,44 +438,42 @@ async fn execute_transaction( .await?; // Prepare the response path before consuming the body - let dir = app_handle.path().app_data_dir()?; - let base_dir = dir.join("responses"); - create_dir_all(&base_dir).await?; - let body_path = if response_id.is_empty() { - base_dir.join(uuid::Uuid::new_v4().to_string()) + // Ephemeral responses: use OS temp directory for automatic cleanup + let temp_dir = std::env::temp_dir().join("yaak-ephemeral-responses"); + create_dir_all(&temp_dir).await?; + temp_dir.join(uuid::Uuid::new_v4().to_string()) } else { + // Persisted responses: use app data directory + let dir = app_handle.path().app_data_dir()?; + let base_dir = dir.join("responses"); + create_dir_all(&base_dir).await?; base_dir.join(&response_id) }; // Extract metadata before consuming the body (headers are available immediately) // Url might change, so update again - let headers: Vec = http_response - .headers - .iter() - .map(|(name, value)| HttpResponseHeader { name: name.clone(), value: value.clone() }) - .collect(); - - { - // Update response with headers info and mark as connected - let mut r = response.lock().await; + response_ctx.update(|r| { r.body_path = Some(body_path.to_string_lossy().to_string()); r.elapsed_headers = start.elapsed().as_millis() as i32; r.status = http_response.status as i32; - r.status_reason = http_response.status_reason.clone().clone(); - r.url = http_response.url.clone().clone(); + r.status_reason = http_response.status_reason.clone(); + r.url = http_response.url.clone(); r.remote_addr = http_response.remote_addr.clone(); - r.version = http_response.version.clone().clone(); - r.headers = headers.clone(); - r.content_length = http_response.content_length.map(|l| l as i32); + r.version = http_response.version.clone(); + r.headers = http_response + .headers + .iter() + .map(|(name, value)| HttpResponseHeader { name: name.clone(), value: value.clone() }) + .collect(); + r.content_length = http_response.content_length.map(|l| l as i64); + r.state = HttpResponseState::Connected; r.request_headers = http_response .request_headers .iter() .map(|(n, v)| HttpResponseHeader { name: n.clone(), value: v.clone() }) .collect(); - r.state = HttpResponseState::Connected; - app_handle.db().update_http_response_if_id(&r, &update_source)?; - } + })?; // Get the body stream for manual consumption let mut body_stream = http_response.into_body_stream()?; @@ -371,10 +487,14 @@ async fn execute_transaction( .await .map_err(|e| GenericError(format!("Failed to open file: {}", e)))?; - // Stream body to file, updating DB on each chunk + // Stream body to file, with throttled DB updates to avoid excessive writes let mut written_bytes: usize = 0; + let mut last_update_time = start; let mut buf = [0u8; 8192]; + // Throttle settings: update DB at most every 100ms + const UPDATE_INTERVAL_MS: u128 = 100; + loop { // Check for cancellation. If we already have headers/body, just close cleanly without error if *cancelled_rx.borrow() { @@ -401,11 +521,17 @@ async fn execute_transaction( .map_err(|e| GenericError(format!("Failed to flush file: {}", e)))?; written_bytes += n; - // Update response in DB with progress - let mut r = response.lock().await; - r.elapsed = start.elapsed().as_millis() as i32; // Approx until the end - r.content_length = Some(written_bytes as i32); - app_handle.db().update_http_response_if_id(&r, &update_source)?; + // Throttle DB updates: only update if enough time has passed + let now = Instant::now(); + let elapsed_since_update = now.duration_since(last_update_time).as_millis(); + + if elapsed_since_update >= UPDATE_INTERVAL_MS { + response_ctx.update(|r| { + r.elapsed = start.elapsed().as_millis() as i32; + r.content_length = Some(written_bytes as i64); + })?; + last_update_time = now; + } } Err(e) => { return Err(GenericError(format!("Failed to read response body: {}", e))); @@ -413,17 +539,108 @@ async fn execute_transaction( } } - // Final update with closed state - let mut resp = response.lock().await.clone(); - resp.elapsed = start.elapsed().as_millis() as i32; - resp.state = HttpResponseState::Closed; - resp.body_path = Some( - body_path.to_str().ok_or(GenericError(format!("Invalid path {body_path:?}",)))?.to_string(), - ); + // Final update with closed state and accurate byte count + response_ctx.update(|r| { + r.elapsed = start.elapsed().as_millis() as i32; + r.content_length = Some(written_bytes as i64); + r.state = HttpResponseState::Closed; + })?; - app_handle.db().update_http_response_if_id(&resp, &update_source)?; + Ok((response_ctx.response().clone(), maybe_blob_write_handle)) +} - Ok(resp) +fn write_bytes_to_db_sync( + response_ctx: &mut ResponseContext, + body_id: &str, + data: Bytes, +) -> Result<()> { + if data.is_empty() { + return Ok(()); + } + + // Write in chunks if data is large + let mut offset = 0; + let mut chunk_index = 0; + while offset < data.len() { + let end = std::cmp::min(offset + REQUEST_BODY_CHUNK_SIZE, data.len()); + let chunk_data = data.slice(offset..end).to_vec(); + let chunk = BodyChunk::new(body_id, chunk_index, chunk_data); + response_ctx.app_handle.blobs().insert_chunk(&chunk)?; + offset = end; + chunk_index += 1; + } + + // Update the response with the total request body size + response_ctx.update(|r| { + r.request_content_length = Some(data.len() as i64); + })?; + + Ok(()) +} + +async fn write_stream_chunks_to_db( + app_handle: AppHandle, + body_id: &str, + workspace_id: &str, + response_id: &str, + update_source: &UpdateSource, + mut rx: tokio::sync::mpsc::Receiver>, +) -> Result<()> { + let mut buffer = Vec::with_capacity(REQUEST_BODY_CHUNK_SIZE); + let mut chunk_index = 0; + let mut total_bytes: usize = 0; + + while let Some(data) = rx.recv().await { + total_bytes += data.len(); + buffer.extend_from_slice(&data); + + // Flush when buffer reaches chunk size + while buffer.len() >= REQUEST_BODY_CHUNK_SIZE { + debug!("Writing chunk {chunk_index} to DB"); + let chunk_data: Vec = buffer.drain(..REQUEST_BODY_CHUNK_SIZE).collect(); + let chunk = BodyChunk::new(body_id, chunk_index, chunk_data); + app_handle.blobs().insert_chunk(&chunk)?; + app_handle.db().upsert_http_response_event( + &HttpResponseEvent::new( + response_id, + workspace_id, + yaak_http::sender::HttpResponseEvent::ChunkSent { + bytes: REQUEST_BODY_CHUNK_SIZE, + } + .into(), + ), + update_source, + )?; + chunk_index += 1; + } + } + + // Flush remaining data + if !buffer.is_empty() { + let chunk = BodyChunk::new(body_id, chunk_index, buffer); + debug!("Flushing remaining data {chunk_index} {}", chunk.data.len()); + app_handle.blobs().insert_chunk(&chunk)?; + app_handle.db().upsert_http_response_event( + &HttpResponseEvent::new( + response_id, + workspace_id, + yaak_http::sender::HttpResponseEvent::ChunkSent { bytes: chunk.data.len() }.into(), + ), + update_source, + )?; + } + + // Update the response with the total request body size + app_handle.with_tx(|tx| { + debug!("Updating final body length {total_bytes}"); + if let Ok(mut response) = tx.get_http_response(&response_id) { + response.request_content_length = Some(total_bytes as i64); + tx.update_http_response_if_id(&response, update_source)?; + } + Ok(()) + })?; + + Ok(()) } async fn apply_authentication( diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index b1f77b4c..2aacc65d 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -32,6 +32,7 @@ use yaak_common::window::WorkspaceWindowTrait; use yaak_grpc::manager::GrpcHandle; use yaak_grpc::{Code, ServiceDefinition, serialize_message}; use yaak_mac_window::AppHandleMacWindowExt; +use yaak_models::blob_manager::BlobManagerExt; use yaak_models::models::{ AnyModel, CookieJar, Environment, GrpcConnection, GrpcConnectionState, GrpcEvent, GrpcEventType, GrpcRequest, HttpRequest, HttpResponse, HttpResponseEvent, HttpResponseState, @@ -784,7 +785,7 @@ async fn cmd_http_response_body( ) -> YaakResult { let body_path = match response.body_path { None => { - return Err(GenericError("Response body path not set".to_string())); + return Ok(FilterResponse { content: String::new(), error: None }); } Some(p) => p, }; @@ -809,6 +810,23 @@ async fn cmd_http_response_body( } } +#[tauri::command] +async fn cmd_http_request_body( + app_handle: AppHandle, + response_id: &str, +) -> YaakResult>> { + let body_id = format!("{}.request", response_id); + let chunks = app_handle.blobs().get_chunks(&body_id)?; + + if chunks.is_empty() { + return Ok(None); + } + + // Concatenate all chunks + let body: Vec = chunks.into_iter().flat_map(|c| c.data).collect(); + Ok(Some(body)) +} + #[tauri::command] async fn cmd_get_sse_events(file_path: &str) -> YaakResult> { let body = fs::read(file_path)?; @@ -835,9 +853,7 @@ 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)?; + let events: Vec = app_handle.db().list_http_response_events(response_id)?; Ok(events) } @@ -1115,6 +1131,7 @@ async fn cmd_send_http_request( // that has not yet been saved in the DB. request: HttpRequest, ) -> YaakResult { + let blobs = app_handle.blob_manager(); let response = app_handle.db().upsert_http_response( &HttpResponse { request_id: request.id.clone(), @@ -1122,6 +1139,7 @@ async fn cmd_send_http_request( ..Default::default() }, &UpdateSource::from_window(&window), + &blobs, )?; let (cancel_tx, mut cancel_rx) = tokio::sync::watch::channel(false); @@ -1167,6 +1185,7 @@ async fn cmd_send_http_request( ..resp }, &UpdateSource::from_window(&window), + &blobs, )? } }; @@ -1174,23 +1193,6 @@ async fn cmd_send_http_request( Ok(r) } -fn response_err( - app_handle: &AppHandle, - response: &HttpResponse, - error: String, - update_source: &UpdateSource, -) -> HttpResponse { - warn!("Failed to send request: {error:?}"); - let mut response = response.clone(); - response.state = HttpResponseState::Closed; - response.error = Some(error.clone()); - response = app_handle - .db() - .update_http_response_if_id(&response, update_source) - .expect("Failed to update response"); - response -} - #[tauri::command] async fn cmd_install_plugin( directory: &str, @@ -1468,6 +1470,7 @@ pub fn run() { cmd_delete_send_history, cmd_dismiss_notification, cmd_export_data, + cmd_http_request_body, cmd_http_response_body, cmd_format_json, cmd_get_http_authentication_summaries, diff --git a/src-tauri/src/plugin_events.rs b/src-tauri/src/plugin_events.rs index b89c187b..4c1d95d1 100644 --- a/src-tauri/src/plugin_events.rs +++ b/src-tauri/src/plugin_events.rs @@ -12,6 +12,7 @@ use log::error; use tauri::{AppHandle, Emitter, Manager, Runtime}; use tauri_plugin_clipboard_manager::ClipboardExt; use yaak_common::window::WorkspaceWindowTrait; +use yaak_models::blob_manager::BlobManagerExt; use yaak_models::models::{HttpResponse, Plugin}; use yaak_models::queries::any_request::AnyRequest; use yaak_models::query_manager::QueryManagerExt; @@ -194,6 +195,7 @@ pub(crate) async fn handle_plugin_event( let http_response = if http_request.id.is_empty() { HttpResponse::default() } else { + let blobs = window.blob_manager(); window.db().upsert_http_response( &HttpResponse { request_id: http_request.id.clone(), @@ -201,6 +203,7 @@ pub(crate) async fn handle_plugin_event( ..Default::default() }, &UpdateSource::Plugin, + &blobs, )? }; diff --git a/src-tauri/yaak-http/src/lib.rs b/src-tauri/yaak-http/src/lib.rs index 387b7419..8c25fe7d 100644 --- a/src-tauri/yaak-http/src/lib.rs +++ b/src-tauri/yaak-http/src/lib.rs @@ -11,6 +11,7 @@ pub mod manager; pub mod path_placeholders; mod proto; pub mod sender; +pub mod tee_reader; pub mod transaction; pub mod types; diff --git a/src-tauri/yaak-http/src/sender.rs b/src-tauri/yaak-http/src/sender.rs index d6cb88ea..1d56cff4 100644 --- a/src-tauri/yaak-http/src/sender.rs +++ b/src-tauri/yaak-http/src/sender.rs @@ -110,12 +110,12 @@ pub struct BodyStats { /// An AsyncRead wrapper that sends chunk events as data is read pub struct TrackingRead { inner: R, - event_tx: mpsc::UnboundedSender, + event_tx: mpsc::Sender, ended: bool, } impl TrackingRead { - pub fn new(inner: R, event_tx: mpsc::UnboundedSender) -> Self { + pub fn new(inner: R, event_tx: mpsc::Sender) -> Self { Self { inner, event_tx, ended: false } } } @@ -131,8 +131,9 @@ impl AsyncRead for TrackingRead { 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 }); + // Ignore send errors - receiver may have been dropped or channel is full + let _ = + self.event_tx.try_send(HttpResponseEvent::ChunkReceived { bytes: bytes_read }); } else if !self.ended { self.ended = true; } @@ -311,7 +312,7 @@ pub trait HttpSender: Send + Sync { async fn send( &self, request: SendableHttpRequest, - event_tx: mpsc::UnboundedSender, + event_tx: mpsc::Sender, ) -> Result; } @@ -338,11 +339,11 @@ impl HttpSender for ReqwestSender { async fn send( &self, request: SendableHttpRequest, - event_tx: mpsc::UnboundedSender, + event_tx: mpsc::Sender, ) -> Result { - // Helper to send events (ignores errors if receiver is dropped) + // Helper to send events (ignores errors if receiver is dropped or channel is full) let send_event = |event: HttpResponseEvent| { - let _ = event_tx.send(event); + let _ = event_tx.try_send(event); }; // Parse the HTTP method diff --git a/src-tauri/yaak-http/src/tee_reader.rs b/src-tauri/yaak-http/src/tee_reader.rs new file mode 100644 index 00000000..b70372a2 --- /dev/null +++ b/src-tauri/yaak-http/src/tee_reader.rs @@ -0,0 +1,171 @@ +use std::io; +use std::pin::Pin; +use std::task::{Context, Poll}; +use tokio::io::{AsyncRead, ReadBuf}; +use tokio::sync::mpsc; + +/// A reader that forwards all read data to a channel while also returning it to the caller. +/// This allows capturing request body data as it's being sent. +/// Uses a bounded channel to provide backpressure if the receiver is slow. +pub struct TeeReader { + inner: R, + tx: mpsc::Sender>, +} + +impl TeeReader { + pub fn new(inner: R, tx: mpsc::Sender>) -> Self { + Self { inner, tx } + } +} + +impl AsyncRead for TeeReader { + fn poll_read( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &mut ReadBuf<'_>, + ) -> Poll> { + let before_len = buf.filled().len(); + + match Pin::new(&mut self.inner).poll_read(cx, buf) { + Poll::Ready(Ok(())) => { + let after_len = buf.filled().len(); + if after_len > before_len { + // Data was read, send a copy to the channel + let data = buf.filled()[before_len..after_len].to_vec(); + // Use try_send to avoid blocking. If channel is full, we drop the data + // rather than blocking the HTTP request. This provides backpressure + // by slowing down the reader when the DB writer can't keep up. + match self.tx.try_send(data) { + Ok(_) => {} // Successfully sent + Err(mpsc::error::TrySendError::Full(_)) => { + // Channel is full - apply backpressure by returning Pending + // This will cause the reader to be polled again later + cx.waker().wake_by_ref(); + return Poll::Pending; + } + Err(mpsc::error::TrySendError::Closed(_)) => { + // Receiver dropped - continue without capturing + } + } + } + Poll::Ready(Ok(())) + } + Poll::Ready(Err(e)) => Poll::Ready(Err(e)), + Poll::Pending => Poll::Pending, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Cursor; + use tokio::io::AsyncReadExt; + + #[tokio::test] + async fn test_tee_reader_captures_all_data() { + let data = b"Hello, World!"; + let cursor = Cursor::new(data.to_vec()); + let (tx, mut rx) = mpsc::channel(10); + + let mut tee = TeeReader::new(cursor, tx); + let mut output = Vec::new(); + tee.read_to_end(&mut output).await.unwrap(); + + // Verify the reader returns the correct data + assert_eq!(output, data); + + // Verify the channel received the data + let mut captured = Vec::new(); + while let Ok(chunk) = rx.try_recv() { + captured.extend(chunk); + } + assert_eq!(captured, data); + } + + #[tokio::test] + async fn test_tee_reader_with_chunked_reads() { + let data = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ"; + let cursor = Cursor::new(data.to_vec()); + let (tx, mut rx) = mpsc::channel(10); + + let mut tee = TeeReader::new(cursor, tx); + + // Read in small chunks + let mut buf = [0u8; 5]; + let mut output = Vec::new(); + loop { + let n = tee.read(&mut buf).await.unwrap(); + if n == 0 { + break; + } + output.extend_from_slice(&buf[..n]); + } + + // Verify the reader returns the correct data + assert_eq!(output, data); + + // Verify the channel received all chunks + let mut captured = Vec::new(); + while let Ok(chunk) = rx.try_recv() { + captured.extend(chunk); + } + assert_eq!(captured, data); + } + + #[tokio::test] + async fn test_tee_reader_empty_data() { + let data: Vec = vec![]; + let cursor = Cursor::new(data.clone()); + let (tx, mut rx) = mpsc::channel(10); + + let mut tee = TeeReader::new(cursor, tx); + let mut output = Vec::new(); + tee.read_to_end(&mut output).await.unwrap(); + + // Verify empty output + assert!(output.is_empty()); + + // Verify no data was sent to channel + assert!(rx.try_recv().is_err()); + } + + #[tokio::test] + async fn test_tee_reader_works_when_receiver_dropped() { + let data = b"Hello, World!"; + let cursor = Cursor::new(data.to_vec()); + let (tx, rx) = mpsc::channel(10); + + // Drop the receiver before reading + drop(rx); + + let mut tee = TeeReader::new(cursor, tx); + let mut output = Vec::new(); + + // Should still work even though receiver is dropped + tee.read_to_end(&mut output).await.unwrap(); + assert_eq!(output, data); + } + + #[tokio::test] + async fn test_tee_reader_large_data() { + // Test with 1MB of data + let data: Vec = (0..1024 * 1024).map(|i| (i % 256) as u8).collect(); + let cursor = Cursor::new(data.clone()); + let (tx, mut rx) = mpsc::channel(100); + + let mut tee = TeeReader::new(cursor, tx); + let mut output = Vec::new(); + tee.read_to_end(&mut output).await.unwrap(); + + // Verify the reader returns the correct data + assert_eq!(output, data); + + // Verify the channel received all data + let mut captured = Vec::new(); + while let Ok(chunk) = rx.try_recv() { + captured.extend(chunk); + } + assert_eq!(captured, data); + } +} diff --git a/src-tauri/yaak-http/src/transaction.rs b/src-tauri/yaak-http/src/transaction.rs index d779e9f5..43a59de0 100644 --- a/src-tauri/yaak-http/src/transaction.rs +++ b/src-tauri/yaak-http/src/transaction.rs @@ -28,7 +28,7 @@ impl HttpTransaction { &self, request: SendableHttpRequest, mut cancelled_rx: Receiver, - event_tx: mpsc::UnboundedSender, + event_tx: mpsc::Sender, ) -> Result { let mut redirect_count = 0; let mut current_url = request.url; @@ -36,9 +36,9 @@ impl HttpTransaction { let mut current_headers = request.headers; let mut current_body = request.body; - // Helper to send events (ignores errors if receiver is dropped) + // Helper to send events (ignores errors if receiver is dropped or channel is full) let send_event = |event: HttpResponseEvent| { - let _ = event_tx.send(event); + let _ = event_tx.try_send(event); }; loop { @@ -236,7 +236,7 @@ mod tests { async fn send( &self, _request: SendableHttpRequest, - _event_tx: mpsc::UnboundedSender, + _event_tx: mpsc::Sender, ) -> Result { let mut responses = self.responses.lock().await; if responses.is_empty() { @@ -276,7 +276,7 @@ mod tests { }; let (_tx, rx) = tokio::sync::watch::channel(false); - let (event_tx, _event_rx) = mpsc::unbounded_channel(); + let (event_tx, _event_rx) = mpsc::channel(100); let result = transaction.execute_with_cancellation(request, rx, event_tx).await.unwrap(); assert_eq!(result.status, 200); @@ -309,7 +309,7 @@ mod tests { }; let (_tx, rx) = tokio::sync::watch::channel(false); - let (event_tx, _event_rx) = mpsc::unbounded_channel(); + let (event_tx, _event_rx) = mpsc::channel(100); let result = transaction.execute_with_cancellation(request, rx, event_tx).await.unwrap(); assert_eq!(result.status, 200); @@ -341,7 +341,7 @@ mod tests { }; let (_tx, rx) = tokio::sync::watch::channel(false); - let (event_tx, _event_rx) = mpsc::unbounded_channel(); + let (event_tx, _event_rx) = mpsc::channel(100); 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")); diff --git a/src-tauri/yaak-models/bindings/gen_models.ts b/src-tauri/yaak-models/bindings/gen_models.ts index f3742077..844c0deb 100644 --- a/src-tauri/yaak-models/bindings/gen_models.ts +++ b/src-tauri/yaak-models/bindings/gen_models.ts @@ -38,7 +38,7 @@ export type HttpRequest = { model: "http_request", id: string, createdAt: string export type HttpRequestHeader = { enabled?: boolean, name: string, value: string, id?: 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 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, requestContentLength: number | 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, }; @@ -47,7 +47,7 @@ export type HttpResponseEvent = { model: "http_response_event", id: string, crea * 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 HttpResponseEventData = { "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, }; diff --git a/src-tauri/yaak-models/blob_migrations/00000000000000_init.sql b/src-tauri/yaak-models/blob_migrations/00000000000000_init.sql new file mode 100644 index 00000000..7589cb4e --- /dev/null +++ b/src-tauri/yaak-models/blob_migrations/00000000000000_init.sql @@ -0,0 +1,12 @@ +CREATE TABLE body_chunks +( + id TEXT PRIMARY KEY, + body_id TEXT NOT NULL, + chunk_index INTEGER NOT NULL, + data BLOB NOT NULL, + created_at DATETIME DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')) NOT NULL, + + UNIQUE (body_id, chunk_index) +); + +CREATE INDEX idx_body_chunks_body_id ON body_chunks (body_id, chunk_index); diff --git a/src-tauri/yaak-models/migrations/20251221100000_request-content-length.sql b/src-tauri/yaak-models/migrations/20251221100000_request-content-length.sql new file mode 100644 index 00000000..16625cf5 --- /dev/null +++ b/src-tauri/yaak-models/migrations/20251221100000_request-content-length.sql @@ -0,0 +1,2 @@ +ALTER TABLE http_responses + ADD COLUMN request_content_length INTEGER; diff --git a/src-tauri/yaak-models/src/blob_manager.rs b/src-tauri/yaak-models/src/blob_manager.rs new file mode 100644 index 00000000..cb817db9 --- /dev/null +++ b/src-tauri/yaak-models/src/blob_manager.rs @@ -0,0 +1,372 @@ +use crate::error::Result; +use crate::util::generate_prefixed_id; +use include_dir::{Dir, include_dir}; +use log::{debug, info}; +use r2d2::Pool; +use r2d2_sqlite::SqliteConnectionManager; +use rusqlite::{OptionalExtension, params}; +use std::sync::{Arc, Mutex}; +use tauri::{Manager, Runtime, State}; + +static BLOB_MIGRATIONS_DIR: Dir = include_dir!("$CARGO_MANIFEST_DIR/blob_migrations"); + +/// A chunk of body data stored in the blob database. +#[derive(Debug, Clone)] +pub struct BodyChunk { + pub id: String, + pub body_id: String, + pub chunk_index: i32, + pub data: Vec, +} + +impl BodyChunk { + pub fn new(body_id: impl Into, chunk_index: i32, data: Vec) -> Self { + Self { id: generate_prefixed_id("bc"), body_id: body_id.into(), chunk_index, data } + } +} + +/// Extension trait for accessing the blob manager from app handle. +pub trait BlobManagerExt<'a, R> { + fn blob_manager(&'a self) -> State<'a, BlobManager>; + fn blobs(&'a self) -> BlobContext; +} + +impl<'a, R: Runtime, M: Manager> BlobManagerExt<'a, R> for M { + fn blob_manager(&'a self) -> State<'a, BlobManager> { + self.state::() + } + + fn blobs(&'a self) -> BlobContext { + let manager = self.state::(); + manager.inner().connect() + } +} + +/// Manages the blob database connection pool. +#[derive(Debug, Clone)] +pub struct BlobManager { + pool: Arc>>, +} + +impl BlobManager { + pub fn new(pool: Pool) -> Self { + Self { pool: Arc::new(Mutex::new(pool)) } + } + + pub fn connect(&self) -> BlobContext { + let conn = self + .pool + .lock() + .expect("Failed to gain lock on blob DB") + .get() + .expect("Failed to get blob DB connection from pool"); + BlobContext { conn } + } +} + +/// Context for blob database operations. +pub struct BlobContext { + conn: r2d2::PooledConnection, +} + +impl BlobContext { + /// Insert a single chunk. + pub fn insert_chunk(&self, chunk: &BodyChunk) -> Result<()> { + self.conn.execute( + "INSERT INTO body_chunks (id, body_id, chunk_index, data) VALUES (?1, ?2, ?3, ?4)", + params![chunk.id, chunk.body_id, chunk.chunk_index, chunk.data], + )?; + Ok(()) + } + + /// Get all chunks for a body, ordered by chunk_index. + pub fn get_chunks(&self, body_id: &str) -> Result> { + let mut stmt = self.conn.prepare( + "SELECT id, body_id, chunk_index, data FROM body_chunks + WHERE body_id = ?1 ORDER BY chunk_index ASC", + )?; + + let chunks = stmt + .query_map(params![body_id], |row| { + Ok(BodyChunk { + id: row.get(0)?, + body_id: row.get(1)?, + chunk_index: row.get(2)?, + data: row.get(3)?, + }) + })? + .collect::, _>>()?; + + Ok(chunks) + } + + /// Delete all chunks for a body. + pub fn delete_chunks(&self, body_id: &str) -> Result<()> { + self.conn.execute("DELETE FROM body_chunks WHERE body_id = ?1", params![body_id])?; + Ok(()) + } + + /// Delete all chunks matching a body_id prefix (e.g., "rs_abc123.%" to delete all bodies for a response). + pub fn delete_chunks_like(&self, body_id_prefix: &str) -> Result<()> { + self.conn + .execute("DELETE FROM body_chunks WHERE body_id LIKE ?1", params![body_id_prefix])?; + Ok(()) + } +} + +/// Get total size of a body without loading data. +impl BlobContext { + pub fn get_body_size(&self, body_id: &str) -> Result { + let size: i64 = self + .conn + .query_row( + "SELECT COALESCE(SUM(LENGTH(data)), 0) FROM body_chunks WHERE body_id = ?1", + params![body_id], + |row| row.get(0), + ) + .unwrap_or(0); + Ok(size as usize) + } + + /// Check if a body exists. + pub fn body_exists(&self, body_id: &str) -> Result { + let count: i64 = self + .conn + .query_row( + "SELECT COUNT(*) FROM body_chunks WHERE body_id = ?1", + params![body_id], + |row| row.get(0), + ) + .unwrap_or(0); + Ok(count > 0) + } +} + +/// Run migrations for the blob database. +pub fn migrate_blob_db(pool: &Pool) -> Result<()> { + info!("Running blob database migrations"); + + // Create migrations tracking table + pool.get()?.execute( + "CREATE TABLE IF NOT EXISTS _blob_migrations ( + version TEXT PRIMARY KEY, + description TEXT NOT NULL, + applied_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL + )", + [], + )?; + + // Read and sort all .sql files + let mut entries: Vec<_> = BLOB_MIGRATIONS_DIR + .entries() + .iter() + .filter(|e| e.path().extension().map(|ext| ext == "sql").unwrap_or(false)) + .collect(); + + entries.sort_by_key(|e| e.path()); + + let mut ran_migrations = 0; + for entry in &entries { + let filename = entry.path().file_name().unwrap().to_str().unwrap(); + let version = filename.split('_').next().unwrap(); + + // Check if already applied + let already_applied: Option = pool + .get()? + .query_row("SELECT 1 FROM _blob_migrations WHERE version = ?", [version], |r| r.get(0)) + .optional()?; + + if already_applied.is_some() { + debug!("Skipping already applied blob migration: {}", filename); + continue; + } + + let sql = + entry.as_file().unwrap().contents_utf8().expect("Failed to read blob migration file"); + + info!("Applying blob migration: {}", filename); + let conn = pool.get()?; + conn.execute_batch(sql)?; + + // Record migration + conn.execute( + "INSERT INTO _blob_migrations (version, description) VALUES (?, ?)", + params![version, filename], + )?; + + ran_migrations += 1; + } + + if ran_migrations == 0 { + info!("No blob migrations to run"); + } else { + info!("Ran {} blob migration(s)", ran_migrations); + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn create_test_pool() -> Pool { + let manager = SqliteConnectionManager::memory(); + let pool = Pool::builder().max_size(1).build(manager).unwrap(); + migrate_blob_db(&pool).unwrap(); + pool + } + + #[test] + fn test_insert_and_get_chunks() { + let pool = create_test_pool(); + let manager = BlobManager::new(pool); + let ctx = manager.connect(); + + let body_id = "rs_test123.request"; + let chunk1 = BodyChunk::new(body_id, 0, b"Hello, ".to_vec()); + let chunk2 = BodyChunk::new(body_id, 1, b"World!".to_vec()); + + ctx.insert_chunk(&chunk1).unwrap(); + ctx.insert_chunk(&chunk2).unwrap(); + + let chunks = ctx.get_chunks(body_id).unwrap(); + assert_eq!(chunks.len(), 2); + assert_eq!(chunks[0].chunk_index, 0); + assert_eq!(chunks[0].data, b"Hello, "); + assert_eq!(chunks[1].chunk_index, 1); + assert_eq!(chunks[1].data, b"World!"); + } + + #[test] + fn test_get_chunks_ordered_by_index() { + let pool = create_test_pool(); + let manager = BlobManager::new(pool); + let ctx = manager.connect(); + + let body_id = "rs_test123.request"; + + // Insert out of order + ctx.insert_chunk(&BodyChunk::new(body_id, 2, b"C".to_vec())).unwrap(); + ctx.insert_chunk(&BodyChunk::new(body_id, 0, b"A".to_vec())).unwrap(); + ctx.insert_chunk(&BodyChunk::new(body_id, 1, b"B".to_vec())).unwrap(); + + let chunks = ctx.get_chunks(body_id).unwrap(); + assert_eq!(chunks.len(), 3); + assert_eq!(chunks[0].data, b"A"); + assert_eq!(chunks[1].data, b"B"); + assert_eq!(chunks[2].data, b"C"); + } + + #[test] + fn test_delete_chunks() { + let pool = create_test_pool(); + let manager = BlobManager::new(pool); + let ctx = manager.connect(); + + let body_id = "rs_test123.request"; + ctx.insert_chunk(&BodyChunk::new(body_id, 0, b"data".to_vec())).unwrap(); + + assert!(ctx.body_exists(body_id).unwrap()); + + ctx.delete_chunks(body_id).unwrap(); + + assert!(!ctx.body_exists(body_id).unwrap()); + assert_eq!(ctx.get_chunks(body_id).unwrap().len(), 0); + } + + #[test] + fn test_delete_chunks_like() { + let pool = create_test_pool(); + let manager = BlobManager::new(pool); + let ctx = manager.connect(); + + // Insert chunks for same response but different body types + ctx.insert_chunk(&BodyChunk::new("rs_abc.request", 0, b"req".to_vec())).unwrap(); + ctx.insert_chunk(&BodyChunk::new("rs_abc.response", 0, b"resp".to_vec())).unwrap(); + ctx.insert_chunk(&BodyChunk::new("rs_other.request", 0, b"other".to_vec())).unwrap(); + + // Delete all bodies for rs_abc + ctx.delete_chunks_like("rs_abc.%").unwrap(); + + // rs_abc bodies should be gone + assert!(!ctx.body_exists("rs_abc.request").unwrap()); + assert!(!ctx.body_exists("rs_abc.response").unwrap()); + + // rs_other should still exist + assert!(ctx.body_exists("rs_other.request").unwrap()); + } + + #[test] + fn test_get_body_size() { + let pool = create_test_pool(); + let manager = BlobManager::new(pool); + let ctx = manager.connect(); + + let body_id = "rs_test123.request"; + ctx.insert_chunk(&BodyChunk::new(body_id, 0, b"Hello".to_vec())).unwrap(); + ctx.insert_chunk(&BodyChunk::new(body_id, 1, b"World".to_vec())).unwrap(); + + let size = ctx.get_body_size(body_id).unwrap(); + assert_eq!(size, 10); // "Hello" + "World" = 10 bytes + } + + #[test] + fn test_get_body_size_empty() { + let pool = create_test_pool(); + let manager = BlobManager::new(pool); + let ctx = manager.connect(); + + let size = ctx.get_body_size("nonexistent").unwrap(); + assert_eq!(size, 0); + } + + #[test] + fn test_body_exists() { + let pool = create_test_pool(); + let manager = BlobManager::new(pool); + let ctx = manager.connect(); + + assert!(!ctx.body_exists("rs_test.request").unwrap()); + + ctx.insert_chunk(&BodyChunk::new("rs_test.request", 0, b"data".to_vec())).unwrap(); + + assert!(ctx.body_exists("rs_test.request").unwrap()); + } + + #[test] + fn test_multiple_bodies_isolated() { + let pool = create_test_pool(); + let manager = BlobManager::new(pool); + let ctx = manager.connect(); + + ctx.insert_chunk(&BodyChunk::new("body1", 0, b"data1".to_vec())).unwrap(); + ctx.insert_chunk(&BodyChunk::new("body2", 0, b"data2".to_vec())).unwrap(); + + let chunks1 = ctx.get_chunks("body1").unwrap(); + let chunks2 = ctx.get_chunks("body2").unwrap(); + + assert_eq!(chunks1.len(), 1); + assert_eq!(chunks1[0].data, b"data1"); + assert_eq!(chunks2.len(), 1); + assert_eq!(chunks2[0].data, b"data2"); + } + + #[test] + fn test_large_chunk() { + let pool = create_test_pool(); + let manager = BlobManager::new(pool); + let ctx = manager.connect(); + + // 1MB chunk + let large_data: Vec = (0..1024 * 1024).map(|i| (i % 256) as u8).collect(); + let body_id = "rs_large.request"; + + ctx.insert_chunk(&BodyChunk::new(body_id, 0, large_data.clone())).unwrap(); + + let chunks = ctx.get_chunks(body_id).unwrap(); + assert_eq!(chunks.len(), 1); + assert_eq!(chunks[0].data, large_data); + assert_eq!(ctx.get_body_size(body_id).unwrap(), 1024 * 1024); + } +} diff --git a/src-tauri/yaak-models/src/commands.rs b/src-tauri/yaak-models/src/commands.rs index b16fb2e6..f7bab2e1 100644 --- a/src-tauri/yaak-models/src/commands.rs +++ b/src-tauri/yaak-models/src/commands.rs @@ -1,3 +1,4 @@ +use crate::blob_manager::BlobManagerExt; use crate::error::Error::GenericError; use crate::error::Result; use crate::models::{AnyModel, GraphQlIntrospection, GrpcEvent, Settings, WebsocketEvent}; @@ -8,6 +9,7 @@ use tauri::{AppHandle, Runtime, WebviewWindow}; #[tauri::command] pub(crate) fn upsert(window: WebviewWindow, model: AnyModel) -> Result { let db = window.db(); + let blobs = window.blob_manager(); let source = &UpdateSource::from_window(&window); let id = match model { AnyModel::CookieJar(m) => db.upsert_cookie_jar(&m, source)?.id, @@ -15,7 +17,7 @@ pub(crate) fn upsert(window: WebviewWindow, model: AnyModel) -> R AnyModel::Folder(m) => db.upsert_folder(&m, source)?.id, AnyModel::GrpcRequest(m) => db.upsert_grpc_request(&m, source)?.id, AnyModel::HttpRequest(m) => db.upsert_http_request(&m, source)?.id, - AnyModel::HttpResponse(m) => db.upsert_http_response(&m, source)?.id, + AnyModel::HttpResponse(m) => db.upsert_http_response(&m, source, &blobs)?.id, AnyModel::KeyValue(m) => db.upsert_key_value(&m, source)?.id, AnyModel::Plugin(m) => db.upsert_plugin(&m, source)?.id, AnyModel::Settings(m) => db.upsert_settings(&m, source)?.id, @@ -30,6 +32,7 @@ pub(crate) fn upsert(window: WebviewWindow, model: AnyModel) -> R #[tauri::command] pub(crate) fn delete(window: WebviewWindow, model: AnyModel) -> Result { + let blobs = window.blob_manager(); // Use transaction for deletions because it might recurse window.with_tx(|tx| { let source = &UpdateSource::from_window(&window); @@ -40,7 +43,7 @@ pub(crate) fn delete(window: WebviewWindow, model: AnyModel) -> R AnyModel::GrpcConnection(m) => tx.delete_grpc_connection(&m, source)?.id, AnyModel::GrpcRequest(m) => tx.delete_grpc_request(&m, source)?.id, AnyModel::HttpRequest(m) => tx.delete_http_request(&m, source)?.id, - AnyModel::HttpResponse(m) => tx.delete_http_response(&m, source)?.id, + AnyModel::HttpResponse(m) => tx.delete_http_response(&m, source, &blobs)?.id, AnyModel::Plugin(m) => tx.delete_plugin(&m, source)?.id, AnyModel::WebsocketConnection(m) => tx.delete_websocket_connection(&m, source)?.id, AnyModel::WebsocketRequest(m) => tx.delete_websocket_request(&m, source)?.id, diff --git a/src-tauri/yaak-models/src/db_context.rs b/src-tauri/yaak-models/src/db_context.rs index f20795ea..31479ccf 100644 --- a/src-tauri/yaak-models/src/db_context.rs +++ b/src-tauri/yaak-models/src/db_context.rs @@ -67,7 +67,7 @@ impl<'a> DbContext<'a> { .expect("Failed to run find on DB") } - pub fn find_all<'s, M>(&self) -> Result> + pub(crate) fn find_all<'s, M>(&self) -> Result> where M: Into + Clone + UpsertModelInfo, { @@ -82,7 +82,7 @@ impl<'a> DbContext<'a> { Ok(items.map(|v| v.unwrap()).collect()) } - pub fn find_many<'s, M>( + pub(crate) fn find_many<'s, M>( &self, col: impl IntoColumnRef, value: impl Into, @@ -115,7 +115,7 @@ impl<'a> DbContext<'a> { Ok(items.map(|v| v.unwrap()).collect()) } - pub fn upsert(&self, model: &M, source: &UpdateSource) -> Result + pub(crate) fn upsert(&self, model: &M, source: &UpdateSource) -> Result where M: Into + From + UpsertModelInfo + Clone, { diff --git a/src-tauri/yaak-models/src/lib.rs b/src-tauri/yaak-models/src/lib.rs index b41fb4d5..7af9be14 100644 --- a/src-tauri/yaak-models/src/lib.rs +++ b/src-tauri/yaak-models/src/lib.rs @@ -1,3 +1,4 @@ +use crate::blob_manager::{BlobManager, migrate_blob_db}; use crate::commands::*; use crate::migrate::migrate_db; use crate::query_manager::QueryManager; @@ -14,6 +15,7 @@ use tauri_plugin_dialog::{DialogExt, MessageDialogKind}; mod commands; +pub mod blob_manager; mod connection_or_tx; pub mod db_context; pub mod error; @@ -50,7 +52,9 @@ pub fn init() -> TauriPlugin { create_dir_all(app_path.clone()).expect("Problem creating App directory!"); let db_file_path = app_path.join("db.sqlite"); + let blob_db_file_path = app_path.join("blobs.sqlite"); + // Main database pool let manager = SqliteConnectionManager::file(db_file_path); let pool = Pool::builder() .max_size(100) // Up from 10 (just in case) @@ -68,7 +72,26 @@ pub fn init() -> TauriPlugin { return Err(Box::from(e.to_string())); }; + // Blob database pool + let blob_manager = SqliteConnectionManager::file(blob_db_file_path); + let blob_pool = Pool::builder() + .max_size(50) + .connection_timeout(Duration::from_secs(10)) + .build(blob_manager) + .unwrap(); + + if let Err(e) = migrate_blob_db(&blob_pool) { + error!("Failed to run blob database migration {e:?}"); + app_handle + .dialog() + .message(e.to_string()) + .kind(MessageDialogKind::Error) + .blocking_show(); + return Err(Box::from(e.to_string())); + }; + app_handle.manage(SqliteConnection::new(pool.clone())); + app_handle.manage(BlobManager::new(blob_pool)); { let (tx, rx) = mpsc::channel(); diff --git a/src-tauri/yaak-models/src/models.rs b/src-tauri/yaak-models/src/models.rs index 012a40fd..6ec3ef67 100644 --- a/src-tauri/yaak-models/src/models.rs +++ b/src-tauri/yaak-models/src/models.rs @@ -1322,13 +1322,14 @@ pub struct HttpResponse { pub request_id: String, pub body_path: Option, - pub content_length: Option, - pub content_length_compressed: Option, + pub content_length: Option, + pub content_length_compressed: Option, pub elapsed: i32, pub elapsed_headers: i32, pub error: Option, pub headers: Vec, pub remote_addr: Option, + pub request_content_length: Option, pub request_headers: Vec, pub status: i32, pub status_reason: Option, @@ -1382,6 +1383,7 @@ impl UpsertModelInfo for HttpResponse { (StatusReason, self.status_reason.into()), (Url, self.url.into()), (Version, self.version.into()), + (RequestContentLength, self.request_content_length.into()), ]) } @@ -1396,6 +1398,7 @@ impl UpsertModelInfo for HttpResponse { HttpResponseIden::Error, HttpResponseIden::Headers, HttpResponseIden::RemoteAddr, + HttpResponseIden::RequestContentLength, HttpResponseIden::RequestHeaders, HttpResponseIden::State, HttpResponseIden::Status, @@ -1431,6 +1434,7 @@ impl UpsertModelInfo for HttpResponse { state: serde_json::from_str(format!(r#""{state}""#).as_str()).unwrap(), body_path: r.get("body_path")?, headers: serde_json::from_str(headers.as_str()).unwrap_or_default(), + request_content_length: r.get("request_content_length").unwrap_or_default(), request_headers: serde_json::from_str( r.get::<_, String>("request_headers").unwrap_or_default().as_str(), ) diff --git a/src-tauri/yaak-models/src/queries/http_response_events.rs b/src-tauri/yaak-models/src/queries/http_response_events.rs new file mode 100644 index 00000000..145ea444 --- /dev/null +++ b/src-tauri/yaak-models/src/queries/http_response_events.rs @@ -0,0 +1,18 @@ +use crate::db_context::DbContext; +use crate::error::Result; +use crate::models::{HttpResponseEvent, HttpResponseEventIden}; +use crate::util::UpdateSource; + +impl<'a> DbContext<'a> { + pub fn list_http_response_events(&self, response_id: &str) -> Result> { + self.find_many(HttpResponseEventIden::ResponseId, response_id, None) + } + + pub fn upsert_http_response_event( + &self, + http_response_event: &HttpResponseEvent, + source: &UpdateSource, + ) -> Result { + self.upsert(http_response_event, source) + } +} diff --git a/src-tauri/yaak-models/src/queries/http_responses.rs b/src-tauri/yaak-models/src/queries/http_responses.rs index 4647446d..a555b276 100644 --- a/src-tauri/yaak-models/src/queries/http_responses.rs +++ b/src-tauri/yaak-models/src/queries/http_responses.rs @@ -1,3 +1,4 @@ +use crate::blob_manager::BlobManager; use crate::db_context::DbContext; use crate::error::Result; use crate::models::{HttpResponse, HttpResponseIden, HttpResponseState}; @@ -58,6 +59,7 @@ impl<'a> DbContext<'a> { &self, http_response: &HttpResponse, source: &UpdateSource, + blob_manager: &BlobManager, ) -> Result { // Delete the body file if it exists if let Some(p) = http_response.body_path.clone() { @@ -66,6 +68,13 @@ impl<'a> DbContext<'a> { }; } + // Delete request body blobs (pattern: {response_id}.request) + let blob_ctx = blob_manager.connect(); + let body_id = format!("{}.request", http_response.id); + if let Err(e) = blob_ctx.delete_chunks(&body_id) { + error!("Failed to delete request body blobs: {}", e); + } + Ok(self.delete(http_response, source)?) } @@ -73,12 +82,13 @@ impl<'a> DbContext<'a> { &self, http_response: &HttpResponse, source: &UpdateSource, + blob_manager: &BlobManager, ) -> Result { let responses = self.list_http_responses_for_request(&http_response.request_id, None)?; for m in responses.iter().skip(MAX_HISTORY_ITEMS - 1) { debug!("Deleting old HTTP response {}", http_response.id); - self.delete_http_response(&m, source)?; + self.delete_http_response(&m, source, blob_manager)?; } self.upsert(http_response, source) diff --git a/src-tauri/yaak-models/src/queries/mod.rs b/src-tauri/yaak-models/src/queries/mod.rs index 983ac621..fd1553d9 100644 --- a/src-tauri/yaak-models/src/queries/mod.rs +++ b/src-tauri/yaak-models/src/queries/mod.rs @@ -8,6 +8,7 @@ mod grpc_connections; mod grpc_events; mod grpc_requests; mod http_requests; +mod http_response_events; mod http_responses; mod key_values; mod plugin_key_values; diff --git a/src-tauri/yaak-plugins/bindings/gen_models.ts b/src-tauri/yaak-plugins/bindings/gen_models.ts index ebe00460..454903fe 100644 --- a/src-tauri/yaak-plugins/bindings/gen_models.ts +++ b/src-tauri/yaak-plugins/bindings/gen_models.ts @@ -12,7 +12,7 @@ export type HttpRequest = { model: "http_request", id: string, createdAt: string export type HttpRequestHeader = { enabled?: boolean, name: string, value: string, id?: 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 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, requestContentLength: number | null, requestHeaders: Array, status: number, statusReason: string | null, state: HttpResponseState, url: string, version: string | null, }; export type HttpResponseHeader = { name: string, value: string, }; diff --git a/src-tauri/yaak-templates/pkg/yaak_templates.d.ts b/src-tauri/yaak-templates/pkg/yaak_templates.d.ts index aed6c395..5d24deef 100644 --- a/src-tauri/yaak-templates/pkg/yaak_templates.d.ts +++ b/src-tauri/yaak-templates/pkg/yaak_templates.d.ts @@ -1,5 +1,5 @@ /* tslint:disable */ /* eslint-disable */ export function unescape_template(template: string): any; -export function parse_template(template: string): any; export function escape_template(template: string): any; +export function parse_template(template: string): any; diff --git a/src-tauri/yaak-templates/pkg/yaak_templates.js b/src-tauri/yaak-templates/pkg/yaak_templates.js index 7ca3c562..8d2a7738 100644 --- a/src-tauri/yaak-templates/pkg/yaak_templates.js +++ b/src-tauri/yaak-templates/pkg/yaak_templates.js @@ -1,4 +1,5 @@ import * as wasm from "./yaak_templates_bg.wasm"; export * from "./yaak_templates_bg.js"; import { __wbg_set_wasm } from "./yaak_templates_bg.js"; -__wbg_set_wasm(wasm); \ No newline at end of file +__wbg_set_wasm(wasm); +wasm.__wbindgen_start(); diff --git a/src-tauri/yaak-templates/pkg/yaak_templates_bg.js b/src-tauri/yaak-templates/pkg/yaak_templates_bg.js index 98b3c8f5..4d11efa6 100644 --- a/src-tauri/yaak-templates/pkg/yaak_templates_bg.js +++ b/src-tauri/yaak-templates/pkg/yaak_templates_bg.js @@ -4,35 +4,6 @@ export function __wbg_set_wasm(val) { } -const heap = new Array(128).fill(undefined); - -heap.push(undefined, null, true, false); - -let heap_next = heap.length; - -function addHeapObject(obj) { - if (heap_next === heap.length) heap.push(heap.length + 1); - const idx = heap_next; - heap_next = heap[idx]; - - heap[idx] = obj; - return idx; -} - -function getObject(idx) { return heap[idx]; } - -function dropObject(idx) { - if (idx < 132) return; - heap[idx] = heap_next; - heap_next = idx; -} - -function takeObject(idx) { - const ret = getObject(idx); - dropObject(idx); - return ret; -} - function debugString(val) { // primitive types const type = typeof val; @@ -184,48 +155,24 @@ function getStringFromWasm0(ptr, len) { ptr = ptr >>> 0; return cachedTextDecoder.decode(getUint8ArrayMemory0().subarray(ptr, ptr + len)); } + +function takeFromExternrefTable0(idx) { + const value = wasm.__wbindgen_export_2.get(idx); + wasm.__externref_table_dealloc(idx); + return value; +} /** * @param {string} template * @returns {any} */ export function unescape_template(template) { - try { - const retptr = wasm.__wbindgen_add_to_stack_pointer(-16); - const ptr0 = passStringToWasm0(template, wasm.__wbindgen_export_0, wasm.__wbindgen_export_1); - const len0 = WASM_VECTOR_LEN; - wasm.unescape_template(retptr, ptr0, len0); - var r0 = getDataViewMemory0().getInt32(retptr + 4 * 0, true); - var r1 = getDataViewMemory0().getInt32(retptr + 4 * 1, true); - var r2 = getDataViewMemory0().getInt32(retptr + 4 * 2, true); - if (r2) { - throw takeObject(r1); - } - return takeObject(r0); - } finally { - wasm.__wbindgen_add_to_stack_pointer(16); - } -} - -/** - * @param {string} template - * @returns {any} - */ -export function parse_template(template) { - try { - const retptr = wasm.__wbindgen_add_to_stack_pointer(-16); - const ptr0 = passStringToWasm0(template, wasm.__wbindgen_export_0, wasm.__wbindgen_export_1); - const len0 = WASM_VECTOR_LEN; - wasm.parse_template(retptr, ptr0, len0); - var r0 = getDataViewMemory0().getInt32(retptr + 4 * 0, true); - var r1 = getDataViewMemory0().getInt32(retptr + 4 * 1, true); - var r2 = getDataViewMemory0().getInt32(retptr + 4 * 2, true); - if (r2) { - throw takeObject(r1); - } - return takeObject(r0); - } finally { - wasm.__wbindgen_add_to_stack_pointer(16); + const ptr0 = passStringToWasm0(template, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); + const len0 = WASM_VECTOR_LEN; + const ret = wasm.unescape_template(ptr0, len0); + if (ret[2]) { + throw takeFromExternrefTable0(ret[1]); } + return takeFromExternrefTable0(ret[0]); } /** @@ -233,61 +180,69 @@ export function parse_template(template) { * @returns {any} */ export function escape_template(template) { - try { - const retptr = wasm.__wbindgen_add_to_stack_pointer(-16); - const ptr0 = passStringToWasm0(template, wasm.__wbindgen_export_0, wasm.__wbindgen_export_1); - const len0 = WASM_VECTOR_LEN; - wasm.escape_template(retptr, ptr0, len0); - var r0 = getDataViewMemory0().getInt32(retptr + 4 * 0, true); - var r1 = getDataViewMemory0().getInt32(retptr + 4 * 1, true); - var r2 = getDataViewMemory0().getInt32(retptr + 4 * 2, true); - if (r2) { - throw takeObject(r1); - } - return takeObject(r0); - } finally { - wasm.__wbindgen_add_to_stack_pointer(16); + const ptr0 = passStringToWasm0(template, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); + const len0 = WASM_VECTOR_LEN; + const ret = wasm.escape_template(ptr0, len0); + if (ret[2]) { + throw takeFromExternrefTable0(ret[1]); } + return takeFromExternrefTable0(ret[0]); +} + +/** + * @param {string} template + * @returns {any} + */ +export function parse_template(template) { + const ptr0 = passStringToWasm0(template, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); + const len0 = WASM_VECTOR_LEN; + const ret = wasm.parse_template(ptr0, len0); + if (ret[2]) { + throw takeFromExternrefTable0(ret[1]); + } + return takeFromExternrefTable0(ret[0]); } export function __wbg_new_405e22f390576ce2() { const ret = new Object(); - return addHeapObject(ret); + return ret; }; export function __wbg_new_78feb108b6472713() { const ret = new Array(); - return addHeapObject(ret); + return ret; }; export function __wbg_set_37837023f3d740e8(arg0, arg1, arg2) { - getObject(arg0)[arg1 >>> 0] = takeObject(arg2); + arg0[arg1 >>> 0] = arg2; }; export function __wbg_set_3f1d0b984ed272ed(arg0, arg1, arg2) { - getObject(arg0)[takeObject(arg1)] = takeObject(arg2); + arg0[arg1] = arg2; }; export function __wbindgen_debug_string(arg0, arg1) { - const ret = debugString(getObject(arg1)); - const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_export_0, wasm.__wbindgen_export_1); + const ret = debugString(arg1); + const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); const len1 = WASM_VECTOR_LEN; getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true); getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true); }; -export function __wbindgen_object_clone_ref(arg0) { - const ret = getObject(arg0); - return addHeapObject(ret); -}; - -export function __wbindgen_object_drop_ref(arg0) { - takeObject(arg0); +export function __wbindgen_init_externref_table() { + const table = wasm.__wbindgen_export_2; + const offset = table.grow(4); + table.set(0, undefined); + table.set(offset + 0, undefined); + table.set(offset + 1, null); + table.set(offset + 2, true); + table.set(offset + 3, false); + ; }; export function __wbindgen_string_new(arg0, arg1) { const ret = getStringFromWasm0(arg0, arg1); - return addHeapObject(ret); + return ret; }; export function __wbindgen_throw(arg0, arg1) { diff --git a/src-tauri/yaak-templates/pkg/yaak_templates_bg.wasm b/src-tauri/yaak-templates/pkg/yaak_templates_bg.wasm index dceb1699..dfa7764d 100644 Binary files a/src-tauri/yaak-templates/pkg/yaak_templates_bg.wasm and b/src-tauri/yaak-templates/pkg/yaak_templates_bg.wasm differ diff --git a/src-tauri/yaak-templates/pkg/yaak_templates_bg.wasm.d.ts b/src-tauri/yaak-templates/pkg/yaak_templates_bg.wasm.d.ts index 55be67ff..d8bbabb6 100644 --- a/src-tauri/yaak-templates/pkg/yaak_templates_bg.wasm.d.ts +++ b/src-tauri/yaak-templates/pkg/yaak_templates_bg.wasm.d.ts @@ -1,9 +1,11 @@ /* tslint:disable */ /* eslint-disable */ export const memory: WebAssembly.Memory; -export const escape_template: (a: number, b: number, c: number) => void; -export const parse_template: (a: number, b: number, c: number) => void; -export const unescape_template: (a: number, b: number, c: number) => void; -export const __wbindgen_export_0: (a: number, b: number) => number; -export const __wbindgen_export_1: (a: number, b: number, c: number, d: number) => number; -export const __wbindgen_add_to_stack_pointer: (a: number) => number; +export const escape_template: (a: number, b: number) => [number, number, number]; +export const parse_template: (a: number, b: number) => [number, number, number]; +export const unescape_template: (a: number, b: number) => [number, number, number]; +export const __wbindgen_malloc: (a: number, b: number) => number; +export const __wbindgen_realloc: (a: number, b: number, c: number, d: number) => number; +export const __wbindgen_export_2: WebAssembly.Table; +export const __externref_table_dealloc: (a: number) => void; +export const __wbindgen_start: () => void; diff --git a/src-web/components/ConfirmLargeResponseRequest.tsx b/src-web/components/ConfirmLargeResponseRequest.tsx new file mode 100644 index 00000000..80e9fb30 --- /dev/null +++ b/src-web/components/ConfirmLargeResponseRequest.tsx @@ -0,0 +1,58 @@ +import type { HttpResponse } from '@yaakapp-internal/models'; +import { type ReactNode, useMemo } from 'react'; +import { getRequestBodyText as getHttpResponseRequestBodyText } from '../hooks/useHttpRequestBody'; +import { useToggle } from '../hooks/useToggle'; +import { isProbablyTextContentType } from '../lib/contentType'; +import { getContentTypeFromHeaders } from '../lib/model_util'; +import { CopyButton } from './CopyButton'; +import { Banner } from './core/Banner'; +import { Button } from './core/Button'; +import { InlineCode } from './core/InlineCode'; +import { SizeTag } from './core/SizeTag'; +import { HStack } from './core/Stacks'; + +interface Props { + children: ReactNode; + response: HttpResponse; +} + +const LARGE_BYTES = 2 * 1000 * 1000; + +export function ConfirmLargeResponseRequest({ children, response }: Props) { + const [showLargeResponse, toggleShowLargeResponse] = useToggle(); + const isProbablyText = useMemo(() => { + const contentType = getContentTypeFromHeaders(response.headers); + return isProbablyTextContentType(contentType); + }, [response.headers]); + + const contentLength = response.requestContentLength ?? 0; + const isLarge = contentLength > LARGE_BYTES; + if (!showLargeResponse && isLarge) { + return ( + +

+ Showing content over{' '} + + + {' '} + may impact performance +

+ + + {isProbablyText && ( + getHttpResponseRequestBodyText(response).then((d) => d?.bodyText ?? '')} + /> + )} + +
+ ); + } + + return <>{children}; +} diff --git a/src-web/components/HttpResponsePane.tsx b/src-web/components/HttpResponsePane.tsx index a4d0d26e..f0dc6d03 100644 --- a/src-web/components/HttpResponsePane.tsx +++ b/src-web/components/HttpResponsePane.tsx @@ -10,6 +10,7 @@ import { useResponseViewMode } from '../hooks/useResponseViewMode'; import { getMimeTypeFromContentType } from '../lib/contentType'; import { getContentTypeFromHeaders } from '../lib/model_util'; import { ConfirmLargeResponse } from './ConfirmLargeResponse'; +import { ConfirmLargeResponseRequest } from './ConfirmLargeResponseRequest'; import { Banner } from './core/Banner'; import { Button } from './core/Button'; import { CountBadge } from './core/CountBadge'; @@ -23,8 +24,9 @@ import type { TabItem } from './core/Tabs/Tabs'; import { TabContent, Tabs } from './core/Tabs/Tabs'; import { EmptyStateText } from './EmptyStateText'; import { ErrorBoundary } from './ErrorBoundary'; +import { HttpResponseTimeline } from './HttpResponseTimeline'; import { RecentHttpResponsesDropdown } from './RecentHttpResponsesDropdown'; -import { ResponseTimeline } from './ResponseEvents'; +import { RequestBodyViewer } from './RequestBodyViewer'; import { ResponseHeaders } from './ResponseHeaders'; import { ResponseInfo } from './ResponseInfo'; import { AudioViewer } from './responseViewers/AudioViewer'; @@ -46,9 +48,10 @@ interface Props { } const TAB_BODY = 'body'; +const TAB_REQUEST = 'request'; const TAB_HEADERS = 'headers'; const TAB_INFO = 'info'; -const TAB_TIMELINE = 'events'; +const TAB_TIMELINE = 'timeline'; export function HttpResponsePane({ style, className, activeRequestId }: Props) { const { activeResponse, setPinnedResponseId, responses } = usePinnedHttpResponse(activeRequestId); @@ -76,6 +79,12 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) { ], }, }, + { + value: TAB_REQUEST, + label: 'Request', + rightSlot: + (activeResponse?.requestContentLength ?? 0) > 0 ? : null, + }, { value: TAB_HEADERS, label: 'Headers', @@ -98,11 +107,12 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) { ], [ activeResponse?.headers, + activeResponse?.requestContentLength, + activeResponse?.requestHeaders.length, mimeType, + responseEvents.data?.length, setViewMode, viewMode, - activeResponse?.requestHeaders.length, - responseEvents.data?.length, ], ); const activeTab = activeTabs?.[activeRequestId]; @@ -200,8 +210,8 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) { ) : activeResponse.state === 'closed' && - activeResponse.contentLength === 0 ? ( - Empty + (activeResponse.contentLength ?? 0) === 0 ? ( + Empty ) : mimeType?.match(/^text\/event-stream/i) && viewMode === 'pretty' ? ( ) : mimeType?.match(/^image\/svg/) ? ( @@ -227,6 +237,11 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) { + + + + + @@ -234,7 +249,7 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) { - + diff --git a/src-web/components/ResponseEvents.tsx b/src-web/components/HttpResponseTimeline.tsx similarity index 93% rename from src-web/components/ResponseEvents.tsx rename to src-web/components/HttpResponseTimeline.tsx index 3a40404b..fe5fbde3 100644 --- a/src-web/components/ResponseEvents.tsx +++ b/src-web/components/HttpResponseTimeline.tsx @@ -5,7 +5,7 @@ import type { } from '@yaakapp-internal/models'; import classNames from 'classnames'; import { format } from 'date-fns'; -import { Fragment, type ReactNode, useMemo, useState } from 'react'; +import { type ReactNode, useMemo, useState } from 'react'; import { useHttpResponseEvents } from '../hooks/useHttpResponseEvents'; import { AutoScroller } from './core/AutoScroller'; import { Banner } from './core/Banner'; @@ -20,12 +20,8 @@ interface Props { response: HttpResponse; } -export function ResponseTimeline({ response }: Props) { - return ( - - - - ); +export function HttpResponseTimeline({ response }: Props) { + return ; } function Inner({ response }: Props) { @@ -252,20 +248,6 @@ type EventDisplay = { 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', @@ -321,14 +303,14 @@ function getEventDisplay(event: HttpResponseEventData): EventDisplay { icon: 'info', color: 'secondary', label: 'Chunk', - summary: `${event.bytes} bytes sent`, + summary: `${formatBytes(event.bytes)} chunk sent`, }; case 'chunk_received': return { icon: 'info', color: 'secondary', label: 'Chunk', - summary: `${event.bytes} bytes received`, + summary: `${formatBytes(event.bytes)} chunk received`, }; default: return { diff --git a/src-web/components/RequestBodyViewer.tsx b/src-web/components/RequestBodyViewer.tsx new file mode 100644 index 00000000..215c5d25 --- /dev/null +++ b/src-web/components/RequestBodyViewer.tsx @@ -0,0 +1,52 @@ +import type { HttpResponse } from '@yaakapp-internal/models'; +import { useHttpRequestBody } from '../hooks/useHttpRequestBody'; +import { languageFromContentType } from '../lib/contentType'; +import { EmptyStateText } from './EmptyStateText'; +import { Editor } from './core/Editor/LazyEditor'; +import { LoadingIcon } from './core/LoadingIcon'; + +interface Props { + response: HttpResponse; +} + +export function RequestBodyViewer({ response }: Props) { + return ; +} + +function RequestBodyViewerInner({ response }: Props) { + const { data, isLoading, error } = useHttpRequestBody(response); + + if (isLoading) { + return ( + + + + ); + } + + if (error) { + return Error loading request body: {error.message}; + } + + if (data?.bodyText == null || data.bodyText.length === 0) { + return No request body; + } + + const { bodyText } = data; + + // Try to detect language from content-type header that was sent + const contentTypeHeader = response.requestHeaders.find( + (h) => h.name.toLowerCase() === 'content-type', + ); + const contentType = contentTypeHeader?.value ?? null; + const language = languageFromContentType(contentType, bodyText); + + return ( + + ); +} diff --git a/src-web/hooks/useHttpRequestBody.ts b/src-web/hooks/useHttpRequestBody.ts new file mode 100644 index 00000000..31745ceb --- /dev/null +++ b/src-web/hooks/useHttpRequestBody.ts @@ -0,0 +1,32 @@ +import { useQuery } from '@tanstack/react-query'; +import type { HttpResponse } from '@yaakapp-internal/models'; +import { invokeCmd } from '../lib/tauri'; + +export function useHttpRequestBody(response: HttpResponse | null) { + return useQuery({ + placeholderData: (prev) => prev, // Keep previous data on refetch + queryKey: ['request_body', response?.id, response?.state, response?.requestContentLength], + enabled: (response?.requestContentLength ?? 0) > 0, + queryFn: async () => { + return getRequestBodyText(response); + }, + }); +} + +export async function getRequestBodyText(response: HttpResponse | null) { + if (response?.id == null) { + return null; + } + + const data = await invokeCmd('cmd_http_request_body', { + responseId: response.id, + }); + + if (data == null) { + return null; + } + + const body = new Uint8Array(data); + const bodyText = new TextDecoder('utf-8', { fatal: false }).decode(body); + return { body, bodyText }; +} diff --git a/src-web/lib/tauri.ts b/src-web/lib/tauri.ts index 41d6a166..0c856176 100644 --- a/src-web/lib/tauri.ts +++ b/src-web/lib/tauri.ts @@ -25,6 +25,7 @@ type TauriCmd = | 'cmd_grpc_reflect' | 'cmd_grpc_request_actions' | 'cmd_http_request_actions' + | 'cmd_http_request_body' | 'cmd_http_response_body' | 'cmd_import_data' | 'cmd_install_plugin'