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.
This commit is contained in:
Gregory Schier
2026-08-16 10:07:44 -07:00
parent 8c72538102
commit 0e14625e62
8 changed files with 73 additions and 74 deletions
@@ -23,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";
@@ -116,6 +115,20 @@ export interface HttpResponseBody {
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>;
@@ -153,15 +166,12 @@ export interface Context {
};
httpRequest: {
/**
* Send a request and wait for the response.
* Send a request and wait for the response and its body.
*
* A request with an id is saved, and its body can be read back by response
* id whenever you like. A request without one is not: it is sent, the
* response comes back, and nothing keeps it. Read that body with
* `ctx.httpResponse.body()` before returning — it is only there for the
* rest of this call.
* 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<SendHttpRequestResponse["httpResponse"]>;
send(args: SendHttpRequestRequest): Promise<SentHttpRequest>;
getById(args: GetHttpRequestByIdRequest): Promise<GetHttpRequestByIdResponse["httpRequest"]>;
render(args: RenderHttpRequestRequest): Promise<RenderHttpRequestResponse["httpRequest"]>;
list(args?: ListHttpRequestsRequest): Promise<ListHttpRequestsResponse["httpRequests"]>;
@@ -190,14 +200,12 @@ export interface Context {
httpResponse: {
find(args: FindHttpResponsesRequest): Promise<FindHttpResponsesResponse["httpResponses"]>;
/**
* Read a 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.
* 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.
*
* Works for any response the host saved, and for one that `send` returned
* without saving — those are held for the rest of the call that sent them,
* since nothing else has a copy. Reaching for an unsaved response's id in a
* later call throws, because by then its bytes are gone.
* 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>;
};
@@ -18,6 +18,7 @@ export type {
DynamicPromptFormArg,
HttpResponseBody,
ReadHttpResponseBodyOptions,
SentHttpRequest,
} from "./Context";
export type { DynamicTemplateFunctionArg } from "./TemplateFunctionPlugin";
export type { TemplateFunctionPlugin };
+43 -53
View File
@@ -609,13 +609,26 @@ export class PluginInstance {
}
#newCtx(context: PluginContext): Context {
// Bodies of sends that saved nothing, keyed by the response id they were
// handed back with.
//
// A ctx is built per incoming call, so these last exactly as long as the
// plugin invocation that produced them — which is the whole life of an
// unsaved response. Nothing else can reach one: the host has no row for it.
const unsavedBodies = new Map<string, { bytes: Uint8Array; contentType: string | null }>();
/** 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) {
@@ -770,41 +783,7 @@ export class PluginInstance {
);
return httpResponses;
},
body: async ({ responseId }) => {
const unsaved = unsavedBodies.get(responseId);
if (unsaved != null) {
return createResponseBody(
{
responseId,
contentLength: unsaved.bytes.byteLength,
contentType: unsaved.contentType,
},
async (offset, length) => unsaved.bytes.slice(offset, offset + length),
);
}
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);
},
);
},
body: ({ responseId }) => storedBody(responseId),
},
grpcRequest: {
render: async (args) => {
@@ -839,21 +818,32 @@ export class PluginInstance {
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 },
);
// A send with no request behind it saves nothing, so this reply is
// the only copy of its body. Hold it so ctx.httpResponse.body() can
// answer for it the same way it answers for a saved response.
if (body != null) {
unsavedBodies.set(httpResponse.id, {
bytes: decodeBase64Chunk(body),
contentType:
httpResponse.headers.find((h) => h.name.toLowerCase() === "content-type")?.value ??
null,
});
// 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) };
}
return httpResponse;
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 = {
@@ -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,
+1 -1
View File
@@ -57,7 +57,7 @@ export async function fetchAccessToken(
}
httpRequest.authenticationType = "none"; // Don't inherit workspace auth
const resp = await ctx.httpRequest.send({ httpRequest });
const { httpResponse: resp } = await ctx.httpRequest.send({ httpRequest });
console.log("[oauth2] Got access token response", resp.status);
@@ -71,7 +71,7 @@ export async function getOrRefreshAccessToken(
}
httpRequest.authenticationType = "none"; // Don't inherit workspace auth
const resp = await ctx.httpRequest.send({ httpRequest });
const { httpResponse: resp } = await ctx.httpRequest.send({ httpRequest });
if (resp.error) {
throw new Error(`Failed to refresh access token: ${resp.error}`);
@@ -319,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;