Address response bodies by response id instead of a filesystem path (#550)

This commit is contained in:
Gregory Schier
2026-08-15 08:21:23 -07:00
committed by GitHub
parent f3f05502d1
commit 85a9b2a908
18 changed files with 375 additions and 102 deletions
@@ -8,6 +8,7 @@ import { useCopyHttpResponse } from "../hooks/useCopyHttpResponse";
import { useHttpResponseEvents } from "../hooks/useHttpResponseEvents"; import { useHttpResponseEvents } from "../hooks/useHttpResponseEvents";
import { usePinnedHttpResponse } from "../hooks/usePinnedHttpResponse"; import { usePinnedHttpResponse } from "../hooks/usePinnedHttpResponse";
import { useResponseBodyBytes, useResponseBodyText } from "../hooks/useResponseBodyText"; import { useResponseBodyBytes, useResponseBodyText } from "../hooks/useResponseBodyText";
import { useResponseBodyUrl } from "../hooks/useResponseBodyUrl";
import { useResponseViewMode } from "../hooks/useResponseViewMode"; import { useResponseViewMode } from "../hooks/useResponseViewMode";
import { useSaveResponse } from "../hooks/useSaveResponse"; import { useSaveResponse } from "../hooks/useSaveResponse";
import { useTimelineViewMode } from "../hooks/useTimelineViewMode"; import { useTimelineViewMode } from "../hooks/useTimelineViewMode";
@@ -409,14 +410,13 @@ function EnsureCompleteResponse({
Component, Component,
}: { }: {
response: HttpResponse; response: HttpResponse;
Component: ComponentType<{ bodyPath: string }>; Component: ComponentType<{ bodyUrl: string }>;
}) { }) {
if (response.bodyPath === null) { // Wait until the response has been fully-downloaded before asking for it
return <div>Empty response body</div>; const complete = response.state === "closed";
} const bodyUrl = useResponseBodyUrl(complete ? response : null);
// Wait until the response has been fully-downloaded if (!complete || bodyUrl.isPending) {
if (response.state !== "closed") {
return ( return (
<EmptyStateText> <EmptyStateText>
<LoadingIcon /> <LoadingIcon />
@@ -424,7 +424,15 @@ function EnsureCompleteResponse({
); );
} }
return <Component bodyPath={response.bodyPath} />; if (bodyUrl.error) {
return <Banner color="danger">{String(bodyUrl.error)}</Banner>;
}
if (bodyUrl.data == null) {
return <div>Empty response body</div>;
}
return <Component bodyUrl={bodyUrl.data} />;
} }
function HttpSvgViewer({ response }: { response: HttpResponse }) { function HttpSvgViewer({ response }: { response: HttpResponse }) {
@@ -1,29 +1,29 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { platform } from "@yaakapp-internal/platform";
interface Props { interface Props {
bodyPath?: string; /** A URL for the body the host already stored. */
bodyUrl?: string;
data?: Uint8Array; data?: Uint8Array;
mimeType?: string; mimeType?: string;
} }
export function AudioViewer({ bodyPath, data, mimeType }: Props) { export function AudioViewer({ bodyUrl, data, mimeType }: Props) {
const [src, setSrc] = useState<string>(); const [src, setSrc] = useState<string>();
useEffect(() => { useEffect(() => {
if (bodyPath) { if (bodyUrl) {
setSrc(platform.files.url(bodyPath)); setSrc(bodyUrl);
} else if (data) { } else if (data) {
// The type matters here in a way it doesn't for an image: a media element goes by what // The type matters here in a way it doesn't for an image: a media element goes by what
// the blob declares rather than sniffing it, so an Ogg labelled as MP3 won't play // the blob declares rather than sniffing it, so an Ogg labelled as MP3 won't play
const blob = new Blob([new Uint8Array(data)], { type: mimeType ?? "audio/mpeg" }); const blob = new Blob([new Uint8Array(data)], { type: mimeType ?? "audio/mpeg" });
const url = URL.createObjectURL(blob); const objectUrl = URL.createObjectURL(blob);
setSrc(url); setSrc(objectUrl);
return () => URL.revokeObjectURL(url); return () => URL.revokeObjectURL(objectUrl);
} else { } else {
setSrc(undefined); setSrc(undefined);
} }
}, [bodyPath, data, mimeType]); }, [bodyUrl, data, mimeType]);
// oxlint-disable-next-line jsx-a11y/media-has-caption // oxlint-disable-next-line jsx-a11y/media-has-caption
return <audio className="w-full" controls src={src} />; return <audio className="w-full" controls src={src} />;
@@ -1,10 +1,10 @@
import classNames from "classnames"; import classNames from "classnames";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { platform } from "@yaakapp-internal/platform";
type Props = { className?: string; mimeType?: string } & ( type Props = { className?: string; mimeType?: string } & (
| { | {
bodyPath: string; /** A URL for the body the host already stored. */
bodyUrl: string;
} }
| { | {
data: ArrayBuffer; data: ArrayBuffer;
@@ -13,21 +13,21 @@ type Props = { className?: string; mimeType?: string } & (
export function ImageViewer({ className, mimeType, ...props }: Props) { export function ImageViewer({ className, mimeType, ...props }: Props) {
const [src, setSrc] = useState<string>(); const [src, setSrc] = useState<string>();
const bodyPath = "bodyPath" in props ? props.bodyPath : null; const bodyUrl = "bodyUrl" in props ? props.bodyUrl : null;
const data = "data" in props ? props.data : null; const data = "data" in props ? props.data : null;
useEffect(() => { useEffect(() => {
if (bodyPath != null) { if (bodyUrl != null) {
setSrc(platform.files.url(bodyPath)); setSrc(bodyUrl);
} else if (data != null) { } else if (data != null) {
const blob = new Blob([data], { type: mimeType ?? "image/png" }); const blob = new Blob([data], { type: mimeType ?? "image/png" });
const url = URL.createObjectURL(blob); const objectUrl = URL.createObjectURL(blob);
setSrc(url); setSrc(objectUrl);
return () => URL.revokeObjectURL(url); return () => URL.revokeObjectURL(objectUrl);
} else { } else {
setSrc(undefined); setSrc(undefined);
} }
}, [bodyPath, data, mimeType]); }, [bodyUrl, data, mimeType]);
return ( return (
<img <img
@@ -6,7 +6,6 @@ import { useMemo, useRef, useState } from "react";
import { Document, Page } from "react-pdf"; import { Document, Page } from "react-pdf";
import { useContainerSize } from "@yaakapp-internal/ui"; import { useContainerSize } from "@yaakapp-internal/ui";
import { fireAndForget } from "../../lib/fireAndForget"; import { fireAndForget } from "../../lib/fireAndForget";
import { platform } from "@yaakapp-internal/platform";
fireAndForget( fireAndForget(
import("react-pdf").then(({ pdfjs }) => { import("react-pdf").then(({ pdfjs }) => {
@@ -18,7 +17,8 @@ fireAndForget(
); );
interface Props { interface Props {
bodyPath?: string; /** A URL for the body the host already stored. */
bodyUrl?: string;
data?: Uint8Array; data?: Uint8Array;
} }
@@ -27,7 +27,7 @@ const options = {
standardFontDataUrl: "/standard_fonts/", standardFontDataUrl: "/standard_fonts/",
}; };
export function PdfViewer({ bodyPath, data }: Props) { export function PdfViewer({ bodyUrl, data }: Props) {
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const [numPages, setNumPages] = useState<number>(); const [numPages, setNumPages] = useState<number>();
@@ -36,8 +36,8 @@ export function PdfViewer({ bodyPath, data }: Props) {
// During render, not in an effect: an effect leaves the first paint with no file, and // During render, not in an effect: an effect leaves the first paint with no file, and
// `Document` renders its "Failed to load PDF file" state for that frame before recovering // `Document` renders its "Failed to load PDF file" state for that frame before recovering
const src = useMemo(() => { const src = useMemo(() => {
if (bodyPath) { if (bodyUrl) {
return platform.files.url(bodyPath); return bodyUrl;
} }
if (data) { if (data) {
// Create a copy to avoid "Buffer is already detached" errors // Create a copy to avoid "Buffer is already detached" errors
@@ -45,7 +45,7 @@ export function PdfViewer({ bodyPath, data }: Props) {
return { data: new Uint8Array(data) }; return { data: new Uint8Array(data) };
} }
return undefined; return undefined;
}, [bodyPath, data]); }, [bodyUrl, data]);
const onDocumentLoadSuccess = ({ numPages: nextNumPages }: PDFDocumentProxy): void => { const onDocumentLoadSuccess = ({ numPages: nextNumPages }: PDFDocumentProxy): void => {
setNumPages(nextNumPages); setNumPages(nextNumPages);
@@ -1,28 +1,28 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { platform } from "@yaakapp-internal/platform";
interface Props { interface Props {
bodyPath?: string; /** A URL for the body the host already stored. */
bodyUrl?: string;
data?: Uint8Array; data?: Uint8Array;
mimeType?: string; mimeType?: string;
} }
export function VideoViewer({ bodyPath, data, mimeType }: Props) { export function VideoViewer({ bodyUrl, data, mimeType }: Props) {
const [src, setSrc] = useState<string>(); const [src, setSrc] = useState<string>();
useEffect(() => { useEffect(() => {
if (bodyPath) { if (bodyUrl) {
setSrc(platform.files.url(bodyPath)); setSrc(bodyUrl);
} else if (data) { } else if (data) {
// As in AudioViewer: a media element trusts the declared type instead of sniffing // As in AudioViewer: a media element trusts the declared type instead of sniffing
const blob = new Blob([new Uint8Array(data)], { type: mimeType ?? "video/mp4" }); const blob = new Blob([new Uint8Array(data)], { type: mimeType ?? "video/mp4" });
const url = URL.createObjectURL(blob); const objectUrl = URL.createObjectURL(blob);
setSrc(url); setSrc(objectUrl);
return () => URL.revokeObjectURL(url); return () => URL.revokeObjectURL(objectUrl);
} else { } else {
setSrc(undefined); setSrc(undefined);
} }
}, [bodyPath, data, mimeType]); }, [bodyUrl, data, mimeType]);
// oxlint-disable-next-line jsx-a11y/media-has-caption // oxlint-disable-next-line jsx-a11y/media-has-caption
return <video className="w-full" controls src={src} />; return <video className="w-full" controls src={src} />;
@@ -5,7 +5,6 @@ import type { GraphQLSchema, IntrospectionQuery } from "graphql";
import { buildClientSchema, getIntrospectionQuery } from "graphql"; import { buildClientSchema, getIntrospectionQuery } from "graphql";
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { minPromiseMillis } from "../lib/minPromiseMillis"; import { minPromiseMillis } from "../lib/minPromiseMillis";
import { getResponseBodyText } from "../lib/responseBody";
import { sendEphemeralRequest } from "../lib/sendEphemeralRequest"; import { sendEphemeralRequest } from "../lib/sendEphemeralRequest";
import { useActiveEnvironment } from "./useActiveEnvironment"; import { useActiveEnvironment } from "./useActiveEnvironment";
import { useDebouncedValue } from "@yaakapp-internal/ui"; import { useDebouncedValue } from "@yaakapp-internal/ui";
@@ -55,7 +54,7 @@ export function useIntrospectGraphQL(
bodyType: "application/json", bodyType: "application/json",
body: { text: introspectionRequestBody }, body: { text: introspectionRequestBody },
}; };
const response = await minPromiseMillis( const { response, body } = await minPromiseMillis(
sendEphemeralRequest(args, activeEnvironment?.id ?? null), sendEphemeralRequest(args, activeEnvironment?.id ?? null),
700, 700,
); );
@@ -64,14 +63,16 @@ export function useIntrospectGraphQL(
return setError(response.error); return setError(response.error);
} }
const bodyText = await getResponseBodyText({ response, filter: null }); // The send hands back the only copy of the body — an unsaved response has
// nothing on disk and no row to read it back from
const bodyText = new TextDecoder("utf-8").decode(new Uint8Array(body));
if (response.status < 200 || response.status >= 300) { if (response.status < 200 || response.status >= 300) {
return setError( return setError(
`Request failed with status ${response.status}.\nThe response text is:\n\n${bodyText}`, `Request failed with status ${response.status}.\nThe response text is:\n\n${bodyText}`,
); );
} }
if (bodyText === null) { if (bodyText === "") {
return setError("Empty body returned in response"); return setError("Empty body returned in response");
} }
@@ -0,0 +1,24 @@
import { useQuery } from "@tanstack/react-query";
import type { HttpResponse } from "@yaakapp-internal/models";
import { platform } from "@yaakapp-internal/platform";
/**
* A URL for a stored response body, for the viewers that hand one to an element
* instead of reading the bytes themselves.
*
* Resolved once here rather than inside each viewer: the host may have to ask
* the backend where the body is, and a viewer that computes its source during
* render (the PDF one, deliberately) needs it settled before it mounts.
*
* Null data means the response has no stored body.
*/
export function useResponseBodyUrl(response: HttpResponse | null) {
const responseId = response?.id ?? null;
return useQuery({
queryKey: ["response_body_url", responseId, response?.updatedAt ?? ""],
enabled: responseId != null,
// A response body is stored under the response's own id
queryFn: () => (responseId == null ? null : platform.blobs.url(responseId)),
});
}
+20 -10
View File
@@ -5,6 +5,12 @@ import { candidateJsonPayloadsFromSseText, computeSseSummary } from "@yaakapp-in
import { rpc } from "./rpc"; import { rpc } from "./rpc";
import { platform } from "@yaakapp-internal/platform"; import { platform } from "@yaakapp-internal/platform";
/**
* Reading a response body means naming the response, never the file it lives
* in: the backend resolves an id against its own records, so nothing the UI
* says can point a read somewhere else.
*/
export async function getResponseBodyText({ export async function getResponseBodyText({
response, response,
filter, filter,
@@ -13,7 +19,7 @@ export async function getResponseBodyText({
filter: string | null; filter: string | null;
}): Promise<string | null> { }): Promise<string | null> {
const result = await rpc<FilterResponse>("cmd_http_response_body", { const result = await rpc<FilterResponse>("cmd_http_response_body", {
response, responseId: response.id,
filter, filter,
}); });
@@ -27,10 +33,9 @@ export async function getResponseBodyText({
export async function getResponseBodyEventSource( export async function getResponseBodyEventSource(
response: HttpResponse, response: HttpResponse,
): Promise<ServerSentEvent[]> { ): Promise<ServerSentEvent[]> {
if (!response.bodyPath) return [];
try { try {
const events = await rpc<ServerSentEvent[]>("cmd_get_sse_events", { const events = await rpc<ServerSentEvent[]>("cmd_get_sse_events", {
filePath: response.bodyPath, responseId: response.id,
}); });
if (events.length > 0) { if (events.length > 0) {
return events; return events;
@@ -39,8 +44,9 @@ export async function getResponseBodyEventSource(
// Fall back to raw JSON frame parsing for non-standard SSE-like responses. // Fall back to raw JSON frame parsing for non-standard SSE-like responses.
} }
const bytes = await platform.files.readFile(response.bodyPath); const text = await getResponseBodyDecoded(response);
const text = new TextDecoder("utf-8").decode(bytes); if (text == null) return [];
return candidateJsonPayloadsFromSseText(text).map((data, index) => ({ return candidateJsonPayloadsFromSseText(text).map((data, index) => ({
data, data,
eventType: "", eventType: "",
@@ -53,16 +59,20 @@ export async function getResponseBodySseSummary(
response: HttpResponse, response: HttpResponse,
resultKeyPath: string, resultKeyPath: string,
): Promise<SseSummary> { ): Promise<SseSummary> {
if (!response.bodyPath) return { fragmentCount: 0, summary: "" }; const text = await getResponseBodyDecoded(response);
if (text == null) return { fragmentCount: 0, summary: "" };
const bytes = await platform.files.readFile(response.bodyPath);
const text = new TextDecoder("utf-8").decode(bytes);
return computeSseSummary(text, resultKeyPath); return computeSseSummary(text, resultKeyPath);
} }
export async function getResponseBodyBytes( export async function getResponseBodyBytes(
response: HttpResponse, response: HttpResponse,
): Promise<Uint8Array<ArrayBuffer> | null> { ): Promise<Uint8Array<ArrayBuffer> | null> {
if (!response.bodyPath) return null; // A response body is stored under the response's own id
return platform.files.readFile(response.bodyPath); return platform.blobs.read(response.id);
}
async function getResponseBodyDecoded(response: HttpResponse): Promise<string | null> {
const bytes = await getResponseBodyBytes(response);
return bytes == null ? null : new TextDecoder("utf-8").decode(bytes);
} }
+3 -2
View File
@@ -1,11 +1,12 @@
import type { HttpRequest, HttpResponse } from "@yaakapp-internal/models"; import type { HttpRequest } from "@yaakapp-internal/models";
import type { EphemeralHttpResponse } from "@yaakapp-internal/tauri-client";
import { getActiveCookieJar } from "../hooks/useActiveCookieJar"; import { getActiveCookieJar } from "../hooks/useActiveCookieJar";
import { rpc } from "./rpc"; import { rpc } from "./rpc";
export async function sendEphemeralRequest( export async function sendEphemeralRequest(
request: HttpRequest, request: HttpRequest,
environmentId: string | null, environmentId: string | null,
): Promise<HttpResponse> { ): Promise<EphemeralHttpResponse> {
// Remove some things that we don't want to associate // Remove some things that we don't want to associate
const newRequest = { ...request }; const newRequest = { ...request };
return rpc("cmd_send_ephemeral_request", { return rpc("cmd_send_ephemeral_request", {
File diff suppressed because one or more lines are too long
@@ -8,7 +8,7 @@ use std::sync::Arc;
use std::time::Instant; use std::time::Instant;
use tauri::{AppHandle, Manager, Runtime, WebviewWindow}; use tauri::{AppHandle, Manager, Runtime, WebviewWindow};
use tokio::sync::watch::Receiver; use tokio::sync::watch::Receiver;
use yaak::send::{SendHttpRequestWithPluginsParams, send_http_request_with_plugins}; use yaak::send::{ResponseBody, SendHttpRequestWithPluginsParams, send_http_request_with_plugins};
use yaak_crypto::manager::EncryptionManager; use yaak_crypto::manager::EncryptionManager;
use yaak_http::manager::HttpConnectionManager; use yaak_http::manager::HttpConnectionManager;
use yaak_models::models::{CookieJar, Environment, HttpRequest, HttpResponse, HttpResponseState}; use yaak_models::models::{CookieJar, Environment, HttpRequest, HttpResponse, HttpResponseState};
@@ -62,6 +62,12 @@ impl<R: Runtime> ResponseContext<R> {
} }
} }
/// What a send produced: the response, and where its body went.
pub struct SentHttpRequest {
pub response: HttpResponse,
pub body: ResponseBody,
}
pub async fn send_http_request<R: Runtime>( pub async fn send_http_request<R: Runtime>(
window: &WebviewWindow<R>, window: &WebviewWindow<R>,
unrendered_request: &HttpRequest, unrendered_request: &HttpRequest,
@@ -69,7 +75,7 @@ pub async fn send_http_request<R: Runtime>(
environment: Option<Environment>, environment: Option<Environment>,
cookie_jar: Option<CookieJar>, cookie_jar: Option<CookieJar>,
cancelled_rx: &mut Receiver<bool>, cancelled_rx: &mut Receiver<bool>,
) -> Result<HttpResponse> { ) -> Result<SentHttpRequest> {
send_http_request_with_context( send_http_request_with_context(
window, window,
unrendered_request, unrendered_request,
@@ -90,7 +96,7 @@ pub async fn send_http_request_with_context<R: Runtime>(
cookie_jar: Option<CookieJar>, cookie_jar: Option<CookieJar>,
cancelled_rx: &Receiver<bool>, cancelled_rx: &Receiver<bool>,
plugin_context: &PluginContext, plugin_context: &PluginContext,
) -> Result<HttpResponse> { ) -> Result<SentHttpRequest> {
let app_handle = window.app_handle().clone(); let app_handle = window.app_handle().clone();
let update_source = UpdateSource::from_window_label(window.label()); let update_source = UpdateSource::from_window_label(window.label());
let mut response_ctx = let mut response_ctx =
@@ -110,7 +116,7 @@ pub async fn send_http_request_with_context<R: Runtime>(
.await; .await;
match result { match result {
Ok(response) => Ok(response), Ok(sent) => Ok(sent),
Err(e) => { Err(e) => {
let error = e.to_string(); let error = e.to_string();
let elapsed = start.elapsed().as_millis() as i32; let elapsed = start.elapsed().as_millis() as i32;
@@ -123,7 +129,12 @@ pub async fn send_http_request_with_context<R: Runtime>(
} }
r.error = Some(error); r.error = Some(error);
}); });
Ok(response_ctx.response().clone()) // The send failed, so whatever body exists is the partial one
// already on disk under the response's id.
Ok(SentHttpRequest {
response: response_ctx.response().clone(),
body: ResponseBody::Stored,
})
} }
} }
} }
@@ -136,7 +147,7 @@ async fn send_http_request_inner<R: Runtime>(
cancelled_rx: &Receiver<bool>, cancelled_rx: &Receiver<bool>,
plugin_context: &PluginContext, plugin_context: &PluginContext,
response_ctx: &mut ResponseContext<R>, response_ctx: &mut ResponseContext<R>,
) -> Result<HttpResponse> { ) -> Result<SentHttpRequest> {
let app_handle = window.app_handle().clone(); let app_handle = window.app_handle().clone();
let plugin_manager = Arc::new((*app_handle.state::<PluginManager>()).clone()); let plugin_manager = Arc::new((*app_handle.state::<PluginManager>()).clone());
let encryption_manager = Arc::new((*app_handle.state::<EncryptionManager>()).clone()); let encryption_manager = Arc::new((*app_handle.state::<EncryptionManager>()).clone());
@@ -165,7 +176,7 @@ async fn send_http_request_inner<R: Runtime>(
.await .await
.map_err(|e| GenericError(e.to_string()))?; .map_err(|e| GenericError(e.to_string()))?;
Ok(result.response) Ok(SentHttpRequest { response: result.response, body: result.response_body })
} }
pub fn resolve_http_request<R: Runtime>( pub fn resolve_http_request<R: Runtime>(
+80 -19
View File
@@ -8,6 +8,7 @@ use crate::import::import_data;
use crate::models_ext::{BlobManagerExt, QueryManagerExt}; use crate::models_ext::{BlobManagerExt, QueryManagerExt};
use crate::notifications::YaakNotifier; use crate::notifications::YaakNotifier;
use crate::render::{render_grpc_request, render_json_value, render_template}; use crate::render::{render_grpc_request, render_json_value, render_template};
use crate::rpc_ext::EphemeralHttpResponse;
use crate::updates::{UpdateMode, UpdateTrigger, YaakUpdater}; use crate::updates::{UpdateMode, UpdateTrigger, YaakUpdater};
use crate::uri_scheme::handle_deep_link; use crate::uri_scheme::handle_deep_link;
use error::Result as YaakResult; use error::Result as YaakResult;
@@ -30,6 +31,7 @@ use tokio::sync::Mutex;
use tokio::task::block_in_place; use tokio::task::block_in_place;
use tokio::time; use tokio::time;
use yaak::export::{self, ExportDataParams}; use yaak::export::{self, ExportDataParams};
use yaak::send::ResponseBody;
use yaak_common::command::new_checked_command; use yaak_common::command::new_checked_command;
use yaak_crypto::manager::EncryptionManager; use yaak_crypto::manager::EncryptionManager;
use yaak_grpc::manager::{GrpcConfig, GrpcHandle}; use yaak_grpc::manager::{GrpcConfig, GrpcHandle};
@@ -981,13 +983,18 @@ async fn cmd_restart<R: Runtime>(app_handle: AppHandle<R>) -> YaakResult<()> {
Ok(()) Ok(())
} }
/// Send without saving anything.
///
/// The response never reaches the database, so its body cannot be read back by
/// id later the way a saved response's can. It comes back here instead, which
/// is the only copy the caller gets.
async fn cmd_send_ephemeral_request<R: Runtime>( async fn cmd_send_ephemeral_request<R: Runtime>(
mut request: HttpRequest, mut request: HttpRequest,
environment_id: Option<&str>, environment_id: Option<&str>,
cookie_jar_id: Option<&str>, cookie_jar_id: Option<&str>,
window: WebviewWindow<R>, window: WebviewWindow<R>,
app_handle: AppHandle<R>, app_handle: AppHandle<R>,
) -> YaakResult<HttpResponse> { ) -> YaakResult<EphemeralHttpResponse> {
let response = HttpResponse::default(); let response = HttpResponse::default();
request.id = "".to_string(); request.id = "".to_string();
let environment = match environment_id { let environment = match environment_id {
@@ -1006,7 +1013,18 @@ async fn cmd_send_ephemeral_request<R: Runtime>(
} }
}); });
send_http_request(&window, &request, &response, environment, cookie_jar, &mut cancel_rx).await let sent =
send_http_request(&window, &request, &response, environment, cookie_jar, &mut cancel_rx)
.await?;
// Blanking the request id above is what makes this send unsaved, so the
// engine always hands the body back. Failing loudly beats returning an
// empty body that reads as "the server sent nothing".
let ResponseBody::Returned(body) = sent.body else {
return Err(GenericError("Unsaved response did not return a body".to_string()));
};
Ok(EphemeralHttpResponse { response: sent.response, body })
} }
async fn cmd_format_json(text: &str) -> YaakResult<String> { async fn cmd_format_json(text: &str) -> YaakResult<String> {
@@ -1020,27 +1038,49 @@ async fn cmd_format_graphql(text: &str) -> YaakResult<String> {
} }
} }
/// Where a response's body is, and what it is meant to be read as.
struct ResponseBodyLocation {
/// None when the response has no stored body.
path: Option<PathBuf>,
/// The response's declared `Content-Type`, empty when it has none.
content_type: String,
}
/// Find a response's body from its id alone.
///
/// The frontend hands back an id and never a path, so the only bodies reachable
/// here are ones the engine wrote and the database still knows about. A
/// response that was never saved has no entry, and its body came back from the
/// send that made it.
fn locate_response_body<R: Runtime>(
app_handle: &AppHandle<R>,
response_id: &str,
) -> YaakResult<ResponseBodyLocation> {
let response = app_handle.db().get_http_response(response_id)?;
Ok(ResponseBodyLocation {
path: response.body_path.map(PathBuf::from),
content_type: response
.headers
.iter()
.find(|h| h.name.eq_ignore_ascii_case("content-type"))
.map(|h| h.value.clone())
.unwrap_or_default(),
})
}
async fn cmd_http_response_body<R: Runtime>( async fn cmd_http_response_body<R: Runtime>(
window: WebviewWindow<R>, window: WebviewWindow<R>,
plugin_manager: State<'_, PluginManager>, plugin_manager: State<'_, PluginManager>,
response: HttpResponse, response_id: &str,
filter: Option<&str>, filter: Option<&str>,
) -> YaakResult<FilterResponse> { ) -> YaakResult<FilterResponse> {
let body_path = match response.body_path { let location = locate_response_body(window.app_handle(), response_id)?;
None => { let Some(body_path) = location.path else {
return Ok(FilterResponse { content: String::new(), error: None }); return Ok(FilterResponse { content: String::new(), error: None });
}
Some(p) => p,
}; };
let content_type = response let content_type = location.content_type.as_str();
.headers
.iter()
.find_map(|h| {
if h.name.eq_ignore_ascii_case("content-type") { Some(h.value.as_str()) } else { None }
})
.unwrap_or_default();
let body = read_response_body(&body_path, content_type) let body = read_response_body(&body_path, content_type)
.await .await
.ok_or(GenericError("Failed to find response body".to_string()))?; .ok_or(GenericError("Failed to find response body".to_string()))?;
@@ -1053,6 +1093,20 @@ async fn cmd_http_response_body<R: Runtime>(
} }
} }
/// The body's path on this machine, for the desktop host to read or hand to the
/// webview's asset protocol.
///
/// The frontend holds response ids; only `packages/platform`'s Tauri host sees
/// the path, and only because it is about to open the file itself. Hosts
/// without a filesystem serve the same bytes over HTTP instead.
async fn cmd_http_response_body_path<R: Runtime>(
app_handle: AppHandle<R>,
response_id: &str,
) -> YaakResult<Option<String>> {
let location = locate_response_body(&app_handle, response_id)?;
Ok(location.path.map(|p| p.to_string_lossy().to_string()))
}
async fn cmd_http_request_body<R: Runtime>( async fn cmd_http_request_body<R: Runtime>(
app_handle: AppHandle<R>, app_handle: AppHandle<R>,
response_id: &str, response_id: &str,
@@ -1069,8 +1123,15 @@ async fn cmd_http_request_body<R: Runtime>(
Ok(Some(body)) Ok(Some(body))
} }
async fn cmd_get_sse_events(file_path: &str) -> YaakResult<Vec<ServerSentEvent>> { async fn cmd_get_sse_events<R: Runtime>(
let body = fs::read(file_path)?; app_handle: AppHandle<R>,
response_id: &str,
) -> YaakResult<Vec<ServerSentEvent>> {
let Some(body_path) = locate_response_body(&app_handle, response_id)?.path else {
return Ok(Vec::new());
};
let body = fs::read(body_path)?;
let mut event_parser = EventParser::new(); let mut event_parser = EventParser::new();
event_parser.process_bytes(body.into())?; event_parser.process_bytes(body.into())?;
@@ -1488,7 +1549,7 @@ async fn cmd_send_http_request<R: Runtime>(
) )
.await .await
{ {
Ok(r) => r, Ok(sent) => sent.response,
Err(e) => { Err(e) => {
let resp = app_handle.db().get_http_response(&response.id)?; let resp = app_handle.db().get_http_response(&response.id)?;
app_handle.db().upsert_http_response( app_handle.db().upsert_http_response(
@@ -314,7 +314,7 @@ async fn handle_host_plugin_request<R: Runtime>(
.await?; .await?;
Ok(Some(InternalEventPayload::SendHttpRequestResponse(SendHttpRequestResponse { Ok(Some(InternalEventPayload::SendHttpRequestResponse(SendHttpRequestResponse {
http_response, http_response: http_response.response,
}))) })))
} }
HostRequest::OpenWindow(req) => { HostRequest::OpenWindow(req) => {
+32 -7
View File
@@ -250,6 +250,19 @@ pub(crate) struct CmdSendEphemeralRequestReq {
pub cookie_jar_id: Option<String>, pub cookie_jar_id: Option<String>,
} }
/// An unsaved response and its body.
///
/// The body rides along because nothing stored it: there is no database row to
/// look up later and no file for a host to read, so this is the caller's only
/// copy.
#[derive(Debug, Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "gen_rpc.ts")]
pub struct EphemeralHttpResponse {
pub response: HttpResponse,
pub body: Vec<u8>,
}
#[derive(Debug, Deserialize, TS)] #[derive(Debug, Deserialize, TS)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
#[ts(export, export_to = "gen_rpc.ts")] #[ts(export, export_to = "gen_rpc.ts")]
@@ -268,10 +281,17 @@ pub(crate) struct CmdFormatGraphqlReq {
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
#[ts(export, export_to = "gen_rpc.ts")] #[ts(export, export_to = "gen_rpc.ts")]
pub(crate) struct CmdHttpResponseBodyReq { pub(crate) struct CmdHttpResponseBodyReq {
pub response: HttpResponse, pub response_id: String,
pub filter: Option<String>, pub filter: Option<String>,
} }
#[derive(Debug, Deserialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "gen_rpc.ts")]
pub(crate) struct CmdHttpResponseBodyPathReq {
pub response_id: String,
}
#[derive(Debug, Deserialize, TS)] #[derive(Debug, Deserialize, TS)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
#[ts(export, export_to = "gen_rpc.ts")] #[ts(export, export_to = "gen_rpc.ts")]
@@ -283,7 +303,7 @@ pub(crate) struct CmdHttpRequestBodyReq {
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
#[ts(export, export_to = "gen_rpc.ts")] #[ts(export, export_to = "gen_rpc.ts")]
pub(crate) struct CmdGetSseEventsReq { pub(crate) struct CmdGetSseEventsReq {
pub file_path: String, pub response_id: String,
} }
#[derive(Debug, Deserialize, TS)] #[derive(Debug, Deserialize, TS)]
@@ -970,7 +990,7 @@ async fn cmd_restart<R: Runtime>(ctx: ClientCtx<R>, _req: CmdRestartReq) -> Resu
Ok(crate::cmd_restart(ctx.window.app_handle().clone()).await?) Ok(crate::cmd_restart(ctx.window.app_handle().clone()).await?)
} }
async fn cmd_send_ephemeral_request<R: Runtime>(ctx: ClientCtx<R>, req: CmdSendEphemeralRequestReq) -> Result<HttpResponse> { async fn cmd_send_ephemeral_request<R: Runtime>(ctx: ClientCtx<R>, req: CmdSendEphemeralRequestReq) -> Result<EphemeralHttpResponse> {
Ok(crate::cmd_send_ephemeral_request(req.request, req.environment_id.as_deref(), req.cookie_jar_id.as_deref(), ctx.window.clone(), ctx.window.app_handle().clone()).await?) Ok(crate::cmd_send_ephemeral_request(req.request, req.environment_id.as_deref(), req.cookie_jar_id.as_deref(), ctx.window.clone(), ctx.window.app_handle().clone()).await?)
} }
@@ -983,15 +1003,19 @@ async fn cmd_format_graphql<R: Runtime>(_ctx: ClientCtx<R>, req: CmdFormatGraphq
} }
async fn cmd_http_response_body<R: Runtime>(ctx: ClientCtx<R>, req: CmdHttpResponseBodyReq) -> Result<FilterResponse> { async fn cmd_http_response_body<R: Runtime>(ctx: ClientCtx<R>, req: CmdHttpResponseBodyReq) -> Result<FilterResponse> {
Ok(crate::cmd_http_response_body(ctx.window.clone(), ctx.window.app_handle().state::<PluginManager>(), req.response, req.filter.as_deref()).await?) Ok(crate::cmd_http_response_body(ctx.window.clone(), ctx.window.app_handle().state::<PluginManager>(), &req.response_id, req.filter.as_deref()).await?)
}
async fn cmd_http_response_body_path<R: Runtime>(ctx: ClientCtx<R>, req: CmdHttpResponseBodyPathReq) -> Result<Option<String>> {
Ok(crate::cmd_http_response_body_path(ctx.window.app_handle().clone(), &req.response_id).await?)
} }
async fn cmd_http_request_body<R: Runtime>(ctx: ClientCtx<R>, req: CmdHttpRequestBodyReq) -> Result<Option<Vec<u8>>> { async fn cmd_http_request_body<R: Runtime>(ctx: ClientCtx<R>, req: CmdHttpRequestBodyReq) -> Result<Option<Vec<u8>>> {
Ok(crate::cmd_http_request_body(ctx.window.app_handle().clone(), &req.response_id).await?) Ok(crate::cmd_http_request_body(ctx.window.app_handle().clone(), &req.response_id).await?)
} }
async fn cmd_get_sse_events<R: Runtime>(_ctx: ClientCtx<R>, req: CmdGetSseEventsReq) -> Result<Vec<ServerSentEvent>> { async fn cmd_get_sse_events<R: Runtime>(ctx: ClientCtx<R>, req: CmdGetSseEventsReq) -> Result<Vec<ServerSentEvent>> {
Ok(crate::cmd_get_sse_events(&req.file_path).await?) Ok(crate::cmd_get_sse_events(ctx.window.app_handle().clone(), &req.response_id).await?)
} }
async fn cmd_get_http_response_events<R: Runtime>(ctx: ClientCtx<R>, req: CmdGetHttpResponseEventsReq) -> Result<Vec<HttpResponseEvent>> { async fn cmd_get_http_response_events<R: Runtime>(ctx: ClientCtx<R>, req: CmdGetHttpResponseEventsReq) -> Result<Vec<HttpResponseEvent>> {
@@ -1367,10 +1391,11 @@ rpc_commands! {
cmd_grpc_reflect(CmdGrpcReflectReq) -> Vec<ServiceDefinition>, cmd_grpc_reflect(CmdGrpcReflectReq) -> Vec<ServiceDefinition>,
cmd_grpc_go(CmdGrpcGoReq) -> String, cmd_grpc_go(CmdGrpcGoReq) -> String,
cmd_restart(CmdRestartReq) -> (), cmd_restart(CmdRestartReq) -> (),
cmd_send_ephemeral_request(CmdSendEphemeralRequestReq) -> HttpResponse, cmd_send_ephemeral_request(CmdSendEphemeralRequestReq) -> EphemeralHttpResponse,
cmd_format_json(CmdFormatJsonReq) -> String, cmd_format_json(CmdFormatJsonReq) -> String,
cmd_format_graphql(CmdFormatGraphqlReq) -> String, cmd_format_graphql(CmdFormatGraphqlReq) -> String,
cmd_http_response_body(CmdHttpResponseBodyReq) -> FilterResponse, cmd_http_response_body(CmdHttpResponseBodyReq) -> FilterResponse,
cmd_http_response_body_path(CmdHttpResponseBodyPathReq) -> Option<String>,
cmd_http_request_body(CmdHttpRequestBodyReq) -> Option<Vec<u8>>, cmd_http_request_body(CmdHttpRequestBodyReq) -> Option<Vec<u8>>,
cmd_get_sse_events(CmdGetSseEventsReq) -> Vec<ServerSentEvent>, cmd_get_sse_events(CmdGetSseEventsReq) -> Vec<ServerSentEvent>,
cmd_get_http_response_events(CmdGetHttpResponseEventsReq) -> Vec<HttpResponseEvent>, cmd_get_http_response_events(CmdGetHttpResponseEventsReq) -> Vec<HttpResponseEvent>,
+76 -6
View File
@@ -339,11 +339,28 @@ pub struct SendHttpRequestByIdWithPluginsParams<'a> {
pub connection_manager: &'a HttpConnectionManager, pub connection_manager: &'a HttpConnectionManager,
} }
/// Where a send left the response body, so the caller knows where to get it.
///
/// The body goes to exactly one place, and which one depends on what the caller
/// asked for. Saying so outright beats handing back a `Vec` that is empty for
/// two entirely different reasons.
pub enum ResponseBody {
/// Written to the response's own file. Read it back by response id.
Stored,
/// Sent to the chunk sender the caller supplied, as it arrived.
Streamed,
/// Here it is, because nothing else kept it. Empty means the response
/// really had no body.
Returned(Vec<u8>),
}
pub struct SendHttpRequestResult { pub struct SendHttpRequestResult {
pub rendered_request: HttpRequest, pub rendered_request: HttpRequest,
pub response: HttpResponse, pub response: HttpResponse,
pub response_body: Vec<u8>, pub response_body: ResponseBody,
/// The cookies held by the jar after the send, for callers that persist one. /// The cookies held by the jar after the send, for callers that persist
/// one. `None` when the caller supplied no jar, which is independent of
/// where the body went.
pub cookies: Option<Vec<Cookie>>, pub cookies: Option<Vec<Cookie>>,
} }
@@ -798,9 +815,16 @@ pub async fn send_http_request<T: TemplateCallback>(
}; };
let mut body_stream = let mut body_stream =
http_response.into_body_stream().map_err(SendHttpRequestError::ReadResponseBody)?; http_response.into_body_stream().map_err(SendHttpRequestError::ReadResponseBody)?;
let mut response_body = Vec::new();
let mut read_buf = vec![0; 64 * 1024]; let mut read_buf = vec![0; 64 * 1024];
let collect_response_body = !persist_response && params.emit_response_body_chunks_to.is_none(); // Decided once, before the first chunk: the accumulator only exists in the
// one arm that returns it, so nothing can hand back bytes it never received.
let mut response_body = if params.emit_response_body_chunks_to.is_some() {
ResponseBody::Streamed
} else if persist_response {
ResponseBody::Stored
} else {
ResponseBody::Returned(Vec::new())
};
let mut body_read_error = None; let mut body_read_error = None;
let mut written_bytes: usize = 0; let mut written_bytes: usize = 0;
let mut last_progress_update = started_at; let mut last_progress_update = started_at;
@@ -841,8 +865,8 @@ pub async fn send_http_request<T: TemplateCallback>(
} }
if let Some(tx) = params.emit_response_body_chunks_to.as_ref() { if let Some(tx) = params.emit_response_body_chunks_to.as_ref() {
let _ = tx.send(chunk.to_vec()); let _ = tx.send(chunk.to_vec());
} else if collect_response_body { } else if let ResponseBody::Returned(body) = &mut response_body {
response_body.extend_from_slice(chunk); body.extend_from_slice(chunk);
} }
let now = Instant::now(); let now = Instant::now();
@@ -1384,6 +1408,52 @@ mod tests {
); );
} }
/// A response nothing stores has to hand its body back, because no later
/// read can find it: there is no row to look up and no id to read it by.
/// GraphQL introspection is the caller that depends on this.
#[tokio::test]
async fn returns_the_body_when_nothing_stores_it() {
let executor = StubExecutor { body: b"hello world" };
let result = send_http_request(SendHttpRequestParams {
inputs: HttpSendInputs {
request: ResolvedHttpRequest::assume_resolved(
HttpRequest {
workspace_id: "wk_test".to_string(),
url: "http://localhost/test".to_string(),
..Default::default()
},
String::new(),
),
environment_chain: Vec::new(),
runtime_config: HttpSendRuntimeConfig {
settings: ResolvedHttpRequestSettings::default(),
proxy: HttpConnectionProxySetting::System,
dns_overrides: Vec::new(),
client_certificates: Vec::new(),
},
cookie_store: Some(CookieStore::new()),
},
template_callback: &NoopTemplateCallback,
storage: None,
emit_events_to: None,
// No chunk sender: the body is collected for the caller instead.
emit_response_body_chunks_to: None,
cancelled_rx: None,
existing_response: None,
prepare_sendable_request: None,
executor: &executor,
})
.await
.expect("send should succeed without a database");
let ResponseBody::Returned(body) = result.response_body else {
panic!("a response nothing stores has to hand its body back");
};
assert_eq!(body, b"hello world");
assert!(result.response.request_id.is_empty(), "an unsaved response has no request");
}
fn seed_cookie_jar() -> (QueryManager, CookieJar, TempDir) { fn seed_cookie_jar() -> (QueryManager, CookieJar, TempDir) {
let temp_dir = TempDir::new().expect("Failed to create temp dir"); let temp_dir = TempDir::new().expect("Failed to create temp dir");
let (query_manager, _blob_manager, _rx) = yaak_models::init_standalone( let (query_manager, _blob_manager, _rx) = yaak_models::init_standalone(
+3
View File
@@ -49,6 +49,9 @@ export const platform: Platform = {
get files() { get files() {
return host().files; return host().files;
}, },
get blobs() {
return host().blobs;
},
rpc: (cmd, payload) => host().rpc(cmd, payload), rpc: (cmd, payload) => host().rpc(cmd, payload),
rpcStream: (cmd, payload, onMessage) => host().rpcStream(cmd, payload, onMessage), rpcStream: (cmd, payload, onMessage) => host().rpcStream(cmd, payload, onMessage),
listen: (event, callback) => host().listen(event, callback), listen: (event, callback) => host().listen(event, callback),
+24 -1
View File
@@ -104,6 +104,18 @@ async function rpc<T>(cmd: string, payload?: RpcPayload): Promise<T> {
} }
} }
/**
* Where this host keeps the bytes stored under an id, or null if it has none.
*
* The desktop stores them as files the engine wrote, so answering means asking
* the backend. Callers hold ids and nothing else; the path exists for exactly
* as long as it takes to open the file, which is the one thing a desktop host
* can do that a tab cannot.
*/
function storedBodyPath(id: string): Promise<string | null> {
return rpc<string | null>("cmd_http_response_body_path", { responseId: id });
}
export function createTauriPlatform(): Platform { export function createTauriPlatform(): Platform {
const window = createWindow(); const window = createWindow();
@@ -124,13 +136,24 @@ export function createTauriPlatform(): Platform {
}, },
files: { files: {
readFile: (path) => readFile(path),
readDir: (path) => readDir(path), readDir: (path) => readDir(path),
url: (path) => convertFileSrc(path), url: (path) => convertFileSrc(path),
basename: (path) => basename(path), basename: (path) => basename(path),
resolveResource: (path) => resolveResource(path), resolveResource: (path) => resolveResource(path),
}, },
blobs: {
async read(id) {
const path = await storedBodyPath(id);
return path == null ? null : readFile(path);
},
async url(id) {
const path = await storedBodyPath(id);
return path == null ? null : convertFileSrc(path);
},
},
rpc, rpc,
async rpcStream<T, M>( async rpcStream<T, M>(
+27 -2
View File
@@ -135,11 +135,10 @@ export interface PlatformDialog {
* File-ish operations, keyed by paths the backend handed us. * File-ish operations, keyed by paths the backend handed us.
* *
* A path here is an opaque handle, not something to parse or construct: the UI * A path here is an opaque handle, not something to parse or construct: the UI
* only ever passes one back to `readFile` or `url`. A host without a filesystem * only ever passes one back to `url` or `readDir`. A host without a filesystem
* can mint handles of its own (a blob id, a URL) and stay compatible. * can mint handles of its own (a blob id, a URL) and stay compatible.
*/ */
export interface PlatformFiles { export interface PlatformFiles {
readFile(path: string): Promise<Uint8Array<ArrayBuffer>>;
readDir(path: string): Promise<DirEntry[]>; readDir(path: string): Promise<DirEntry[]>;
/** A URL the page can load a file from, for `<img>`, `<video>`, and friends. */ /** A URL the page can load a file from, for `<img>`, `<video>`, and friends. */
@@ -151,12 +150,38 @@ export interface PlatformFiles {
resolveResource(path: string): Promise<string>; resolveResource(path: string): Promise<string>;
} }
/**
* Content the backend stored, addressed by the id it stored it under.
*
* Where those bytes actually live is the one thing every host answers
* differently — a file the engine wrote, a row in a database, a URL on the
* other end of a socket — so finding them is the host's job and nobody else's.
* The caller passes an id and never a location, which is also what keeps a page
* from naming something the backend never wrote.
*
* What the content *means* is the app's business, not this package's.
*/
export interface PlatformBlobs {
/** The bytes, or null if the host has nothing stored under that id. */
read(id: string): Promise<Uint8Array<ArrayBuffer> | null>;
/**
* A URL an element can load the content from, for `<img>`, `<video>` and
* friends, or null if the host has nothing stored under that id.
*
* Asynchronous because the host may have to ask its backend where the bytes
* are, and it only does that the moment before it reads them.
*/
url(id: string): Promise<string | null>;
}
export interface Platform { export interface Platform {
readonly capabilities: PlatformCapabilities; readonly capabilities: PlatformCapabilities;
readonly window: PlatformWindow; readonly window: PlatformWindow;
readonly clipboard: PlatformClipboard; readonly clipboard: PlatformClipboard;
readonly dialog: PlatformDialog; readonly dialog: PlatformDialog;
readonly files: PlatformFiles; readonly files: PlatformFiles;
readonly blobs: PlatformBlobs;
/** Call a backend command and await its result. */ /** Call a backend command and await its result. */
rpc<T>(cmd: string, payload?: RpcPayload): Promise<T>; rpc<T>(cmd: string, payload?: RpcPayload): Promise<T>;