Compare commits

...
Author SHA1 Message Date
Gregory Schier 0f8413c441 Read OAuth 2.0 token responses from the send instead of the filesystem
Both token requests opened HttpResponse.bodyPath with readFileSync, which
ties the plugin to bodies living on a filesystem. The send now hands the body
back, so they read it from there and keep working once bodies move into the
blob DB and in the browser Worker, where there is no fs to read.

text() on a response with no body returns "", which is what the bodyPath
check produced, so an empty response still parses to {} rather than throwing.
2026-08-16 10:09:43 -07:00
Gregory Schier 0e14625e62 Hand the body back from send instead of holding it for a lookup
Holding an unsaved body against its response id let a plugin stash the id
and read it in a later call, which would throw only sometimes and only
for ad-hoc sends. Documenting that was never going to be enough.

send now returns the response and its body together, so there is nothing
to stash: an unsaved body is a value you were handed. ctx.httpResponse
.body() goes back to meaning one thing, a saved response read by id, and
refuses ids it has no row for. Reading is identical either way, so no
caller has to know which kind of send it made.
2026-08-16 10:07:44 -07:00
Gregory Schier 8c72538102 Say on send() that an unsaved response's body is call-scoped 2026-08-16 10:00:35 -07:00
Gregory Schier ffa6a610b8 Return the body of a send that saved nothing
Replaces the response-directory fallback with what the frontend already
does for ephemeral sends: the engine hands the body back, because it is
the only copy. Guessing at a file named for an id was the store reaching
around its own abstraction, and it is exactly what must not survive the
move to blob storage.

The send reply carries the bytes when the engine returned them, and the
runtime holds them for the rest of the call that sent them, so
ctx.httpResponse.body() answers for a saved and an unsaved response the
same way. Unsaved ones now report a real contentType too, taken from the
response the send already handed back.
2026-08-16 09:54:56 -07:00
Gregory Schier 8b031db685 Read bodies of responses the engine never recorded
A send with no request behind it — a plugin's ad-hoc ctx.httpRequest.send,
GraphQL introspection — gets a generated id and a body file, but no row.
Resolving purely through the database refused those, which would have
broken auth-oauth2 the moment it moved off readFileSync, since every
request it sends is ad-hoc.

The store now falls back to the response directory when there is no row,
accepting only ids shaped the way the engine generates them. Such a
response has no stored headers, so contentType is null and text()
decodes as UTF-8 — which is what the filesystem readers did anyway.
2026-08-16 09:35:03 -07:00
Gregory Schier 96c8a95094 Add a plugin API for reading HTTP response bodies
Plugins read bodies by response id through ctx.httpResponse.body()
instead of opening HttpResponse.bodyPath themselves. The accessors are
named after fetch's, minus the single-use semantics, since the bytes are
durable and re-reading should work.

Underneath is a chunked pull over the existing plugin protocol, so the
host can move bodies off the filesystem without plugins noticing.
text() now decodes with the response's charset rather than assuming
UTF-8, and the buffering accessors refuse past 32 MiB and point at
chunks().
2026-08-16 09:20:29 -07:00
23 changed files with 1051 additions and 63 deletions
Generated
+1
View File
@@ -11196,6 +11196,7 @@ name = "yaak"
version = "0.1.0"
dependencies = [
"async-trait",
"base64 0.22.1",
"log 0.4.29",
"md5 0.8.0",
"serde_json",
+13 -1
View File
@@ -1,5 +1,7 @@
use crate::context::CliExecutionContext;
use arboard::Clipboard;
use base64::Engine;
use base64::prelude::BASE64_STANDARD;
use console::Term;
use inquire::{Confirm, Editor, Password, PasswordDisplayMode, Select, Text};
use serde_json::Value;
@@ -12,6 +14,7 @@ use yaak::plugin_events::{
GroupedPluginEvent, HostRequest, SharedPluginEventContext, handle_shared_plugin_event,
};
use yaak::render::{render_grpc_request, render_http_request};
use yaak::response_body::FileResponseBodyStore;
use yaak::send::{SendHttpRequestWithPluginsParams, send_http_request_with_plugins};
use yaak_crypto::manager::EncryptionManager;
use yaak_http::cookies::get_cookie_value_from_jar;
@@ -131,6 +134,7 @@ async fn build_plugin_reply(
match handle_shared_plugin_event(
&host_context.query_manager,
&FileResponseBodyStore::new(&host_context.query_manager),
&event.payload,
SharedPluginEventContext { plugin_name, workspace_id: shared_workspace_id },
) {
@@ -223,7 +227,15 @@ async fn build_plugin_reply(
.await
{
Ok(result) => Some(InternalEventPayload::SendHttpRequestResponse(
SendHttpRequestResponse { http_response: result.response },
SendHttpRequestResponse {
http_response: result.response,
// Nothing saved this body, so the reply is the only
// place the plugin can get it.
body: result
.response_body
.returned_bytes()
.map(|b| BASE64_STANDARD.encode(b)),
},
)),
Err(err) => Some(InternalEventPayload::ErrorResponse(ErrorResponse {
error: format!("Failed to send HTTP request in CLI: {err}"),
@@ -7,6 +7,8 @@ use crate::{
call_frontend, cookie_jar_from_window, environment_from_window, get_window_from_plugin_context,
workspace_from_window,
};
use base64::Engine;
use base64::prelude::BASE64_STANDARD;
use chrono::Utc;
use log::error;
use std::sync::Arc;
@@ -16,6 +18,7 @@ use tauri_plugin_opener::OpenerExt;
use yaak::plugin_events::{
GroupedPluginEvent, HostRequest, SharedPluginEventContext, handle_shared_plugin_event,
};
use yaak::response_body::FileResponseBodyStore;
use yaak_crypto::manager::EncryptionManager;
use yaak_http::cookies::get_cookie_value_from_jar;
use yaak_models::models::{HttpResponse, Plugin};
@@ -54,6 +57,7 @@ pub(crate) async fn handle_plugin_event<R: Runtime>(
match handle_shared_plugin_event(
app_handle.db_manager().inner(),
&FileResponseBodyStore::new(app_handle.db_manager().inner()),
&event.payload,
SharedPluginEventContext {
plugin_name: &plugin_name,
@@ -313,8 +317,13 @@ async fn handle_host_plugin_request<R: Runtime>(
)
.await?;
// An ad-hoc request saves nothing, so the engine hands the body
// back and this reply is the only place the plugin can get it.
let body = http_response.body.returned_bytes().map(|b| BASE64_STANDARD.encode(b));
Ok(Some(InternalEventPayload::SendHttpRequestResponse(SendHttpRequestResponse {
http_response: http_response.response,
body,
})))
}
HostRequest::OpenWindow(req) => {
File diff suppressed because one or more lines are too long
+71
View File
@@ -171,6 +171,12 @@ pub enum InternalEventPayload {
FindHttpResponsesRequest(FindHttpResponsesRequest),
FindHttpResponsesResponse(FindHttpResponsesResponse),
GetHttpResponseBodyInfoRequest(GetHttpResponseBodyInfoRequest),
GetHttpResponseBodyInfoResponse(GetHttpResponseBodyInfoResponse),
ReadHttpResponseBodyChunkRequest(ReadHttpResponseBodyChunkRequest),
ReadHttpResponseBodyChunkResponse(ReadHttpResponseBodyChunkResponse),
ListHttpRequestsRequest(ListHttpRequestsRequest),
ListHttpRequestsResponse(ListHttpRequestsResponse),
ListFoldersRequest(ListFoldersRequest),
@@ -288,6 +294,15 @@ pub struct SendHttpRequestRequest {
#[ts(export, export_to = "gen_events.ts")]
pub struct SendHttpRequestResponse {
pub http_response: HttpResponse,
/// The body, base64, when the send saved nothing.
///
/// A request with no id behind it produces a response the model store never
/// sees, so it cannot be read back by id later the way a saved one can.
/// This is the only copy of it. `None` means the body was stored and should
/// be read with `read_http_response_body_chunk_request`.
#[ts(optional = nullable)]
pub body: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]
@@ -1413,6 +1428,62 @@ pub struct FindHttpResponsesResponse {
pub http_responses: Vec<HttpResponse>,
}
/// Ask what a response's body is, before deciding whether to pull it.
///
/// Bodies are addressed by response id and never by path, so where the host
/// keeps the bytes is its own business.
#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]
#[serde(default, rename_all = "camelCase")]
#[ts(export, export_to = "gen_events.ts")]
pub struct GetHttpResponseBodyInfoRequest {
pub response_id: String,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]
#[serde(default, rename_all = "camelCase")]
#[ts(export, export_to = "gen_events.ts")]
pub struct GetHttpResponseBodyInfoResponse {
/// How many bytes are actually stored, which is not necessarily what the
/// `Content-Length` header claimed. Zero when the response has no body.
#[ts(type = "number")]
pub content_length: u64,
/// The response's `Content-Type` header, verbatim, so the reader can pick a
/// charset.
#[ts(optional = nullable)]
pub content_type: Option<String>,
}
/// Pull one window of a response body.
///
/// Reads are idempotent: the bytes live in durable storage, so the same window
/// can be asked for as many times as the plugin likes.
#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]
#[serde(default, rename_all = "camelCase")]
#[ts(export, export_to = "gen_events.ts")]
pub struct ReadHttpResponseBodyChunkRequest {
pub response_id: String,
#[ts(type = "number")]
pub offset: u64,
#[ts(type = "number")]
pub length: u64,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]
#[serde(default, rename_all = "camelCase")]
#[ts(export, export_to = "gen_events.ts")]
pub struct ReadHttpResponseBodyChunkResponse {
/// Base64, because the desktop transport is a WebSocket that only sends
/// text frames today. A host that can carry binary sends the bytes as they
/// are and fills this in from them.
pub data: String,
/// Bytes decoded from `data`. Short of the requested length means the body
/// ended here.
#[ts(type = "number")]
pub length: u64,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]
#[serde(default, rename_all = "camelCase")]
#[ts(export, export_to = "gen_events.ts")]
+1
View File
@@ -6,6 +6,7 @@ publish = false
[dependencies]
async-trait = "0.1"
base64 = "0.22.1" # For carrying body chunks over a text-only plugin transport
log = { workspace = true }
md5 = "0.8.0"
serde_json = { workspace = true }
+1
View File
@@ -3,6 +3,7 @@ pub mod export;
pub mod import;
pub mod plugin_events;
pub mod render;
pub mod response_body;
pub mod send;
pub use error::Error;
+172 -16
View File
@@ -1,3 +1,6 @@
use crate::response_body::ResponseBodyStore;
use base64::Engine;
use base64::prelude::BASE64_STANDARD;
use yaak_models::models::AnyModel;
use yaak_models::query_manager::QueryManager;
use yaak_models::util::UpdateSource;
@@ -5,12 +8,14 @@ use yaak_plugins::events::{
CloseWindowRequest, CopyTextRequest, DeleteKeyValueRequest, DeleteKeyValueResponse,
DeleteModelRequest, DeleteModelResponse, ErrorResponse, FindHttpResponsesRequest,
FindHttpResponsesResponse, GetCookieValueRequest, GetHttpRequestByIdRequest,
GetHttpRequestByIdResponse, GetKeyValueRequest, GetKeyValueResponse, InternalEventPayload,
ListCookieNamesRequest, ListFoldersRequest, ListFoldersResponse, ListHttpRequestsRequest,
ListHttpRequestsResponse, ListOpenWorkspacesRequest, OpenExternalUrlRequest, OpenWindowRequest,
PromptFormRequest, PromptTextRequest, ReloadResponse, RenderGrpcRequestRequest,
RenderHttpRequestRequest, SendHttpRequestRequest, SetKeyValueRequest, ShowToastRequest,
TemplateRenderRequest, UpsertModelRequest, UpsertModelResponse, WindowInfoRequest,
GetHttpRequestByIdResponse, GetHttpResponseBodyInfoRequest, GetHttpResponseBodyInfoResponse,
GetKeyValueRequest, GetKeyValueResponse, InternalEventPayload, ListCookieNamesRequest,
ListFoldersRequest, ListFoldersResponse, ListHttpRequestsRequest, ListHttpRequestsResponse,
ListOpenWorkspacesRequest, OpenExternalUrlRequest, OpenWindowRequest, PromptFormRequest,
PromptTextRequest, ReadHttpResponseBodyChunkRequest, ReadHttpResponseBodyChunkResponse,
ReloadResponse, RenderGrpcRequestRequest, RenderHttpRequestRequest, SendHttpRequestRequest,
SetKeyValueRequest, ShowToastRequest, TemplateRenderRequest, UpsertModelRequest,
UpsertModelResponse, WindowInfoRequest,
};
pub struct SharedPluginEventContext<'a> {
@@ -40,6 +45,8 @@ pub enum SharedRequest<'a> {
ListFolders(&'a ListFoldersRequest),
ListHttpRequests(&'a ListHttpRequestsRequest),
FindHttpResponses(&'a FindHttpResponsesRequest),
GetHttpResponseBodyInfo(&'a GetHttpResponseBodyInfoRequest),
ReadHttpResponseBodyChunk(&'a ReadHttpResponseBodyChunkRequest),
UpsertModel(&'a UpsertModelRequest),
DeleteModel(&'a DeleteModelRequest),
}
@@ -136,6 +143,12 @@ impl<'a> From<&'a InternalEventPayload> for GroupedPluginRequest<'a> {
InternalEventPayload::FindHttpResponsesRequest(req) => {
GroupedPluginRequest::Shared(SharedRequest::FindHttpResponses(req))
}
InternalEventPayload::GetHttpResponseBodyInfoRequest(req) => {
GroupedPluginRequest::Shared(SharedRequest::GetHttpResponseBodyInfo(req))
}
InternalEventPayload::ReadHttpResponseBodyChunkRequest(req) => {
GroupedPluginRequest::Shared(SharedRequest::ReadHttpResponseBodyChunk(req))
}
InternalEventPayload::UpsertModelRequest(req) => {
GroupedPluginRequest::Shared(SharedRequest::UpsertModel(req))
}
@@ -182,13 +195,17 @@ impl<'a> From<&'a InternalEventPayload> for GroupedPluginRequest<'a> {
pub fn handle_shared_plugin_event<'a>(
query_manager: &QueryManager,
body_store: &dyn ResponseBodyStore,
payload: &'a InternalEventPayload,
context: SharedPluginEventContext<'_>,
) -> GroupedPluginEvent<'a> {
match GroupedPluginRequest::from(payload) {
GroupedPluginRequest::Shared(req) => {
GroupedPluginEvent::Handled(Some(build_shared_reply(query_manager, req, context)))
}
GroupedPluginRequest::Shared(req) => GroupedPluginEvent::Handled(Some(build_shared_reply(
query_manager,
body_store,
req,
context,
))),
GroupedPluginRequest::Host(req) => GroupedPluginEvent::ToHandle(req),
GroupedPluginRequest::Ignore => GroupedPluginEvent::Handled(None),
}
@@ -196,6 +213,7 @@ pub fn handle_shared_plugin_event<'a>(
fn build_shared_reply(
query_manager: &QueryManager,
body_store: &dyn ResponseBodyStore,
request: SharedRequest<'_>,
context: SharedPluginEventContext<'_>,
) -> InternalEventPayload {
@@ -283,6 +301,30 @@ fn build_shared_reply(
http_responses,
})
}
SharedRequest::GetHttpResponseBodyInfo(req) => match body_store.info(&req.response_id) {
Ok(info) => InternalEventPayload::GetHttpResponseBodyInfoResponse(
GetHttpResponseBodyInfoResponse {
content_length: info.content_length,
content_type: info.content_type,
},
),
Err(err) => InternalEventPayload::ErrorResponse(ErrorResponse {
error: format!("Failed to read body of response {}: {err}", req.response_id),
}),
},
SharedRequest::ReadHttpResponseBodyChunk(req) => {
match body_store.read_chunk(&req.response_id, req.offset, req.length) {
Ok(bytes) => InternalEventPayload::ReadHttpResponseBodyChunkResponse(
ReadHttpResponseBodyChunkResponse {
length: bytes.len() as u64,
data: BASE64_STANDARD.encode(bytes),
},
),
Err(err) => InternalEventPayload::ErrorResponse(ErrorResponse {
error: format!("Failed to read body of response {}: {err}", req.response_id),
}),
}
}
SharedRequest::UpsertModel(req) => {
use AnyModel::*;
@@ -437,10 +479,26 @@ fn build_shared_reply(
#[cfg(test)]
mod tests {
use super::*;
use crate::response_body::{FileResponseBodyStore, ResponseBodyInfo};
use std::cell::RefCell;
use tempfile::TempDir;
use yaak_models::models::{AnyModel, Folder, HttpRequest, Workspace};
use yaak_models::util::UpdateSource;
/// The real dispatch, with the store the desktop and CLI hand it.
fn dispatch<'a>(
query_manager: &QueryManager,
payload: &'a InternalEventPayload,
context: SharedPluginEventContext<'_>,
) -> GroupedPluginEvent<'a> {
handle_shared_plugin_event(
query_manager,
&FileResponseBodyStore::new(query_manager),
payload,
context,
)
}
fn seed_query_manager() -> (QueryManager, TempDir) {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let db_path = temp_dir.path().join("db.sqlite");
@@ -498,7 +556,7 @@ mod tests {
let payload = InternalEventPayload::ListHttpRequestsRequest(
yaak_plugins::events::ListHttpRequestsRequest { folder_id: None },
);
let result = handle_shared_plugin_event(
let result = dispatch(
&query_manager,
&payload,
SharedPluginEventContext { plugin_name: "@yaak/test", workspace_id: None },
@@ -517,7 +575,7 @@ mod tests {
let by_workspace_payload = InternalEventPayload::ListHttpRequestsRequest(
yaak_plugins::events::ListHttpRequestsRequest { folder_id: None },
);
let by_workspace = handle_shared_plugin_event(
let by_workspace = dispatch(
&query_manager,
&by_workspace_payload,
SharedPluginEventContext { plugin_name: "@yaak/test", workspace_id: Some("wk_test") },
@@ -536,7 +594,7 @@ mod tests {
folder_id: Some("fl_test".to_string()),
},
);
let by_folder = handle_shared_plugin_event(
let by_folder = dispatch(
&query_manager,
&by_folder_payload,
SharedPluginEventContext { plugin_name: "@yaak/test", workspace_id: None },
@@ -559,7 +617,7 @@ mod tests {
limit: Some(1),
});
let result = handle_shared_plugin_event(
let result = dispatch(
&query_manager,
&payload,
SharedPluginEventContext { plugin_name: "@yaak/test", workspace_id: Some("wk_test") },
@@ -575,6 +633,104 @@ mod tests {
}
}
/// A store that answers from memory, standing in for whatever holds the
/// bytes — the point being that the dispatch below never learns which.
struct FakeBodyStore {
body: Vec<u8>,
reads: RefCell<Vec<(u64, u64)>>,
}
impl ResponseBodyStore for FakeBodyStore {
fn info(&self, _response_id: &str) -> crate::error::Result<ResponseBodyInfo> {
Ok(ResponseBodyInfo {
content_length: self.body.len() as u64,
content_type: Some("text/plain; charset=utf-8".to_string()),
})
}
fn read_chunk(
&self,
_response_id: &str,
offset: u64,
length: u64,
) -> crate::error::Result<Vec<u8>> {
self.reads.borrow_mut().push((offset, length));
let start = (offset as usize).min(self.body.len());
let end = (start + length as usize).min(self.body.len());
Ok(self.body[start..end].to_vec())
}
}
#[test]
fn response_body_is_read_by_id_through_the_store() {
let (query_manager, _temp_dir) = seed_query_manager();
let store = FakeBodyStore { body: b"hello".to_vec(), reads: RefCell::new(Vec::new()) };
let info_payload = InternalEventPayload::GetHttpResponseBodyInfoRequest(
GetHttpResponseBodyInfoRequest { response_id: "rs_test".to_string() },
);
let info = handle_shared_plugin_event(
&query_manager,
&store,
&info_payload,
SharedPluginEventContext { plugin_name: "@yaak/test", workspace_id: None },
);
match info {
GroupedPluginEvent::Handled(Some(
InternalEventPayload::GetHttpResponseBodyInfoResponse(resp),
)) => {
assert_eq!(resp.content_length, 5);
assert_eq!(resp.content_type.as_deref(), Some("text/plain; charset=utf-8"));
}
other => panic!("unexpected body info result: {other:?}"),
}
let chunk_payload = InternalEventPayload::ReadHttpResponseBodyChunkRequest(
ReadHttpResponseBodyChunkRequest {
response_id: "rs_test".to_string(),
offset: 1,
length: 3,
},
);
let chunk = handle_shared_plugin_event(
&query_manager,
&store,
&chunk_payload,
SharedPluginEventContext { plugin_name: "@yaak/test", workspace_id: None },
);
match chunk {
GroupedPluginEvent::Handled(Some(
InternalEventPayload::ReadHttpResponseBodyChunkResponse(resp),
)) => {
assert_eq!(resp.length, 3);
assert_eq!(BASE64_STANDARD.decode(resp.data).unwrap(), b"ell");
}
other => panic!("unexpected body chunk result: {other:?}"),
}
assert_eq!(*store.reads.borrow(), vec![(1, 3)]);
}
#[test]
fn an_unreadable_response_body_becomes_an_error_reply() {
let (query_manager, _temp_dir) = seed_query_manager();
let payload = InternalEventPayload::GetHttpResponseBodyInfoRequest(
GetHttpResponseBodyInfoRequest { response_id: "rs_never_persisted".to_string() },
);
let result = dispatch(
&query_manager,
&payload,
SharedPluginEventContext { plugin_name: "@yaak/test", workspace_id: None },
);
match result {
GroupedPluginEvent::Handled(Some(InternalEventPayload::ErrorResponse(resp))) => {
assert!(resp.error.contains("rs_never_persisted"), "unhelpful error: {}", resp.error)
}
other => panic!("unexpected missing-response result: {other:?}"),
}
}
#[test]
fn upsert_and_delete_model_are_shared_handled() {
let (query_manager, _temp_dir) = seed_query_manager();
@@ -590,7 +746,7 @@ mod tests {
}),
});
let upsert_result = handle_shared_plugin_event(
let upsert_result = dispatch(
&query_manager,
&upsert_payload,
SharedPluginEventContext { plugin_name: "@yaak/test", workspace_id: Some("wk_test") },
@@ -609,7 +765,7 @@ mod tests {
model: "http_request".to_string(),
id: "rq_test".to_string(),
});
let delete_result = handle_shared_plugin_event(
let delete_result = dispatch(
&query_manager,
&delete_payload,
SharedPluginEventContext { plugin_name: "@yaak/test", workspace_id: Some("wk_test") },
@@ -631,7 +787,7 @@ mod tests {
let payload = InternalEventPayload::WindowInfoRequest(WindowInfoRequest {
label: "main".to_string(),
});
let result = handle_shared_plugin_event(
let result = dispatch(
&query_manager,
&payload,
SharedPluginEventContext { plugin_name: "@yaak/test", workspace_id: None },
+210
View File
@@ -0,0 +1,210 @@
//! Reading response bodies back out, by response id.
//!
//! Plugins only ever name a response. Where its bytes actually live — files the
//! engine wrote under `<data dir>/responses/<id>` today, blob rows later — is
//! behind [`ResponseBodyStore`], so moving the bytes is a change to this file
//! and nothing a plugin can see.
//!
//! Only saved responses are reachable by id. A send that saved nothing hands
//! its body back with the reply instead, which is the only copy of it there is.
use crate::error::Result;
use std::fs::File;
use std::io::{Read, Seek, SeekFrom};
use yaak_models::query_manager::QueryManager;
/// The most bytes one read will hand back, however much was asked for.
///
/// A chunk is buffered whole and, on the desktop transport, base64'd into a
/// single WebSocket frame, so an unbounded request is a way to make the host
/// allocate on a plugin's say-so.
pub const MAX_CHUNK_BYTES: u64 = 8 * 1024 * 1024;
/// What a stored body is, without reading any of it.
#[derive(Debug, Clone, Default)]
pub struct ResponseBodyInfo {
/// Bytes actually stored, which is not necessarily what `Content-Length`
/// claimed. Zero when the response has no body.
pub content_length: u64,
/// The response's `Content-Type` header, verbatim.
pub content_type: Option<String>,
}
/// Somewhere response bodies can be read from, a window at a time.
///
/// Reads are repeatable — the bytes are durable, so nothing is consumed by
/// looking at it.
pub trait ResponseBodyStore {
fn info(&self, response_id: &str) -> Result<ResponseBodyInfo>;
/// Bytes `[offset, offset + length)`, clamped to what is there. A short
/// read means the body ended.
fn read_chunk(&self, response_id: &str, offset: u64, length: u64) -> Result<Vec<u8>>;
}
/// The desktop and CLI store: the database says where the file is, and the
/// filesystem holds it.
pub struct FileResponseBodyStore<'a> {
query_manager: &'a QueryManager,
}
impl<'a> FileResponseBodyStore<'a> {
pub fn new(query_manager: &'a QueryManager) -> Self {
Self { query_manager }
}
/// The file backing a response, or `None` when it stored no body.
///
/// Only responses the store knows about are reachable here. A send with no
/// request behind it never reaches the store at all, and its bytes come
/// back from the send instead — see `SendHttpRequestResponse::body`.
fn body_path(&self, response_id: &str) -> Result<Option<String>> {
Ok(self.query_manager.connect().get_http_response(response_id)?.body_path)
}
}
impl ResponseBodyStore for FileResponseBodyStore<'_> {
fn info(&self, response_id: &str) -> Result<ResponseBodyInfo> {
let response = self.query_manager.connect().get_http_response(response_id)?;
let content_type = response
.headers
.iter()
.find(|h| h.name.eq_ignore_ascii_case("content-type"))
.map(|h| h.value.clone());
let content_length = match response.body_path {
Some(path) => std::fs::metadata(path)?.len(),
None => 0,
};
Ok(ResponseBodyInfo { content_length, content_type })
}
fn read_chunk(&self, response_id: &str, offset: u64, length: u64) -> Result<Vec<u8>> {
let Some(path) = self.body_path(response_id)? else {
return Ok(Vec::new());
};
let length = length.min(MAX_CHUNK_BYTES);
if length == 0 {
return Ok(Vec::new());
}
let mut file = File::open(path)?;
file.seek(SeekFrom::Start(offset))?;
let mut buf = Vec::new();
file.take(length).read_to_end(&mut buf)?;
Ok(buf)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use tempfile::TempDir;
use yaak_models::models::{HttpRequest, HttpResponse, HttpResponseHeader, Workspace};
use yaak_models::util::UpdateSource;
fn seed(body: Option<&[u8]>) -> (QueryManager, TempDir, String) {
let temp_dir = TempDir::new().unwrap();
let (query_manager, blob_manager, _rx) = yaak_models::init_standalone(
&temp_dir.path().join("db.sqlite"),
&temp_dir.path().join("blobs.sqlite"),
)
.unwrap();
query_manager
.connect()
.upsert_workspace(
&Workspace { id: "wk_test".to_string(), ..Default::default() },
&UpdateSource::Sync,
)
.unwrap();
query_manager
.connect()
.upsert_http_request(
&HttpRequest {
id: "rq_test".to_string(),
workspace_id: "wk_test".to_string(),
..Default::default()
},
&UpdateSource::Sync,
)
.unwrap();
let body_path = body.map(|bytes| {
let path = temp_dir.path().join("body");
let mut f = std::fs::File::create(&path).unwrap();
f.write_all(bytes).unwrap();
path.to_string_lossy().to_string()
});
let response = query_manager
.connect()
.upsert_http_response(
&HttpResponse {
workspace_id: "wk_test".to_string(),
request_id: "rq_test".to_string(),
body_path,
headers: vec![HttpResponseHeader {
name: "Content-Type".to_string(),
value: "application/json; charset=utf-8".to_string(),
}],
..Default::default()
},
&UpdateSource::Sync,
&blob_manager,
)
.unwrap();
let id = response.id.clone();
(query_manager, temp_dir, id)
}
#[test]
fn info_reports_stored_size_and_content_type() {
let (qm, _tmp, id) = seed(Some(b"hello world"));
let info = FileResponseBodyStore::new(&qm).info(&id).unwrap();
assert_eq!(info.content_length, 11);
assert_eq!(info.content_type.as_deref(), Some("application/json; charset=utf-8"));
}
#[test]
fn chunks_cover_the_body_and_stop_short_at_the_end() {
let (qm, _tmp, id) = seed(Some(b"hello world"));
let store = FileResponseBodyStore::new(&qm);
assert_eq!(store.read_chunk(&id, 0, 5).unwrap(), b"hello");
assert_eq!(store.read_chunk(&id, 6, 100).unwrap(), b"world");
assert!(store.read_chunk(&id, 11, 100).unwrap().is_empty());
// Reading the same window twice gives the same bytes; nothing is consumed.
assert_eq!(store.read_chunk(&id, 0, 5).unwrap(), b"hello");
}
#[test]
fn a_response_with_no_body_is_empty_not_an_error() {
let (qm, _tmp, id) = seed(None);
let store = FileResponseBodyStore::new(&qm);
assert_eq!(store.info(&id).unwrap().content_length, 0);
assert!(store.read_chunk(&id, 0, 100).unwrap().is_empty());
}
#[test]
fn an_unknown_response_fails() {
let (qm, _tmp, _id) = seed(Some(b"hi"));
assert!(FileResponseBodyStore::new(&qm).info("rs_nope").is_err());
}
#[test]
fn an_unsaved_response_is_not_reachable_by_id() {
// Its bytes rode back with the send; there is nothing here to find, and
// guessing at a file named for the id is exactly what this must not do.
let (qm, tmp, _id) = seed(Some(b"hi"));
std::fs::write(tmp.path().join("rs_ephemeral1"), b"access_token=abc").unwrap();
assert!(FileResponseBodyStore::new(&qm).info("rs_ephemeral1").is_err());
}
}
+13
View File
@@ -354,6 +354,19 @@ pub enum ResponseBody {
Returned(Vec<u8>),
}
impl ResponseBody {
/// The bytes, when this is the only copy of them.
///
/// Stored and streamed bodies belong to whoever holds them; only `Returned`
/// has to travel back to the caller.
pub fn returned_bytes(&self) -> Option<&[u8]> {
match self {
ResponseBody::Returned(bytes) => Some(bytes),
ResponseBody::Stored | ResponseBody::Streamed => None,
}
}
}
pub struct SendHttpRequestResult {
pub rendered_request: HttpRequest,
pub response: HttpResponse,
+1 -1
View File
@@ -17226,7 +17226,7 @@
},
"packages/plugin-runtime-types": {
"name": "@yaakapp/api",
"version": "0.8.0",
"version": "0.9.0",
"dependencies": {
"@types/node": "^24.0.13"
},
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@yaakapp/api",
"version": "0.8.0",
"version": "0.9.0",
"keywords": [
"api-client",
"bruno-alternative",
File diff suppressed because one or more lines are too long
@@ -6,6 +6,7 @@ import type {
GetCookieValueResponse,
GetHttpRequestByIdRequest,
GetHttpRequestByIdResponse,
GetHttpResponseBodyInfoRequest,
JsonPrimitive,
ListCookieNamesResponse,
ListFoldersRequest,
@@ -22,12 +23,11 @@ import type {
RenderHttpRequestRequest,
RenderHttpRequestResponse,
SendHttpRequestRequest,
SendHttpRequestResponse,
ShowToastRequest,
TemplateRenderRequest,
WorkspaceInfo,
} from "../bindings/gen_events.ts";
import type { Folder, HttpRequest } from "../bindings/gen_models.ts";
import type { Folder, HttpRequest, HttpResponse } from "../bindings/gen_models.ts";
import type { JsonValue } from "../bindings/serde_json/JsonValue";
import type { MaybePromise } from "../helpers";
@@ -65,6 +65,70 @@ type DynamicPromptFormRequest = Omit<PromptFormRequest, "inputs"> & {
export type WorkspaceHandle = Pick<WorkspaceInfo, "id" | "name">;
export interface ReadHttpResponseBodyOptions {
/**
* Refuse to buffer a body larger than this, in bytes. Defaults to 32 MiB.
* Pass `Infinity` to read whatever is there.
*/
maxBytes?: number;
/** Bytes to pull from the host at a time. Defaults to 1 MiB. */
chunkSize?: number;
}
/**
* A response body, read back from wherever the host stored it.
*
* The accessors are named after `fetch`'s, but unlike `fetch` the body is not
* used up by reading it: these bytes are in durable storage, so every accessor
* can be called as many times as you like, in any order.
*/
export interface HttpResponseBody {
/** The response these bytes belong to. */
readonly responseId: string;
/**
* How many bytes are stored, which is not necessarily what the
* `Content-Length` header claimed. Zero when the response has no body.
*/
readonly contentLength: number;
/** The response's `Content-Type` header, verbatim, or null if it had none. */
readonly contentType: string | null;
/**
* The body decoded to a string, using the charset from `contentType` and
* falling back to UTF-8. Throws if the body is over `maxBytes`.
*/
text(options?: ReadHttpResponseBodyOptions): Promise<string>;
/** `text()`, parsed as JSON. */
json<T = unknown>(options?: ReadHttpResponseBodyOptions): Promise<T>;
/** The raw bytes. Throws if the body is over `maxBytes`. */
arrayBuffer(options?: ReadHttpResponseBodyOptions): Promise<ArrayBuffer>;
/**
* The raw bytes, a chunk at a time, so a body of any size can be read
* without holding all of it at once. Not subject to `maxBytes`.
*/
chunks(options?: Pick<ReadHttpResponseBodyOptions, "chunkSize">): AsyncIterable<Uint8Array>;
}
/** What a send came back with. */
export interface SentHttpRequest {
httpResponse: HttpResponse;
/**
* The response's body.
*
* Handed over here rather than looked up later, because a request with no id
* is not saved and this is the only copy of its body. Reading it is the same
* either way, so nothing has to know which kind of send it made.
*/
body: HttpResponseBody;
}
export interface Context {
clipboard: {
copyText(text: string): Promise<void>;
@@ -101,7 +165,13 @@ export interface Context {
render(args: RenderGrpcRequestRequest): Promise<RenderGrpcRequestResponse["grpcRequest"]>;
};
httpRequest: {
send(args: SendHttpRequestRequest): Promise<SendHttpRequestResponse["httpResponse"]>;
/**
* Send a request and wait for the response and its body.
*
* The body comes back with the response because a request with no id is
* never saved, and there would be nothing to look up afterwards.
*/
send(args: SendHttpRequestRequest): Promise<SentHttpRequest>;
getById(args: GetHttpRequestByIdRequest): Promise<GetHttpRequestByIdResponse["httpRequest"]>;
render(args: RenderHttpRequestRequest): Promise<RenderHttpRequestResponse["httpRequest"]>;
list(args?: ListHttpRequestsRequest): Promise<ListHttpRequestsResponse["httpRequests"]>;
@@ -129,6 +199,15 @@ export interface Context {
};
httpResponse: {
find(args: FindHttpResponsesRequest): Promise<FindHttpResponsesResponse["httpResponses"]>;
/**
* Read a saved response's body by id. Where the host keeps the bytes —
* files on a desktop, rows in a database, somewhere else later — is not
* something a plugin sees or should depend on.
*
* Ids come from `find`. A response that was never saved has none to look
* up, so its body arrives with the send that made it instead.
*/
body(args: GetHttpResponseBodyInfoRequest): Promise<HttpResponseBody>;
};
templates: {
render<T extends JsonValue>(args: TemplateRenderRequest & { data: T }): Promise<T>;
@@ -13,7 +13,13 @@ import type { WorkspaceActionPlugin } from "./WorkspaceActionPlugin";
export type { Context };
export type { DynamicAuthenticationArg } from "./AuthenticationPlugin";
export type { CallPromptFormDynamicArgs, DynamicPromptFormArg } from "./Context";
export type {
CallPromptFormDynamicArgs,
DynamicPromptFormArg,
HttpResponseBody,
ReadHttpResponseBodyOptions,
SentHttpRequest,
} from "./Context";
export type { DynamicTemplateFunctionArg } from "./TemplateFunctionPlugin";
export type { TemplateFunctionPlugin };
export type { FolderActionPlugin } from "./FolderActionPlugin";
+60 -3
View File
@@ -21,6 +21,7 @@ import type {
GetCookieValueRequest,
GetCookieValueResponse,
GetHttpRequestByIdResponse,
GetHttpResponseBodyInfoResponse,
GetKeyValueResponse,
GrpcRequestAction,
HttpAuthenticationAction,
@@ -37,6 +38,7 @@ import type {
PluginContext,
PromptFormResponse,
PromptTextResponse,
ReadHttpResponseBodyChunkResponse,
RenderGrpcRequestResponse,
RenderHttpRequestResponse,
SendHttpRequestResponse,
@@ -49,6 +51,7 @@ import type {
import { applyDynamicFormInput } from "./common";
import { EventChannel } from "./EventChannel";
import { migrateTemplateFunctionSelectOptions } from "./migrations";
import { createResponseBody, decodeBase64Chunk } from "./responseBody";
export interface PluginWorkerData {
bootRequest: BootRequest;
@@ -555,16 +558,24 @@ export class PluginInstance {
#sendForReply<T extends Omit<InternalEventPayload, "type">>(
context: PluginContext,
payload: InternalEventPayload,
// Off by default because a reply-shaped object with none of the expected
// fields is what every existing caller already copes with; turning it on
// for a new call is how that stops spreading.
{ throwOnError = false }: { throwOnError?: boolean } = {},
): Promise<T> {
// 1. Build event to send
const eventToSend = this.#buildEventToSend(context, payload, null);
// 2. Spawn listener in background
const promise = new Promise<T>((resolve) => {
const promise = new Promise<T>((resolve, reject) => {
const cb = (event: InternalEvent) => {
if (event.replyId === eventToSend.id) {
this.#appToPluginEvents.unlisten(cb); // Unlisten, now that we're done
const { type: _, ...payload } = event.payload;
if (throwOnError && event.payload.type === "error_response") {
reject(new Error(String((payload as { error?: string }).error ?? "Unknown error")));
return;
}
resolve(payload as T);
}
};
@@ -598,6 +609,27 @@ export class PluginInstance {
}
#newCtx(context: PluginContext): Context {
/** Read a body the host has stored, a chunk at a time. */
const storedBody = async (responseId: string) => {
const info = await this.#sendForReply<GetHttpResponseBodyInfoResponse>(
context,
{ type: "get_http_response_body_info_request", responseId },
{ throwOnError: true },
);
return createResponseBody(
{ responseId, contentLength: info.contentLength, contentType: info.contentType ?? null },
async (offset, length) => {
const chunk = await this.#sendForReply<ReadHttpResponseBodyChunkResponse>(
context,
{ type: "read_http_response_body_chunk_request", responseId, offset, length },
{ throwOnError: true },
);
return decodeBase64Chunk(chunk.data);
},
);
};
const _windowInfo = async () => {
if (context.label == null) {
throw new Error("Can't get window context without an active window");
@@ -751,6 +783,7 @@ export class PluginInstance {
);
return httpResponses;
},
body: ({ responseId }) => storedBody(responseId),
},
grpcRequest: {
render: async (args) => {
@@ -782,11 +815,35 @@ export class PluginInstance {
type: "send_http_request_request",
...args,
} as const;
const { httpResponse } = await this.#sendForReply<SendHttpRequestResponse>(
const { httpResponse, body } = await this.#sendForReply<SendHttpRequestResponse>(
context,
payload,
// A failed send has no response to hand back, and reading `.body`
// off nothing would bury the host's reason for failing.
{ throwOnError: true },
);
return httpResponse;
// A send with no request behind it saves nothing, so the reply
// carries the only copy of its body. A saved one is read back from
// the host like any other. Callers get the same thing either way.
if (body == null) {
return { httpResponse, body: await storedBody(httpResponse.id) };
}
const bytes = decodeBase64Chunk(body);
return {
httpResponse,
body: createResponseBody(
{
responseId: httpResponse.id,
contentLength: bytes.byteLength,
contentType:
httpResponse.headers.find((h) => h.name.toLowerCase() === "content-type")
?.value ?? null,
},
async (offset, length) => bytes.slice(offset, offset + length),
),
};
},
render: async (args) => {
const payload = {
+151
View File
@@ -0,0 +1,151 @@
import type { HttpResponseBody, ReadHttpResponseBodyOptions } from "@yaakapp/api";
/** Bytes pulled from the host per round trip, when the caller doesn't say. */
const DEFAULT_CHUNK_SIZE = 1024 * 1024;
/**
* The most a plugin buffers by default.
*
* Reading a body used to be unbounded, so any ceiling is an improvement; this
* one is set well above what an API returns and well below what makes the
* plugin runtime fall over. `chunks()` has no ceiling, and any caller that
* really wants the whole thing can raise `maxBytes`.
*/
const DEFAULT_MAX_BYTES = 32 * 1024 * 1024;
/** Fetch one window of body bytes from the host. */
export type ReadResponseBodyChunk = (offset: number, length: number) => Promise<Uint8Array>;
export interface ResponseBodyInfo {
responseId: string;
contentLength: number;
contentType: string | null;
}
export function createResponseBody(
info: ResponseBodyInfo,
readChunk: ReadResponseBodyChunk,
): HttpResponseBody {
const { responseId, contentLength, contentType } = info;
async function* chunks(
options?: Pick<ReadHttpResponseBodyOptions, "chunkSize">,
): AsyncIterable<Uint8Array> {
const chunkSize = Math.max(1, Math.floor(options?.chunkSize ?? DEFAULT_CHUNK_SIZE));
let offset = 0;
// Bounded by the length the host reported, but a short read still ends it:
// the body may have been rewritten between the two calls.
while (offset < contentLength) {
const chunk = await readChunk(offset, Math.min(chunkSize, contentLength - offset));
if (chunk.byteLength === 0) return;
yield chunk;
offset += chunk.byteLength;
}
}
async function readAll(accessor: string, options?: ReadHttpResponseBodyOptions) {
const maxBytes = options?.maxBytes ?? DEFAULT_MAX_BYTES;
refuseIfTooBig(accessor, contentLength, maxBytes);
const parts: Uint8Array[] = [];
let total = 0;
for await (const chunk of chunks(options)) {
total += chunk.byteLength;
// The size the host reported is a claim about a moment ago, so check the
// bytes actually arriving too.
refuseIfTooBig(accessor, total, maxBytes);
parts.push(chunk);
}
const bytes = new Uint8Array(total);
let offset = 0;
for (const part of parts) {
bytes.set(part, offset);
offset += part.byteLength;
}
return bytes;
}
return {
responseId,
contentLength,
contentType,
chunks,
async arrayBuffer(options) {
const bytes = await readAll("arrayBuffer", options);
return bytes.buffer as ArrayBuffer;
},
async text(options) {
return decodeBody(await readAll("text", options), contentType);
},
async json<T>(options?: ReadHttpResponseBodyOptions) {
return JSON.parse(decodeBody(await readAll("json", options), contentType)) as T;
},
};
}
function refuseIfTooBig(accessor: string, bytes: number, maxBytes: number) {
if (bytes <= maxBytes) return;
throw new Error(
`Response body is ${formatBytes(bytes)}, over the ${formatBytes(maxBytes)} limit for ` +
`${accessor}(). Read it with chunks() instead, or pass a larger maxBytes.`,
);
}
/**
* Decode using the charset the response declared.
*
* Assuming UTF-8 mangles every response that isn't, and the header is right
* there. An unknown label is the one case worth guessing on, since the
* alternative is refusing to read a body we can very likely still read.
*/
function decodeBody(bytes: Uint8Array, contentType: string | null): string {
const charset = parseCharset(contentType);
if (charset != null) {
try {
return new TextDecoder(charset).decode(bytes);
} catch {
// Not a label this runtime knows.
}
}
// TextDecoder drops a leading BOM on its own.
return new TextDecoder("utf-8").decode(bytes);
}
function parseCharset(contentType: string | null): string | null {
const match = contentType?.match(/;\s*charset\s*=\s*"?([^";]+)"?/i);
return match?.[1]?.trim() || null;
}
function formatBytes(bytes: number): string {
if (bytes === Infinity) return "unlimited";
if (bytes < 1024) return `${bytes} B`;
const units = ["KB", "MB", "GB"];
let value = bytes / 1024;
let unit = 0;
while (value >= 1024 && unit < units.length - 1) {
value /= 1024;
unit++;
}
return `${value.toFixed(1)} ${units[unit]}`;
}
/**
* Decode a chunk that arrived as base64.
*
* The desktop transport is a WebSocket carrying JSON text frames, so bytes
* have to be spelled out. A host that can pass an ArrayBuffer along skips this.
*/
export function decodeBase64Chunk(data: string): Uint8Array {
if (typeof Buffer !== "undefined") {
const buf = Buffer.from(data, "base64");
return new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength);
}
const binary = atob(data);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return bytes;
}
@@ -0,0 +1,122 @@
import { describe, expect, test } from "vite-plus/test";
import { createResponseBody, decodeBase64Chunk } from "../src/responseBody";
/** A store of bytes that records every window it was asked for. */
function fakeBody(bytes: Uint8Array, contentType: string | null) {
const reads: Array<[number, number]> = [];
const body = createResponseBody(
{ responseId: "rs_test", contentLength: bytes.byteLength, contentType },
async (offset, length) => {
reads.push([offset, length]);
return bytes.slice(offset, offset + length);
},
);
return { body, reads };
}
function utf8(text: string) {
return new TextEncoder().encode(text);
}
describe("response body", () => {
test("pulls a body in chunks and reassembles it", async () => {
const { body, reads } = fakeBody(utf8("abcdefghij"), "text/plain");
expect(await body.text({ chunkSize: 4 })).toEqual("abcdefghij");
expect(reads).toEqual([
[0, 4],
[4, 4],
[8, 2],
]);
});
test("can be read more than once, unlike fetch", async () => {
const { body } = fakeBody(utf8('{"a":1}'), "application/json");
expect(await body.text()).toEqual('{"a":1}');
expect(await body.json()).toEqual({ a: 1 });
expect(new Uint8Array(await body.arrayBuffer())).toEqual(utf8('{"a":1}'));
});
test("decodes using the charset the response declared", async () => {
// "café naïve" as Latin-1, which is mojibake if read as UTF-8.
const latin1 = new Uint8Array([0x63, 0x61, 0x66, 0xe9, 0x20, 0x6e, 0x61, 0xef, 0x76, 0x65]);
const declared = fakeBody(latin1, "text/plain; charset=iso-8859-1");
expect(await declared.body.text()).toEqual("café naïve");
const undeclared = fakeBody(latin1, "text/plain");
expect(await undeclared.body.text()).not.toEqual("café naïve");
});
test("falls back to UTF-8 for a charset the runtime doesn't know", async () => {
const { body } = fakeBody(utf8("hello"), "text/plain; charset=not-a-real-charset");
expect(await body.text()).toEqual("hello");
});
test("drops a UTF-8 BOM", async () => {
const withBom = new Uint8Array([0xef, 0xbb, 0xbf, ...utf8('{"a":1}')]);
const { body } = fakeBody(withBom, "application/json");
expect(await body.text()).toEqual('{"a":1}');
expect(await body.json()).toEqual({ a: 1 });
});
test("refuses to buffer past maxBytes, and says what to do instead", async () => {
const { body } = fakeBody(utf8("x".repeat(100)), "text/plain");
await expect(body.text({ maxBytes: 50 })).rejects.toThrow(/chunks\(\)/);
await expect(body.json({ maxBytes: 50 })).rejects.toThrow(/over the/);
await expect(body.arrayBuffer({ maxBytes: 50 })).rejects.toThrow(/arrayBuffer\(\)/);
// The ceiling is the caller's to raise.
expect(await body.text({ maxBytes: 100 })).toHaveLength(100);
});
test("streams past maxBytes through chunks()", async () => {
const { body } = fakeBody(utf8("x".repeat(100)), "text/plain");
let total = 0;
for await (const chunk of body.chunks({ chunkSize: 10 })) {
total += chunk.byteLength;
}
expect(total).toEqual(100);
});
test("stops early when the host runs out of bytes sooner than it claimed", async () => {
// contentLength says 100; the store only ever hands back 10.
const body = createResponseBody(
{ responseId: "rs_test", contentLength: 100, contentType: "text/plain" },
async (offset) => (offset === 0 ? utf8("0123456789") : new Uint8Array()),
);
expect(await body.text()).toEqual("0123456789");
});
test("a response with no body reads as empty", async () => {
const { body, reads } = fakeBody(new Uint8Array(), "application/json");
expect(body.contentLength).toEqual(0);
expect(await body.text()).toEqual("");
expect(reads).toEqual([]);
});
test("keeps binary bytes intact", async () => {
const png = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0xff]);
const { body } = fakeBody(png, "image/png");
expect(new Uint8Array(await body.arrayBuffer({ chunkSize: 3 }))).toEqual(png);
});
});
describe("decodeBase64Chunk", () => {
test("round-trips arbitrary bytes", () => {
const bytes = new Uint8Array([0, 1, 127, 128, 254, 255]);
const base64 = Buffer.from(bytes).toString("base64");
expect(decodeBase64Chunk(base64)).toEqual(bytes);
});
test("decodes an empty chunk", () => {
expect(decodeBase64Chunk("")).toEqual(new Uint8Array());
});
});
@@ -80,7 +80,7 @@ export function registerHttpRequestTools(server: McpServer, ctx: McpServerContex
throw new Error(`HTTP request with ID ${id} not found`);
}
const response = await workspaceCtx.yaak.httpRequest.send({ httpRequest });
const { httpResponse: response } = await workspaceCtx.yaak.httpRequest.send({ httpRequest });
return {
content: [
+1 -1
View File
@@ -67,7 +67,7 @@ export const plugin: PluginDefinition = {
const type1 = ntlm.createType1Message(options);
const negotiateResponse = await ctx.httpRequest.send({
const { httpResponse: negotiateResponse } = await ctx.httpRequest.send({
httpRequest: {
method,
url,
+3 -3
View File
@@ -1,4 +1,3 @@
import { readFileSync } from "node:fs";
import type { Context, HttpRequest, HttpUrlParameter } from "@yaakapp/api";
import type { AccessTokenRawResponse } from "./store";
@@ -57,7 +56,7 @@ export async function fetchAccessToken(
}
httpRequest.authenticationType = "none"; // Don't inherit workspace auth
const resp = await ctx.httpRequest.send({ httpRequest });
const { httpResponse: resp, body: responseBody } = await ctx.httpRequest.send({ httpRequest });
console.log("[oauth2] Got access token response", resp.status);
@@ -65,7 +64,8 @@ export async function fetchAccessToken(
throw new Error(`Failed to fetch access token: ${resp.error}`);
}
const body = resp.bodyPath ? readFileSync(resp.bodyPath, "utf8") : "";
// Empty when the response had no body, which parses to {} below.
const body = await responseBody.text();
if (resp.status < 200 || resp.status >= 300) {
throw new Error(`Failed to fetch access token with status=${resp.status} and body=${body}`);
@@ -1,4 +1,3 @@
import { readFileSync } from "node:fs";
import type { Context, HttpRequest } from "@yaakapp/api";
import type { AccessToken, AccessTokenRawResponse, TokenStoreArgs } from "./store";
import { deleteToken, getToken, storeToken } from "./store";
@@ -71,7 +70,7 @@ export async function getOrRefreshAccessToken(
}
httpRequest.authenticationType = "none"; // Don't inherit workspace auth
const resp = await ctx.httpRequest.send({ httpRequest });
const { httpResponse: resp, body: responseBody } = await ctx.httpRequest.send({ httpRequest });
if (resp.error) {
throw new Error(`Failed to refresh access token: ${resp.error}`);
@@ -85,7 +84,8 @@ export async function getOrRefreshAccessToken(
return null;
}
const body = resp.bodyPath ? readFileSync(resp.bodyPath, "utf8") : "";
// Empty when the response had no body, which parses to {} below.
const body = await responseBody.text();
console.log("[oauth2] Got refresh token response", resp.status);
+24 -25
View File
@@ -1,4 +1,3 @@
import { readFileSync } from "node:fs";
import type {
CallTemplateFunctionArgs,
Context,
@@ -196,17 +195,8 @@ export const plugin: PluginDefinition = {
});
if (response == null) return null;
if (response.bodyPath == null) {
return null;
}
const BOM = "\ufeff";
let body: string;
try {
body = readFileSync(response.bodyPath, "utf-8").replace(BOM, "");
} catch {
return null;
}
const body = await readResponseBody(ctx, response);
if (body == null) return null;
try {
const result: JSONPathResult =
@@ -261,23 +251,32 @@ export const plugin: PluginDefinition = {
});
if (response == null) return null;
if (response.bodyPath == null) {
return null;
}
let body: string;
try {
body = readFileSync(response.bodyPath, "utf-8");
} catch {
return null;
}
return body;
return await readResponseBody(ctx, response);
},
},
],
};
/**
* The response's body as text, or null when there is nothing to read.
*
* The host is asked for it by response id, so this works wherever the bytes
* happen to live — including responses it never recorded, which still get an
* id. A body over the runtime's size limit throws rather than coming back
* empty, since a template silently rendering to nothing is worse than one that
* says why.
*/
async function readResponseBody(ctx: Context, response: HttpResponse): Promise<string | null> {
// Belt and braces: everything reaching here came from find() or send() and so
// has an id. An empty one would just be an unreadable id.
if (!response.id) return null;
const body = await ctx.httpResponse.body({ responseId: response.id });
if (body.contentLength === 0) return null;
return await body.text();
}
async function getResponse(
ctx: Context,
{
@@ -320,7 +319,7 @@ async function getResponse(
// Explicitly render the request before send (instead of relying on send() to render) so that we can
// preserve the render purpose.
const renderedHttpRequest = await ctx.httpRequest.render({ httpRequest, purpose });
response = await ctx.httpRequest.send({ httpRequest: renderedHttpRequest });
response = (await ctx.httpRequest.send({ httpRequest: renderedHttpRequest })).httpResponse;
}
return response;