From 26a3e88715944a7c15e8e003a05b2bbd42918c05 Mon Sep 17 00:00:00 2001 From: Gregory Schier Date: Sun, 28 Dec 2025 08:07:42 -0800 Subject: [PATCH] Store and show request body in UI (#327) --- .gitattributes | 2 + .github/workflows/claude-code-review.yml | 57 --- .../src/bindings/gen_models.ts | 2 +- src-tauri/src/http_request.rs | 419 +++++++++++++----- src-tauri/src/lib.rs | 45 +- src-tauri/src/plugin_events.rs | 3 + src-tauri/yaak-http/src/lib.rs | 1 + src-tauri/yaak-http/src/sender.rs | 17 +- src-tauri/yaak-http/src/tee_reader.rs | 171 +++++++ src-tauri/yaak-http/src/transaction.rs | 14 +- src-tauri/yaak-models/bindings/gen_models.ts | 4 +- .../blob_migrations/00000000000000_init.sql | 12 + .../20251221100000_request-content-length.sql | 2 + src-tauri/yaak-models/src/blob_manager.rs | 372 ++++++++++++++++ src-tauri/yaak-models/src/commands.rs | 7 +- src-tauri/yaak-models/src/db_context.rs | 6 +- src-tauri/yaak-models/src/lib.rs | 23 + src-tauri/yaak-models/src/models.rs | 8 +- .../src/queries/http_response_events.rs | 18 + .../yaak-models/src/queries/http_responses.rs | 12 +- src-tauri/yaak-models/src/queries/mod.rs | 1 + src-tauri/yaak-plugins/bindings/gen_models.ts | 2 +- .../yaak-templates/pkg/yaak_templates.d.ts | 2 +- .../yaak-templates/pkg/yaak_templates.js | 3 +- .../yaak-templates/pkg/yaak_templates_bg.js | 141 ++---- .../yaak-templates/pkg/yaak_templates_bg.wasm | Bin 54673 -> 68448 bytes .../pkg/yaak_templates_bg.wasm.d.ts | 14 +- .../ConfirmLargeResponseRequest.tsx | 58 +++ src-web/components/HttpResponsePane.tsx | 29 +- ...nseEvents.tsx => HttpResponseTimeline.tsx} | 28 +- src-web/components/RequestBodyViewer.tsx | 52 +++ src-web/hooks/useHttpRequestBody.ts | 32 ++ src-web/lib/tauri.ts | 1 + 33 files changed, 1221 insertions(+), 337 deletions(-) delete mode 100644 .github/workflows/claude-code-review.yml create mode 100644 src-tauri/yaak-http/src/tee_reader.rs create mode 100644 src-tauri/yaak-models/blob_migrations/00000000000000_init.sql create mode 100644 src-tauri/yaak-models/migrations/20251221100000_request-content-length.sql create mode 100644 src-tauri/yaak-models/src/blob_manager.rs create mode 100644 src-tauri/yaak-models/src/queries/http_response_events.rs create mode 100644 src-web/components/ConfirmLargeResponseRequest.tsx rename src-web/components/{ResponseEvents.tsx => HttpResponseTimeline.tsx} (93%) create mode 100644 src-web/components/RequestBodyViewer.tsx create mode 100644 src-web/hooks/useHttpRequestBody.ts 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 dceb16999aeedecf064a42a94887d72b1c76f1b0..dfa7764dff420bc633b4f857ec6680244403f30d 100644 GIT binary patch delta 19196 zcmch93vgW3ndUwH(xWA-g)KjY-I5>37&-U-Xba;u*ydqlV>>41)xJ-4%dKwF-Lfq( zT!)a16A}`0!R8SgOmKLEi69UrA>N(I?q-rL$R@R$+Dc}b+U!(zE1R9!ovod%rE2y& z_jXI&Ey*^?%s|oi+;h+Q&wu{+|DPjHyc+)aWJF{1@+e^#hWt6{J-{#r7~Zar~+k;jrnq}4l5mU|BAA-qzL@jmBd57>P&QVx6&Ath2K- zI**C9McUiK;m&BZD~c)Mw$4}_|FlKhBACjwzRaw z!ySpv&YsSB3+B%Y$K$c~a6A%=h5;vv^}^wBEZ*K0BP15&a+-XR+&Q@K72=VDe?r_%M;>_a{`)2#B1aEDOr9f; zlUvt~eudn<{`RxPexAHZj*%OM^n2v%t3Y{cz&fD9ynr!gy>sl5$M>>f2OwYR5af-m0_ZM9nzC;o$+p#DRT`-ck z!{ZW5$T{zp#0Y-Bn;2W5^>=rI(0$P)Nx$>x1vC@dWUBk$gs{W#g7p~1FSv&!y%P%t z$ztz^3vNTZyQdHRg8%#3o&mDcd$4B*8S{SHvl#vVrDroJH?Ytp8SkZqSx~ln(I>5B z@8rDXziY$TzgqiK;!M7A^@lM$9^_smq|;j?>|E_I@#MT@XEL0A>1p6kUptZ=>F`K$ ze)`F3&uiY(!f&MSlVm*Ins}A;cXpCwG@Td$L0gmY#OLug5L(a|qK`yhq@y*C&x`Rn zp?lvEZzuX>yY#n|*`LU(!bJD_QjcPv=Tl3_M(;0D`&NMbpf*Xr4+^FuiQ4N2^fjE& zyz4gnC25^pK6C?V*E&O>X>tVY%U}DmIw)(s&uww)ED>yX?WX@48e_cYHuhkR ztJBwz72fUXquBZ{((i`J&dINC`5o!_J^>3NfJYLqdPn#0WRdsgo+XnXZ95$%k9a5c zX+ZgZ|+>ZB%h2XzD+={R%|dy212d$rz3IF`|wb2)c@+u+qDpL zR_~&7KD%r6{;4^XDD`syXCTxIF~DPSn%vtNPR7zFU?)I^U{adtBOUE=AWGN#B{Sm7 zJ|zsWe%AZ>E_VA#AIf&nA5d-tlr5bxu;yWMQ)e4^u)m{?3UkQsfDkA4gFSsAA7n>M z(MF9bQr?i;AF()-E2ELrJ^xBs5>O|JZ0gpfXOVE+g=%*4~roxx7lr$53Y zLEk?P-oNo|^}XS{_hG3r-BZjBOJfYoUI)A(#IK|A^H1%;_L=vw~ zhO);9d1UenxuG!DzL5V3SrTJ9!ySwdvo%Qpgpw`6k}niC0Gp2sOWN9*J{XbK`2Y4o z#j8kU^6FwMX^qFRRygt9$<$aEAuo70-P;%c9s#$$@f6r}*W~!UzaymGGs>_N-izf| zRy&d>`6>H$Dog8ixX9@9|zd)OA@3 z|2K!0U|#!SIxls2Rri#9L*YT8M8Yc`URE^@Uq4L8zI=FXZ@nUhn^orbhpz^(B}e+f z;GrXntJ1#z$o_!9Q79(}?0s-#J*?n=Ke83S;>0!h)hF&)yaTo(IRYN^(yii|q)#}* zpHF-QeR$!%8v-b?fKA@t-B-j+>;CkWbY|7JE~c)v9S$!tl0NQ%#5=rydH<~#*L`$8 zBEQu~-vwuXeRNCJkVc`Y)R3-!@E+3ZeeFRW3tf0{8QC`Zs|OEetPnb4-iADsUA`0!JJs+Vbnr70BOq(J63ptByqn-v$$T^zW{4tjrvSZ|y zW&N?fwvUR*wokf9e@oik+8Opo5)5mB1W~0)Qk}p~lPz*+vA^MTBnk0PyNC~NP6FZ9G{Ks*)zKG* zfo@60`l57Ms!YIlXD28WPPPCXHOyp024h?B>;pd7+Du)41$4b_q<@zB7c zKE;v|nUbnTIyzKpJmc@`U$49vcXKiFk{>>%A?HttMA`pZ6A%7rlocW1$2wnEVg%zTf+; z*IPe0OO)!sr@iKZzb3bM|LXCT5Xk@b_-P2|@h6;~?~~A`bO+TT(hnx5Bk7LBTVD9& z0pM?)>|fc=z#@Vl$p|%0)Dn^oaG)jq)Z_&w9ZI&rM!tOVR@g`y4I%JJpA0_GSSYY( z-KW+A*rroUT-dW14T^l~#p35eBuvGe^~IdZ8E^sTfBOC=V1mYPAia=zD!phXeV@V5 zkcxDNAzO&O`UxJZM%IQHh4v(wwV_@m42eP@LAAsOMnwwkq#N>C3V;v^EP^g(ZMe0n zEJ?C99Ox8fFbf8E(FWEYzymXS@i_m?1$01ksg5Plv3Ml?9{Lhvh!~kPf`tG|fD46< zK}oDjCo2F#rGD{9VjPb~$iJKg^2Y&WZ}`*%*!5SZ?(FXEV|*P<|MuhsCVh3{l(*x_ zLDQJmNL>EGs1X5&reUHST9rmT z2|fpU6Q%wgpu%7mlqLeI!bSpvU(ig!@s>aHWwPCS=@}}QKYb_US8|1e(5^2?3Z?230oFu z2;5BieQNW7Dg-sKc^|%Xq&nl?m+6eRUe4lo=__aZ7Is}C&h~!w%672-*sI&)FPy#r zHbavW^nITT38)c_#P0;gzx9`A>5rU8qbUyAv?=a&Q4hVwFdO6MOVOTFk@1AHZ*)UG~C zBj41J_|**E1qA2>-34vqL4}I;z3OG&QpmgBskhdlvKL<$NOIy}e+Y^Z@`v0&S&A2G zfbi2=g*@xn>p#WVtxaRy8sI@7=C24lfkq%r&;`oW6cYtf;S>|0xPfQ!0bc|tb9{q9 znKZ>NzlC+9<{Z@F*^_JMQ@4_& zwD7ftitbz9*~$K0bwu+?*2ctp8J}^`O+OI>hgzPmuUhz)un?eh8&L*wi0NUzrRo>yCQM-pPgb$Gze^JqSX-`Ob<} zi$kdH7O7+Z>lrg(An)zhh|VM^+_1?RJ;8iYR8gf$j9Z1t~ec#_yCH8S%E&y3dBb! z0E2ML`0i?VNjyQRzKRB)t0_((pbr`}`)pTyo>oA zt=@mJfPeKsq@a)5%!2t7x_)r#Xra9RFg*qO>kKv^(`Kf6_>xL_9O=QvkF=CsQEk`P;RN2OlcYrK)G250Uh2=PdRTa)u9XpgBDQC(P zCFpQawA?TJOskWkWEN{AY>&8YN zrBeQRP$`w#MbCxs49G^S+0gB8fDi%_yl71Lcvs*vBmmc;F z?Y)K~Te7j~Lw2TQ(~$sSst_oN7fj8PRnb;D)XO3mX0keKi4bF6(QH$&_0C<*m=C+n zyA3eUl*b(>cLWsmH!}5_g^DR=sNNEgrNBP0D47oaveZ%pOvL8OR9aFqOK#=yMIyGSyG{pr|sOuqdjmF4~T| zV(U!Vf|;G7pqn!DMQZl1rw>4?zGihBsxQg9C_5}`i&9dkN~5nAFeA_l2#o&(KJXb> z-7@}r1AoHA8u zDikw82ZUq0e&58NO0lF5!;fGa^ft+q6_XT!w=~IOMczP|pspO6LJR9pdCwafe5MVr znHvV7voGHyvV;O5Id#K{lLX9&@w00AuLthG=V|sUS{ScWa%ED2&6~z?;_eukOOfy_qIB~Evy$zC4u_zA zaQn5uM=w~JEPnFTS?IPVNeZWev>=JC58hnU=UPO$$B!(h6gt4A(InDP0h%=jFYo{{;RC2nXV#hs;cv<#_HTZ$Eq5uiMqscEJ8WeQe20>3@HKf z7ZVcJ(pWfN$5B*X6%Ab|4EZ*5OotUY=rh)DR>cZL4B{FP$HmuO`b55{Thk)s52g+m z)2kmr!HRy+&KN}m#aU5PB-4hHTD-`b2xvFgsYhMR6y!dY@v*!eke$P$kn_WOg*6a5 z!T*^ENo<&UX>+k)!Wh_jL<-}D@a734$WahGBAou1H7{FLO6$5DUP{6)MG{##Hp?GQ#?Cn}&o4UyC4`B_ZI3rx0b` zbRDGL+KgptVb88=3JPa$W~-rg+zCn(Pj{$b>U~0G2fcl{F!Gy37h1FALXQV(Rov zOVb2F;91SI43kxzP1iMwz?68vZvt1R8s_|4bPF0swZO6w?7nyOhSQ_&bx(`8jr+3UNS^6eXD z-lE-d?M(i;Fmcmn@&5a(6nfwRV2X576m)|%9E+0`TiV)mQ+M0?KuAH*@jSLRUN~IR z$1;wd%hS{>E6bK48@#3?|A5AcHySgE>QWm!jlh&$o{g$Co7G^lG)d_->n2+f~OkU+NgGdCiV$A_+hU%dHp^2O=sD@$7x7{4fuI7EX7jMJa`fo6Qc=1Kc zj}@yPx`dKZZj{D}s44K0MYCO#Q(48@eVMRa1fDa1!F+NGFN+2$ZyG0xdoB~$Y_K#7 zq*Xc5aClzU1#Rz*)5%w=JmJC??U_I=W0b1BsW<;V~5&}{5x)}$-kd7AYt&G#k4srj)7>@DW+04 z;1?7MUXc6?Dl!E_U{&6fBEfFOWkt@q`yyz#bqaGiXxvkE=RuTMT|Q5X zOcRJrq11<|>77jwO^%h3X=@xW$u=v?nb@d3YPzFi2L@+UC7Z=4uo?H+rJ`-=>Bb5+ zf@yrt8M%C}f)fr?AJa{=GDXzXoFF&1SrqCG)MHTDkaSTqrU~NC#hARiDD&FSd?GE~);4A!!s5+)pleQ>6iYjkpZ zDmn@n5Jlt`?x)N=B#A{`iFGu=kXfEbjqmboCXPOAo8pPH4pVkyYj~|c(M4UiF-gLf z1YSp6tBV$@l9DBJ+3V}1v@X4AED0a>%a3FD<0X1x@MB6fKRQyrk5p!bIj!jhq zsHnKjvH}}{YOQ1lFhr)sIU~2unhN{=YmTg@Ay5)|)-iErq@$k8a|U;?5gzhC`qx*e z<6k6Ax+uWBf)r+Es_yfcW(*nBEJt9lvKFVw2yx-#IiBNGadZa+#f7#u*_5M&<%tb8w~i|EE1W(=(DD+BE7)T z8w&UEkoWe@IP^%4BE-n~M|H_^HaA*Y(G-?84{*!N{RCzz4QU=wnd3NO>R1uw4G0w?t!nyt-w!OSpK~{65mSGg2alcCjg2&; z%nG)`t16GWl7V`mB_5jPkJ1ibBpq=Z@)0o;q$9ioUDY@NhYCE4^30KoKxhCUQ~|IE zTuc_{IkJOD6wYBT0Pf6`C_sTTImbdJl#^MRg)_ZxE(mi0KujkZ268G{;w6XE@1GTb z3+BxQK{5r@LRkkTcuqz^;^?dpE&@Q)IaSsXr>G9F(bNZK0&wZc&)g^=GM8y20V<#j zDM$~_gm7^HhGnyY;((CI_!NnEn*hN7`^Ok_qkytI$8s|E;HV;spvMRfNa@MuEVY4| z`Q4?mE0+qf;)tkYSsbe=8cn7?Pqd5r_#hmB#l&2hLw?62{*XW;7`KN=(+=m{AWvnL z-kRVHL>0V6y#)&2yz~Xa53TVJf`X0u)~8x@2D*VUQY1&`VgJBhglV?)@MW*dRdvLn zbzql6x`fj~1Lq@OB-JwBER?PKF4%L#k1zoKlhTft9ToYp##)l@7_9syGCZ_qR*yi7 zw|i>Uu3FjFh35!%i8=M(0UJHll5N?(7fT6-;Y)yyl$zf3_!FW9Q~iM0884WXToJBp zYk^h`X(*P@qqahuS=Ql^!J0^uP~$LF?KoNEy9T%ZFnZRo@5^N0obpy%K3I3>7=6}4 z?y2B?Tn$Vm%33(_mRM9S1i?|&uTXOP=l`FE-Uwq9heK^iL%^mP&LiYA&2dF=crX=? zONN8nG=hel1ud2TYCW4bG*#E;`hNOZBbt>Frb$$yQk^>6(or`&??@E#>AFXD3X zX*g3Xj((kR!AWO8K)ry#IbEQ2*vbU{>^VoPCpcZ#Wt1H(lO8I|&NpVA<2FtbbRK6f zmWqs%({Q|df>cAd>b_9yEz*M5_Ubc->q)p4fuaD8`ZDm_{?a&0Lk8^d2ont)(hHA~ z9dn6*d&%gGDv5#nI-KD+CN4Yhxc}qL4e(RK+Y$hL{8Oj`LQ;X1a9AMYT8*eUPmopg z_Ltkl$&;T>kuOh={nV1>97TXZ=WW4K4MjRdc#xNen);@#F2D{#Y8V}={U&aP!4IHB z>>yDWo`e9>3tUZTKSkJCjAujOC<~}dDIypw+Z@kYPm{HCUrB2M|4jW@diBhuch729 z&zc5*lWeJ3gGR$ymw?keZ-#AW5l$h5Q61gT4BI|^5r;q>Sl|&f&5>baaCOe&IZ?9D zP*j_)+ca1>8vi1Tqm`Ct$;hX%+I7sP7f7b9dZX3$&)&c~$y7v(wNa=<7J}IBxql?O zoF$nd_Yyw8KG|%{HD`6Jx{B(L>hQLKD}D+mJx`pAr?YV2b1&g15;G3cf;u>|LJ=q3 zxRanl6?y$z7h&=gj}edBqJFQne@xj>RnF%4rSIbWEXe)98`Q*oSU)MOJwy$#LiE;e6qZJX+`Bs zTp+V>FG6s1g|~!P$j16&M=eEk>n{WOwbm(mi3tBKo;PtD#(=hJuac`Sf?V(^s1UzK z)?enxj2je&UMJG!&hV4%TGjsza&=>(g?t!?LV?xOG*iOWSlz<)M+HUVH_6PC8P}(0 z4cw=fM;%VkH?g>9DDyCkh}Bs*PwD*Z^lm1%JAEwEOz*cy?b=Y)0as%a|7|Kl!38>% z(_xBDUKLOx{SFbJbhDh+HlBQFrmiF96mW)&YZsg~NiI=^6>Y&VZDjAZhQm*rf1BJ~ zm8T%--Ru_-cZ^qRlGU)&;MT{KXdxZ~+PJx_x-#-^U4Dm#lvDpZo2mSq)>|86r0nw6@qP2dihe`X(up+2J;n?(YQ0}`9Fag$Y0HR*k_k4uhlPt|7@TIed{^aWtqoG z7pP3{XE?TAp-0TE>*B_E{@@^r?8VZ=x~{sH!QxnD-9nn34Wj6lE8$NG25Ge%HxP%{ zwNF>j)^#C4rZ>X}hx0{4&zINDFBA(9@Ubx{)u1z0p+s2MwFD(syJREL8>Hz&d0lH6 K(flBk?*9XW4V7&G delta 5562 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 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'