fast-path local PDF transport and reduce chat re-renders (#6798)

This commit is contained in:
EthanHealy01
2026-06-24 21:44:13 +01:00
committed by GitHub
parent f715a73f1b
commit b040277220
7 changed files with 415 additions and 25 deletions
@@ -0,0 +1,147 @@
//! Fast-path transport for LOCALHOST binary traffic to the bundled backend.
//!
//! `@tauri-apps/plugin-http` marshals request/response bodies across the IPC
//! bridge as a JSON array of per-byte numbers (~3.5x size bloat plus heavy GC),
//! which is painful for large PDF uploads/downloads. This command moves the
//! raw bytes across IPC as `InvokeBody::Raw` instead, carrying the request and
//! response metadata in a small length-prefixed frame alongside the untouched
//! body:
//!
//! ```text
//! frame = [u32 BE meta_len][meta JSON (utf-8)][raw body bytes]
//! ```
//!
//! It is intentionally scoped to loopback URLs only (validated by parsing the
//! host to an `IpAddr`), and the shared client disables redirects, so it can
//! never be used as a general outbound request primitive — a 3xx pointing
//! off-host is not followed (no SSRF / arbitrary-URL fetch).
use std::sync::OnceLock;
use serde::{Deserialize, Serialize};
use tauri::ipc::{InvokeBody, Request, Response};
use tauri_plugin_http::reqwest::header::{HeaderName, HeaderValue};
use tauri_plugin_http::reqwest::{redirect::Policy, Client, Method};
use url::{Host, Url};
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct ProxyRequestMeta {
method: String,
url: String,
headers: Vec<(String, String)>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct ProxyResponseMeta {
status: u16,
status_text: String,
headers: Vec<(String, String)>,
}
/// Process-wide client: pools connections / keeps them alive across the many
/// calls a session makes, and disables redirects so the loopback guarantee
/// can't be escaped via a 3xx `Location` to a non-loopback host.
fn http_client() -> &'static Client {
static CLIENT: OnceLock<Client> = OnceLock::new();
CLIENT.get_or_init(|| {
Client::builder()
.redirect(Policy::none())
.build()
.expect("failed to build local-proxy reqwest client")
})
}
/// Only loopback hosts are permitted — this is a bundled-backend shortcut, not
/// a general HTTP proxy. Parsing to an `IpAddr` covers all of 127.0.0.0/8 and
/// `::1` in any spelling, rather than matching a few exact strings.
fn is_loopback_host(url: &Url) -> bool {
match url.host() {
Some(Host::Ipv4(ip)) => ip.is_loopback(),
Some(Host::Ipv6(ip)) => ip.is_loopback(),
Some(Host::Domain(domain)) => domain.eq_ignore_ascii_case("localhost"),
None => false,
}
}
/// Proxy a single request to the bundled localhost backend, moving the body as
/// raw bytes instead of the plugin-http number-array. See the module docs for
/// the frame layout.
#[tauri::command]
pub async fn proxy_local_pdf_request(request: Request<'_>) -> Result<Response, String> {
let frame = match request.body() {
InvokeBody::Raw(bytes) => bytes,
InvokeBody::Json(_) => {
return Err("proxy_local_pdf_request: expected a raw body".into());
}
};
if frame.len() < 4 {
return Err("proxy_local_pdf_request: frame too short".into());
}
let meta_len = u32::from_be_bytes([frame[0], frame[1], frame[2], frame[3]]) as usize;
let meta_end = 4usize
.checked_add(meta_len)
.ok_or("proxy_local_pdf_request: meta length overflow")?;
if frame.len() < meta_end {
return Err("proxy_local_pdf_request: frame truncated".into());
}
let meta: ProxyRequestMeta =
serde_json::from_slice(&frame[4..meta_end]).map_err(|e| e.to_string())?;
let body = frame[meta_end..].to_vec();
let url = Url::parse(&meta.url).map_err(|e| e.to_string())?;
if !is_loopback_host(&url) {
return Err(format!(
"proxy_local_pdf_request: refusing non-loopback url: {}",
meta.url
));
}
let method = Method::from_bytes(meta.method.as_bytes()).map_err(|e| e.to_string())?;
let mut builder = http_client().request(method, url);
for (name, value) in &meta.headers {
// Build the header explicitly and skip any invalid pair, rather than
// letting one malformed header fail the whole request opaquely at send().
if let (Ok(header_name), Ok(header_value)) = (
HeaderName::from_bytes(name.as_bytes()),
HeaderValue::from_str(value),
) {
builder = builder.header(header_name, header_value);
}
}
if !body.is_empty() {
builder = builder.body(body);
}
let response = builder.send().await.map_err(|e| e.to_string())?;
let status = response.status();
let status_text = status.canonical_reason().unwrap_or("").to_string();
let headers: Vec<(String, String)> = response
.headers()
.iter()
.filter_map(|(name, value)| {
value
.to_str()
.ok()
.map(|v| (name.as_str().to_string(), v.to_string()))
})
.collect();
let body_bytes = response.bytes().await.map_err(|e| e.to_string())?;
let meta_out = ProxyResponseMeta {
status: status.as_u16(),
status_text,
headers,
};
let meta_json = serde_json::to_vec(&meta_out).map_err(|e| e.to_string())?;
let mut out = Vec::with_capacity(4 + meta_json.len() + body_bytes.len());
out.extend_from_slice(&(meta_json.len() as u32).to_be_bytes());
out.extend_from_slice(&meta_json);
out.extend_from_slice(&body_bytes);
Ok(Response::new(out))
}
@@ -3,6 +3,7 @@ pub mod files;
pub mod connection;
pub mod auth;
pub mod default_app;
pub mod local_proxy;
pub mod platform;
pub mod print;
pub mod updater;
@@ -40,6 +41,7 @@ pub use auth::{
start_oauth_login,
};
pub use default_app::{is_default_pdf_handler, set_as_default_pdf_handler};
pub use local_proxy::proxy_local_pdf_request;
pub use platform::get_desktop_os;
pub use print::print_pdf_file_native;
pub use updater::{
+2
View File
@@ -25,6 +25,7 @@ use commands::{
get_user_info,
is_first_launch,
login,
proxy_local_pdf_request,
reset_setup_completion,
save_auth_token,
save_refresh_token,
@@ -177,6 +178,7 @@ pub fn run() {
is_first_launch,
reset_setup_completion,
login,
proxy_local_pdf_request,
save_auth_token,
get_auth_token,
clear_auth_token,
@@ -36,13 +36,20 @@ class TauriFileOpenService implements FileOpenService {
const fileData = await readFile(filePath);
const fileName = filePath.split(/[\\/]/).pop() || "opened-file.pdf";
return {
fileName,
arrayBuffer: fileData.buffer.slice(
fileData.byteOffset,
fileData.byteOffset + fileData.byteLength,
),
};
// readFile usually returns a tightly-packed buffer; in that case hand it
// over directly instead of slicing, which would copy the entire file
// (a transient 2x memory spike for large PDFs). Only slice when the view
// is a window over a larger ArrayBuffer.
const arrayBuffer =
fileData.byteOffset === 0 &&
fileData.byteLength === fileData.buffer.byteLength
? fileData.buffer
: fileData.buffer.slice(
fileData.byteOffset,
fileData.byteOffset + fileData.byteLength,
);
return { fileName, arrayBuffer };
} catch (error) {
console.error("Failed to read file:", error);
return null;
@@ -1,5 +1,10 @@
/* eslint-disable @typescript-eslint/no-explicit-any -- Axios-compatible API requires matching axios's `any` signatures */
import { fetch } from "@tauri-apps/plugin-http";
import {
shouldUseFastLocalTransport,
fetchViaLocalProxy,
markFastTransportUnavailable,
} from "@app/services/tauriLocalProxy";
/**
* Tauri HTTP Client - wrapper around Tauri's native HTTP client
@@ -265,7 +270,47 @@ class TauriHttpClient {
};
}
const response = await fetch(url, fetchOptions);
// Fast path: for localhost PDF uploads/downloads, move the body as raw
// bytes via the Rust proxy instead of plugin-http's number-array IPC.
// Only binary localhost traffic qualifies (see shouldUseFastLocalTransport);
// everything else — remote requests, JSON/GET calls, anything without a
// PDF body — uses the unchanged plugin-http path. Falls back to plugin-http
// automatically if the fast path throws, so behaviour is never worse.
let response: Response;
if (
shouldUseFastLocalTransport(
url,
finalConfig.responseType,
finalConfig.data,
)
) {
try {
response = await fetchViaLocalProxy(
url,
method,
headers,
body,
finalConfig.signal,
);
} catch (proxyError) {
// A deliberate abort must propagate, not silently re-issue the request.
if (
proxyError instanceof DOMException &&
proxyError.name === "AbortError"
) {
throw proxyError;
}
// Fast path failed — trip the circuit breaker and fall back to plugin-http.
markFastTransportUnavailable();
console.warn(
"[TauriHttpClient] local fast-path failed; reverting to plugin-http for this session",
proxyError,
);
response = await fetch(url, fetchOptions);
}
} else {
response = await fetch(url, fetchOptions);
}
// Convert Headers to plain object
const responseHeaders: Record<string, string> = {};
@@ -0,0 +1,162 @@
import { invoke } from "@tauri-apps/api/core";
// Fast path for localhost binary traffic: moves raw bytes via a Rust command
// instead of plugin-http's per-byte number-array IPC (~3.5x size bloat).
// See src-tauri/src/commands/local_proxy.rs.
//
// A single runtime failure trips a session-wide circuit breaker and all
// subsequent requests fall back to plugin-http transparently.
let fastTransportUnavailable = false;
// Trip the circuit breaker so the rest of the session uses plugin-http.
export function markFastTransportUnavailable(): void {
fastTransportUnavailable = true;
}
function isLoopbackUrl(url: string): boolean {
try {
let host = new URL(url).hostname.toLowerCase();
// URL.hostname wraps IPv6 literals in brackets; strip them to compare.
if (host.startsWith("[") && host.endsWith("]")) host = host.slice(1, -1);
return (
host === "localhost" ||
host === "::1" ||
/^127(?:\.\d{1,3}){3}$/.test(host) // 127.0.0.0/8 loopback range
);
} catch {
return false;
}
}
/**
* Whether a request should use the raw-bytes localhost fast path.
*
* Only localhost requests that actually move a PDF qualify: a binary upload
* (FormData) or a binary download (blob/arraybuffer response). Everything else
* — remote/cloud/self-hosted requests, JSON/GET calls, anything with no PDF
* body — returns false and stays on the normal plugin-http path untouched.
*/
export function shouldUseFastLocalTransport(
url: string,
responseType: string | undefined,
data: unknown,
): boolean {
if (fastTransportUnavailable) return false;
if (!isLoopbackUrl(url)) return false;
const hasBinaryUpload =
typeof FormData !== "undefined" && data instanceof FormData;
const wantsBinaryDownload =
responseType === "blob" || responseType === "arraybuffer";
return hasBinaryUpload || wantsBinaryDownload;
}
interface ProxyResponseMeta {
status: number;
statusText: string;
headers: [string, string][];
}
// Statuses that must carry a null body (per the fetch spec). 1xx are excluded:
// the Response constructor only accepts 200599, and they never reach here.
const NULL_BODY_STATUSES = new Set([204, 205, 304]);
// Headers describing the ORIGINAL transfer encoding/length. They're meaningless
// (and can mismatch) once the bytes are handed to a fresh Response, which
// recomputes them — so strip them when reconstructing.
const STRIP_RESPONSE_HEADERS = new Set([
"content-length",
"content-encoding",
"transfer-encoding",
]);
function abortError(): DOMException {
return new DOMException("The operation was aborted.", "AbortError");
}
/**
* Send a request to the localhost backend via the raw-bytes Rust proxy and
* return a standard `Response`, so all downstream handling in tauriHttpClient
* (status checks, body parsing per responseType, response interceptors) is
* identical to the normal fetch path.
*
* Frame layout (both directions): [u32 BE meta_len][meta JSON][raw body].
*/
export async function fetchViaLocalProxy(
url: string,
method: string,
headers: Record<string, string>,
body: BodyInit | undefined,
signal?: AbortSignal,
): Promise<Response> {
if (signal?.aborted) throw abortError();
// Normalise the body to raw bytes. Using Request also generates the correct
// multipart Content-Type (with boundary) for FormData, matching what the
// browser/native fetch would otherwise send.
const outHeaders: Record<string, string> = { ...headers };
let bodyBytes = new Uint8Array(0);
if (body !== undefined) {
const probe = new Request("http://localhost/", { method: "POST", body });
bodyBytes = new Uint8Array(await probe.arrayBuffer());
// Adopt the probe's Content-Type ONLY when the caller didn't set one. This
// captures the multipart boundary for FormData (executeRequest deletes the
// header for FormData) without clobbering an explicit type such as
// application/json that the caller already set on a JSON body.
const hasContentType = Object.keys(outHeaders).some(
(h) => h.toLowerCase() === "content-type",
);
const contentType = probe.headers.get("content-type");
if (contentType && !hasContentType) {
outHeaders["Content-Type"] = contentType;
}
}
const metaBytes = new TextEncoder().encode(
JSON.stringify({ method, url, headers: Object.entries(outHeaders) }),
);
const frame = new Uint8Array(4 + metaBytes.length + bodyBytes.length);
new DataView(frame.buffer).setUint32(0, metaBytes.length, false);
frame.set(metaBytes, 4);
frame.set(bodyBytes, 4 + metaBytes.length);
const invokePromise = invoke<ArrayBuffer>("proxy_local_pdf_request", frame);
// The Rust command can't be cancelled mid-flight, but honour the signal on
// the JS side so an abort unblocks the caller (the discarded result is GC'd).
const respBuf = signal
? await new Promise<ArrayBuffer>((resolve, reject) => {
const onAbort = () => reject(abortError());
signal.addEventListener("abort", onAbort, { once: true });
invokePromise.then(
(value) => {
signal.removeEventListener("abort", onAbort);
resolve(value);
},
(error) => {
signal.removeEventListener("abort", onAbort);
reject(error);
},
);
})
: await invokePromise;
const respBytes = new Uint8Array(respBuf);
const metaLen = new DataView(respBuf).getUint32(0, false);
const respMeta: ProxyResponseMeta = JSON.parse(
new TextDecoder().decode(respBytes.subarray(4, 4 + metaLen)),
);
const respBody = respBytes.subarray(4 + metaLen);
const responseHeaders = respMeta.headers.filter(
([name]) => !STRIP_RESPONSE_HEADERS.has(name.toLowerCase()),
);
return new Response(
NULL_BODY_STATUSES.has(respMeta.status) ? null : respBody,
{
status: respMeta.status,
statusText: respMeta.statusText,
headers: responseHeaders,
},
);
}
@@ -3,6 +3,7 @@ import {
useContext,
useReducer,
useCallback,
useMemo,
useRef,
type ReactNode,
} from "react";
@@ -403,6 +404,15 @@ export function ChatProvider({ children }: { children: ReactNode }) {
const abortRef = useRef<AbortController | null>(null);
const messagesRef = useRef<ChatMessage[]>(state.messages);
messagesRef.current = state.messages;
// Hold the latest files in refs so sendMessage's identity does not change on
// every file operation. Otherwise a new sendMessage (and thus a new context
// value) would be created on each file change, re-rendering every useChat()
// consumer. sendMessage reads .current at call time, so it still sees the
// current files.
const activeFilesRef = useRef(activeFiles);
activeFilesRef.current = activeFiles;
const activeFileStubsRef = useRef(activeFileStubs);
activeFileStubsRef.current = activeFileStubs;
// Download a File from the Stirling files endpoint.
const downloadFile = useCallback(
@@ -495,6 +505,11 @@ export function ChatProvider({ children }: { children: ReactNode }) {
abortRef.current = controller;
const priorMessages = messagesRef.current;
// Snapshot the files at send time so the upload AND the result-import both
// act on what the user actually sent — not on whatever the workbench holds
// when the (possibly many-seconds-later) result arrives.
const sourceFiles = activeFilesRef.current;
const sourceStubs = activeFileStubsRef.current;
const startTime = Date.now();
// Mirror every progress event locally so we can attach the full log to
// the assistant message when the result arrives — without needing a ref
@@ -514,7 +529,7 @@ export function ChatProvider({ children }: { children: ReactNode }) {
try {
const formData = new FormData();
formData.append("userMessage", content);
activeFiles.forEach((file, i) => {
sourceFiles.forEach((file, i) => {
formData.append(`fileInputs[${i}].fileInput`, file);
});
priorMessages.forEach((message, i) => {
@@ -633,7 +648,7 @@ export function ChatProvider({ children }: { children: ReactNode }) {
},
});
if (data.fileId || data.resultFiles?.length) {
importResultFile(data, activeFileStubs).catch((err) => {
importResultFile(data, sourceStubs).catch((err) => {
console.error("Failed to import AI result file", err);
dispatch({
type: "ADD_MESSAGE",
@@ -693,24 +708,34 @@ export function ChatProvider({ children }: { children: ReactNode }) {
}
}
},
[activeFiles, activeFileStubs, importResultFile],
[importResultFile],
);
return (
<ChatContext.Provider
value={{
messages: state.messages,
isLoading: state.isLoading,
progress: state.progress,
progressLog: state.progressLog,
sendMessage,
cancelMessage,
clearChat,
}}
>
{children}
</ChatContext.Provider>
// Memoize the context value so it only changes when chat state changes — not
// on every file operation. With sendMessage/cancelMessage/clearChat all stable,
// useChat() consumers re-render only when messages/loading/progress change.
const value = useMemo<ChatContextValue>(
() => ({
messages: state.messages,
isLoading: state.isLoading,
progress: state.progress,
progressLog: state.progressLog,
sendMessage,
cancelMessage,
clearChat,
}),
[
state.messages,
state.isLoading,
state.progress,
state.progressLog,
sendMessage,
cancelMessage,
clearChat,
],
);
return <ChatContext.Provider value={value}>{children}</ChatContext.Provider>;
}
export function useChat(): ChatContextValue {