From 0e14625e62fffbdcea57e45e70eb41ebfbe5311f Mon Sep 17 00:00:00 2001 From: Gregory Schier Date: Sun, 16 Aug 2026 10:07:44 -0700 Subject: [PATCH] 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. --- .../src/plugins/Context.ts | 40 ++++---- .../plugin-runtime-types/src/plugins/index.ts | 1 + packages/plugin-runtime/src/PluginInstance.ts | 96 +++++++++---------- .../mcp-server/src/tools/httpRequest.ts | 2 +- plugins/auth-ntlm/src/index.ts | 2 +- plugins/auth-oauth2/src/fetchAccessToken.ts | 2 +- .../src/getOrRefreshAccessToken.ts | 2 +- .../template-function-response/src/index.ts | 2 +- 8 files changed, 73 insertions(+), 74 deletions(-) diff --git a/packages/plugin-runtime-types/src/plugins/Context.ts b/packages/plugin-runtime-types/src/plugins/Context.ts index 973e224f..2ee5cb85 100644 --- a/packages/plugin-runtime-types/src/plugins/Context.ts +++ b/packages/plugin-runtime-types/src/plugins/Context.ts @@ -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): AsyncIterable; } +/** 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; @@ -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; + send(args: SendHttpRequestRequest): Promise; getById(args: GetHttpRequestByIdRequest): Promise; render(args: RenderHttpRequestRequest): Promise; list(args?: ListHttpRequestsRequest): Promise; @@ -190,14 +200,12 @@ export interface Context { httpResponse: { find(args: FindHttpResponsesRequest): Promise; /** - * 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; }; diff --git a/packages/plugin-runtime-types/src/plugins/index.ts b/packages/plugin-runtime-types/src/plugins/index.ts index 92e8391f..d5280ac1 100644 --- a/packages/plugin-runtime-types/src/plugins/index.ts +++ b/packages/plugin-runtime-types/src/plugins/index.ts @@ -18,6 +18,7 @@ export type { DynamicPromptFormArg, HttpResponseBody, ReadHttpResponseBodyOptions, + SentHttpRequest, } from "./Context"; export type { DynamicTemplateFunctionArg } from "./TemplateFunctionPlugin"; export type { TemplateFunctionPlugin }; diff --git a/packages/plugin-runtime/src/PluginInstance.ts b/packages/plugin-runtime/src/PluginInstance.ts index a38e49d0..7b22e4d9 100644 --- a/packages/plugin-runtime/src/PluginInstance.ts +++ b/packages/plugin-runtime/src/PluginInstance.ts @@ -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(); + /** Read a body the host has stored, a chunk at a time. */ + const storedBody = async (responseId: string) => { + const info = await this.#sendForReply( + 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( + 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( - 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( - 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( 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 = { diff --git a/plugins-external/mcp-server/src/tools/httpRequest.ts b/plugins-external/mcp-server/src/tools/httpRequest.ts index a1aa342b..545c7504 100644 --- a/plugins-external/mcp-server/src/tools/httpRequest.ts +++ b/plugins-external/mcp-server/src/tools/httpRequest.ts @@ -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: [ diff --git a/plugins/auth-ntlm/src/index.ts b/plugins/auth-ntlm/src/index.ts index 9adfa091..f787de0d 100644 --- a/plugins/auth-ntlm/src/index.ts +++ b/plugins/auth-ntlm/src/index.ts @@ -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, diff --git a/plugins/auth-oauth2/src/fetchAccessToken.ts b/plugins/auth-oauth2/src/fetchAccessToken.ts index ccd28f26..1f585b2a 100644 --- a/plugins/auth-oauth2/src/fetchAccessToken.ts +++ b/plugins/auth-oauth2/src/fetchAccessToken.ts @@ -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); diff --git a/plugins/auth-oauth2/src/getOrRefreshAccessToken.ts b/plugins/auth-oauth2/src/getOrRefreshAccessToken.ts index eb2843f3..386a08ba 100644 --- a/plugins/auth-oauth2/src/getOrRefreshAccessToken.ts +++ b/plugins/auth-oauth2/src/getOrRefreshAccessToken.ts @@ -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}`); diff --git a/plugins/template-function-response/src/index.ts b/plugins/template-function-response/src/index.ts index ca1afdd1..3edf954f 100644 --- a/plugins/template-function-response/src/index.ts +++ b/plugins/template-function-response/src/index.ts @@ -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;