From 2fbd56342730f2fe7067dfa3c00760fae2bb201f Mon Sep 17 00:00:00 2001 From: Den Ilin Date: Thu, 5 Nov 2026 00:01:53 +0700 Subject: [PATCH] feat(http): send fetch request bodies as raw IPC payloads (#3732) --- .changes/http-raw-request-body.md | 6 ++++++ Cargo.lock | 1 + plugins/http/Cargo.toml | 1 + plugins/http/api-iife.js | 2 +- plugins/http/guest-js/index.ts | 31 +++++++++++++++++------------- plugins/http/src/commands.rs | 32 ++++++++++++++++++++++++------- 6 files changed, 52 insertions(+), 21 deletions(-) create mode 100644 .changes/http-raw-request-body.md diff --git a/.changes/http-raw-request-body.md b/.changes/http-raw-request-body.md new file mode 100644 index 000000000..ea46ba33b --- /dev/null +++ b/.changes/http-raw-request-body.md @@ -0,0 +1,6 @@ +--- +"http": minor:feat +"http-js": minor:feat +--- + +Improve `fetch` request by avoiding serialization when possible while handling the request body. diff --git a/Cargo.lock b/Cargo.lock index 85b0c95a8..b3c200787 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6897,6 +6897,7 @@ dependencies = [ "cookie_store", "data-url", "http", + "percent-encoding", "regex", "reqwest 0.12.28", "schemars", diff --git a/plugins/http/Cargo.toml b/plugins/http/Cargo.toml index 30732d24a..f9c338c5e 100644 --- a/plugins/http/Cargo.toml +++ b/plugins/http/Cargo.toml @@ -45,6 +45,7 @@ http = "1" reqwest = { version = "0.12.16", default-features = false } url = { workspace = true } data-url = "0.3" +percent-encoding = "2" cookie_store = { version = "0.22", optional = true, features = ["serde"] } bytes = { version = "1.9", optional = true } tracing = { workspace = true, optional = true } diff --git a/plugins/http/api-iife.js b/plugins/http/api-iife.js index 5d3720aca..63965f740 100644 --- a/plugins/http/api-iife.js +++ b/plugins/http/api-iife.js @@ -1 +1 @@ -if("__TAURI__"in window){var __TAURI_PLUGIN_HTTP__=function(e,t){"use strict";const r="Request cancelled";return e.fetch=async function(e,n){const a=n?.signal;if(a?.aborted)throw new Error(r);const o=n?.maxRedirections,i=n?.connectTimeout,s=n?.proxy,c=n?.danger;n&&(delete n.maxRedirections,delete n.connectTimeout,delete n.proxy,delete n.danger);const d=n?.headers?n.headers instanceof Headers?n.headers:new Headers(n.headers):new Headers,l=new Request(e,n),u=await l.arrayBuffer(),h=0!==u.byteLength?Array.from(new Uint8Array(u)):null;for(const[e,t]of l.headers)d.get(e)||d.set(e,t);const _=(d instanceof Headers?Array.from(d.entries()):Array.isArray(d)?d:Object.entries(d)).map(([e,t])=>[e,"string"==typeof t?t:t.toString()]);if(a?.aborted)throw new Error(r);const w=await t.invoke("plugin:http|fetch",{clientConfig:{method:l.method,url:l.url,headers:_,data:h,maxRedirections:o,connectTimeout:i,proxy:s,danger:c}}),f=()=>t.invoke("plugin:http|fetch_cancel",{rid:w}).catch(()=>{});if(a?.aborted)throw f(),new Error(r);a?.addEventListener("abort",()=>{f()});const{status:y,statusText:p,url:b,headers:g,rid:v}=await t.invoke("plugin:http|fetch_send",{rid:w});let T=!1;const m=()=>T?Promise.resolve():(T=!0,t.invoke("plugin:http|fetch_cancel_body",{rid:v}).catch(()=>{})),R=[101,103,204,205,304].includes(y)?null:new ReadableStream({start:e=>{a?.addEventListener("abort",()=>{e.error(r),m()})},pull:e=>(async e=>{let r;try{r=await t.invoke("plugin:http|fetch_read_body",{rid:v})}catch(t){return e.error(t),void m()}const n=new Uint8Array(r),a=n[n.byteLength-1],o=n.slice(0,n.byteLength-1);1!==a?e.enqueue(o):e.close()})(e),cancel:()=>{m()}}),A=new Response(R,{status:y,statusText:p});Object.defineProperty(A,"url",{value:b,writable:!1}),Object.defineProperty(A,"headers",{value:new Headers(g),writable:!1});const P=A.clone.bind(A);return Object.defineProperty(A,"clone",{value:()=>{const e=P();return Object.defineProperty(e,"url",{value:b,writable:!1}),Object.defineProperty(e,"headers",{value:new Headers(g),writable:!1}),e}}),A},e}({},window.__TAURI__.core);Object.defineProperty(window.__TAURI__,"http",{value:__TAURI_PLUGIN_HTTP__})} +if("__TAURI__"in window){var __TAURI_PLUGIN_HTTP__=function(e,t){"use strict";const r="Request cancelled";return e.fetch=async function(e,n){const a=n?.signal;if(a?.aborted)throw new Error(r);const o=n?.maxRedirections,i=n?.connectTimeout,s=n?.proxy,c=n?.danger;n&&(delete n.maxRedirections,delete n.connectTimeout,delete n.proxy,delete n.danger);const d=n?.headers?n.headers instanceof Headers?n.headers:new Headers(n.headers):new Headers,l=new Request(e,n),u=await l.arrayBuffer();for(const[e,t]of l.headers)d.get(e)||d.set(e,t);const h=(d instanceof Headers?Array.from(d.entries()):Array.isArray(d)?d:Object.entries(d)).map(([e,t])=>[e,"string"==typeof t?t:t.toString()]);if(a?.aborted)throw new Error(r);const _={method:l.method,url:l.url,headers:h,maxRedirections:o,connectTimeout:i,proxy:s,danger:c},w=await t.invoke("plugin:http|fetch",new Uint8Array(u),{headers:{"client-config":encodeURIComponent(JSON.stringify(_))}}),f=()=>t.invoke("plugin:http|fetch_cancel",{rid:w}).catch(()=>{});if(a?.aborted)throw f(),new Error(r);a?.addEventListener("abort",()=>{f()});const{status:p,statusText:y,url:b,headers:g,rid:v}=await t.invoke("plugin:http|fetch_send",{rid:w});let T=!1;const m=()=>T?Promise.resolve():(T=!0,t.invoke("plugin:http|fetch_cancel_body",{rid:v}).catch(()=>{})),R=[101,103,204,205,304].includes(p)?null:new ReadableStream({start:e=>{a?.addEventListener("abort",()=>{e.error(r),m()})},pull:e=>(async e=>{let r;try{r=await t.invoke("plugin:http|fetch_read_body",{rid:v})}catch(t){return e.error(t),void m()}const n=new Uint8Array(r),a=n[n.byteLength-1],o=n.slice(0,n.byteLength-1);1!==a?e.enqueue(o):e.close()})(e),cancel:()=>{m()}}),P=new Response(R,{status:p,statusText:y});Object.defineProperty(P,"url",{value:b,writable:!1}),Object.defineProperty(P,"headers",{value:new Headers(g),writable:!1});const A=P.clone.bind(P);return Object.defineProperty(P,"clone",{value:()=>{const e=A();return Object.defineProperty(e,"url",{value:b,writable:!1}),Object.defineProperty(e,"headers",{value:new Headers(g),writable:!1}),e}}),P},e}({},window.__TAURI__.core);Object.defineProperty(window.__TAURI__,"http",{value:__TAURI_PLUGIN_HTTP__})} diff --git a/plugins/http/guest-js/index.ts b/plugins/http/guest-js/index.ts index 5d4e0945f..af2f2f656 100644 --- a/plugins/http/guest-js/index.ts +++ b/plugins/http/guest-js/index.ts @@ -178,8 +178,6 @@ export async function fetch( const req = new Request(input, init) const buffer = await req.arrayBuffer() - const data = - buffer.byteLength !== 0 ? Array.from(new Uint8Array(buffer)) : null // append new headers created by the browser `Request` implementation, // if not already declared by the caller of this function @@ -211,18 +209,25 @@ export async function fetch( throw new Error(ERROR_REQUEST_CANCELLED) } - const rid = await invoke('plugin:http|fetch', { - clientConfig: { - method: req.method, - url: req.url, - headers: mappedHeaders, - data, - maxRedirections, - connectTimeout, - proxy, - danger + const clientConfig = { + method: req.method, + url: req.url, + headers: mappedHeaders, + maxRedirections, + connectTimeout, + proxy, + danger + } + + const rid = await invoke( + 'plugin:http|fetch', + new Uint8Array(buffer), + { + headers: { + 'client-config': encodeURIComponent(JSON.stringify(clientConfig)) + } } - }) + ) const abort = () => invoke('plugin:http|fetch_cancel', { rid }).catch(() => {}) diff --git a/plugins/http/src/commands.rs b/plugins/http/src/commands.rs index 44af5bd54..b22afcf1f 100644 --- a/plugins/http/src/commands.rs +++ b/plugins/http/src/commands.rs @@ -2,16 +2,16 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: MIT -use std::{future::Future, pin::Pin, str::FromStr, sync::Arc, time::Duration}; +use std::{borrow::Cow, future::Future, pin::Pin, str::FromStr, sync::Arc, time::Duration}; use http::{HeaderMap, HeaderName, HeaderValue, Method, StatusCode, header}; use reqwest::{NoProxy, redirect::Policy}; -use serde::{Deserialize, Serialize}; +use serde::{Deserialize, Serialize, de::Error as _}; use tauri::{ Manager, ResourceId, ResourceTable, Runtime, State, Webview, async_runtime::Mutex, command, - ipc::{CommandScope, GlobalScope}, + ipc::{CommandScope, GlobalScope, InvokeBody}, }; use tokio::sync::oneshot::{Receiver, Sender, channel}; @@ -93,7 +93,6 @@ pub struct ClientConfig { method: String, url: url::Url, headers: Vec<(String, String)>, - data: Option>, connect_timeout: Option, max_redirections: Option, proxy: Option, @@ -228,11 +227,23 @@ fn map_request_error(error: reqwest::Error) -> Error { Error::Network(error) } +/// IPC header carrying the percent-encoded JSON [`ClientConfig`]. +const CLIENT_CONFIG_HEADER: &str = "client-config"; + +/// Reads the options from the IPC header. +fn read_client_config(headers: &HeaderMap) -> serde_json::Result { + let header = headers + .get(CLIENT_CONFIG_HEADER) + .ok_or_else(|| serde_json::Error::custom("missing client-config header"))?; + let header: Cow<'_, [u8]> = percent_encoding::percent_decode(header.as_bytes()).into(); + serde_json::from_slice(&header) +} + #[command] pub async fn fetch( webview: Webview, state: State<'_, Http>, - client_config: ClientConfig, + request: tauri::ipc::Request<'_>, command_scope: CommandScope, global_scope: GlobalScope, ) -> crate::Result { @@ -240,12 +251,19 @@ pub async fn fetch( method, url, headers: headers_raw, - data, connect_timeout, max_redirections, proxy, danger, - } = client_config; + } = read_client_config(request.headers())?; + + // Even though JS sends a raw payload, Android and the `postMessage` fallback deliver it + // as a JSON array of numbers. + let data = match request.body() { + InvokeBody::Raw(data) => data.clone(), + InvokeBody::Json(data) => Vec::::deserialize(data)?, + }; + let data = if data.is_empty() { None } else { Some(data) }; let scheme = url.scheme(); let method = Method::from_bytes(method.as_bytes())?;