mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 05:10:16 +03:00
fast-path local PDF transport and reduce chat re-renders (#6798)
This commit is contained in:
@@ -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::{
|
||||
|
||||
@@ -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 200–599, 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 {
|
||||
|
||||
Reference in New Issue
Block a user