From b4fa10ae67ca7144503000ceee4309ff0343ced1 Mon Sep 17 00:00:00 2001 From: sebslight <19554889+sebslight@users.noreply.github.com> Date: Mon, 16 Feb 2026 08:24:26 -0500 Subject: [PATCH] refactor(infra): make fetch wrapping idempotent --- src/infra/fetch.test.ts | 29 ++++++++++++++++++++++++++++- src/infra/fetch.ts | 22 ++++++++++++++++++++-- 2 files changed, 48 insertions(+), 3 deletions(-) diff --git a/src/infra/fetch.test.ts b/src/infra/fetch.test.ts index 783a2d78473..4af18b6a9e8 100644 --- a/src/infra/fetch.test.ts +++ b/src/infra/fetch.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from "vitest"; -import { wrapFetchWithAbortSignal } from "./fetch.js"; +import { resolveFetch, wrapFetchWithAbortSignal } from "./fetch.js"; describe("wrapFetchWithAbortSignal", () => { it("adds duplex for requests with a body", async () => { @@ -182,4 +182,31 @@ describe("wrapFetchWithAbortSignal", () => { expect(addEventListener).not.toHaveBeenCalled(); expect(removeEventListener).not.toHaveBeenCalled(); }); + + it("returns the same function when called with an already wrapped fetch", () => { + const fetchImpl = vi.fn(async () => ({ ok: true }) as Response); + const wrapped = wrapFetchWithAbortSignal(fetchImpl); + + expect(wrapFetchWithAbortSignal(wrapped)).toBe(wrapped); + expect(resolveFetch(wrapped)).toBe(wrapped); + }); + + it("keeps preconnect bound to the original fetch implementation", () => { + const preconnectSpy = vi.fn(function (this: unknown) { + return this; + }); + const fetchImpl = vi.fn(async () => ({ ok: true }) as Response) as typeof fetch & { + preconnect: (url: string, init?: { credentials?: RequestCredentials }) => unknown; + }; + fetchImpl.preconnect = preconnectSpy; + + const wrapped = wrapFetchWithAbortSignal(fetchImpl) as typeof fetch & { + preconnect: (url: string, init?: { credentials?: RequestCredentials }) => unknown; + }; + + const seenThis = wrapped.preconnect("https://example.com"); + + expect(preconnectSpy).toHaveBeenCalledOnce(); + expect(seenThis).toBe(fetchImpl); + }); }); diff --git a/src/infra/fetch.ts b/src/infra/fetch.ts index 88f5ec0e531..d4612780438 100644 --- a/src/infra/fetch.ts +++ b/src/infra/fetch.ts @@ -6,6 +6,12 @@ type FetchWithPreconnect = typeof fetch & { type RequestInitWithDuplex = RequestInit & { duplex?: "half" }; +const wrapFetchWithAbortSignalMarker = Symbol.for("openclaw.fetch.abort-signal-wrapped"); + +type FetchWithAbortSignalMarker = typeof fetch & { + [wrapFetchWithAbortSignalMarker]?: true; +}; + function withDuplex( init: RequestInit | undefined, input: RequestInfo | URL, @@ -28,6 +34,10 @@ function withDuplex( } export function wrapFetchWithAbortSignal(fetchImpl: typeof fetch): typeof fetch { + if ((fetchImpl as FetchWithAbortSignalMarker)[wrapFetchWithAbortSignalMarker]) { + return fetchImpl; + } + const wrapped = ((input: RequestInfo | URL, init?: RequestInit) => { const patchedInit = withDuplex(init, input); const signal = patchedInit?.signal; @@ -73,13 +83,21 @@ export function wrapFetchWithAbortSignal(fetchImpl: typeof fetch): typeof fetch } }) as FetchWithPreconnect; + const wrappedFetch = Object.assign(wrapped, fetchImpl) as FetchWithPreconnect; const fetchWithPreconnect = fetchImpl as FetchWithPreconnect; - wrapped.preconnect = + wrappedFetch.preconnect = typeof fetchWithPreconnect.preconnect === "function" ? fetchWithPreconnect.preconnect.bind(fetchWithPreconnect) : () => {}; - return Object.assign(wrapped, fetchImpl); + Object.defineProperty(wrappedFetch, wrapFetchWithAbortSignalMarker, { + value: true, + enumerable: false, + configurable: false, + writable: false, + }); + + return wrappedFetch; } export function resolveFetch(fetchImpl?: typeof fetch): typeof fetch | undefined {