diff --git a/src/infra/fetch.test.ts b/src/infra/fetch.test.ts new file mode 100644 index 000000000..9c286d4be --- /dev/null +++ b/src/infra/fetch.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it, vi } from "vitest"; + +import { wrapFetchWithAbortSignal } from "./fetch.js"; + +describe("wrapFetchWithAbortSignal", () => { + it("converts foreign abort signals to native controllers", async () => { + let seenSignal: AbortSignal | undefined; + const fetchImpl = vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => { + seenSignal = init?.signal as AbortSignal | undefined; + return {} as Response; + }); + + const wrapped = wrapFetchWithAbortSignal(fetchImpl); + + let abortHandler: (() => void) | null = null; + const fakeSignal = { + aborted: false, + addEventListener: (event: string, handler: () => void) => { + if (event === "abort") abortHandler = handler; + }, + removeEventListener: (event: string, handler: () => void) => { + if (event === "abort" && abortHandler === handler) abortHandler = null; + }, + } as AbortSignal; + + const promise = wrapped("https://example.com", { signal: fakeSignal }); + expect(fetchImpl).toHaveBeenCalledOnce(); + expect(seenSignal).toBeInstanceOf(AbortSignal); + expect(seenSignal).not.toBe(fakeSignal); + + abortHandler?.(); + expect(seenSignal?.aborted).toBe(true); + + await promise; + }); +}); diff --git a/src/infra/fetch.ts b/src/infra/fetch.ts new file mode 100644 index 000000000..5cd0d94e6 --- /dev/null +++ b/src/infra/fetch.ts @@ -0,0 +1,29 @@ +export function wrapFetchWithAbortSignal(fetchImpl: typeof fetch): typeof fetch { + return (input: RequestInfo | URL, init?: RequestInit) => { + const signal = init?.signal; + if (!signal) return fetchImpl(input, init); + if (typeof AbortSignal !== "undefined" && signal instanceof AbortSignal) { + return fetchImpl(input, init); + } + if (typeof AbortController === "undefined") { + return fetchImpl(input, init); + } + if (typeof signal.addEventListener !== "function") { + return fetchImpl(input, init); + } + const controller = new AbortController(); + const onAbort = () => controller.abort(); + if (signal.aborted) { + controller.abort(); + } else { + signal.addEventListener("abort", onAbort, { once: true }); + } + const response = fetchImpl(input, { ...init, signal: controller.signal }); + if (typeof signal.removeEventListener === "function") { + void response.finally(() => { + signal.removeEventListener("abort", onAbort); + }); + } + return response; + }; +} diff --git a/src/telegram/bot.create-telegram-bot.installs-grammy-throttler.test.ts b/src/telegram/bot.create-telegram-bot.installs-grammy-throttler.test.ts index fc34477c1..de7f6b62b 100644 --- a/src/telegram/bot.create-telegram-bot.installs-grammy-throttler.test.ts +++ b/src/telegram/bot.create-telegram-bot.installs-grammy-throttler.test.ts @@ -157,13 +157,12 @@ describe("createTelegramBot", () => { (globalThis as { Bun?: unknown }).Bun = {}; createTelegramBot({ token: "tok" }); const fetchImpl = resolveTelegramFetch(); - expect(fetchImpl).toBe(fetchSpy); - expect(botCtorSpy).toHaveBeenCalledWith( - "tok", - expect.objectContaining({ - client: expect.objectContaining({ fetch: fetchSpy }), - }), - ); + expect(fetchImpl).toBeTypeOf("function"); + expect(fetchImpl).not.toBe(fetchSpy); + const clientFetch = (botCtorSpy.mock.calls[0]?.[1] as { client?: { fetch?: unknown } }) + ?.client?.fetch; + expect(clientFetch).toBeTypeOf("function"); + expect(clientFetch).not.toBe(fetchSpy); } finally { globalThis.fetch = originalFetch; if (originalBun === undefined) { diff --git a/src/telegram/bot.test.ts b/src/telegram/bot.test.ts index 4166ddb19..77f50b41f 100644 --- a/src/telegram/bot.test.ts +++ b/src/telegram/bot.test.ts @@ -284,13 +284,12 @@ describe("createTelegramBot", () => { (globalThis as { Bun?: unknown }).Bun = {}; createTelegramBot({ token: "tok" }); const fetchImpl = resolveTelegramFetch(); - expect(fetchImpl).toBe(fetchSpy); - expect(botCtorSpy).toHaveBeenCalledWith( - "tok", - expect.objectContaining({ - client: expect.objectContaining({ fetch: fetchSpy }), - }), - ); + expect(fetchImpl).toBeTypeOf("function"); + expect(fetchImpl).not.toBe(fetchSpy); + const clientFetch = (botCtorSpy.mock.calls[0]?.[1] as { client?: { fetch?: unknown } }) + ?.client?.fetch; + expect(clientFetch).toBeTypeOf("function"); + expect(clientFetch).not.toBe(fetchSpy); } finally { globalThis.fetch = originalFetch; if (originalBun === undefined) { diff --git a/src/telegram/fetch.ts b/src/telegram/fetch.ts index 21d85e2e9..1c4a288d0 100644 --- a/src/telegram/fetch.ts +++ b/src/telegram/fetch.ts @@ -1,11 +1,13 @@ +import { wrapFetchWithAbortSignal } from "../infra/fetch.js"; + // Bun-only: force native fetch to avoid grammY's Node shim under Bun. export function resolveTelegramFetch(proxyFetch?: typeof fetch): typeof fetch | undefined { - if (proxyFetch) return proxyFetch; + if (proxyFetch) return wrapFetchWithAbortSignal(proxyFetch); const fetchImpl = globalThis.fetch; const isBun = "Bun" in globalThis || Boolean(process?.versions?.bun); if (!isBun) return undefined; if (!fetchImpl) { throw new Error("fetch is not available; set channels.telegram.proxy in config"); } - return fetchImpl; + return wrapFetchWithAbortSignal(fetchImpl); } diff --git a/src/telegram/proxy.ts b/src/telegram/proxy.ts index 7217db477..19d53d569 100644 --- a/src/telegram/proxy.ts +++ b/src/telegram/proxy.ts @@ -1,10 +1,11 @@ // @ts-nocheck import { ProxyAgent } from "undici"; +import { wrapFetchWithAbortSignal } from "../infra/fetch.js"; export function makeProxyFetch(proxyUrl: string): typeof fetch { const agent = new ProxyAgent(proxyUrl); - return (input: RequestInfo | URL, init?: RequestInit) => { + return wrapFetchWithAbortSignal((input: RequestInfo | URL, init?: RequestInit) => { const base = init ? { ...init } : {}; return fetch(input, { ...base, dispatcher: agent }); - }; + }); } diff --git a/src/telegram/send.returns-undefined-empty-input.test.ts b/src/telegram/send.returns-undefined-empty-input.test.ts index f5dff070b..22a85eb3d 100644 --- a/src/telegram/send.returns-undefined-empty-input.test.ts +++ b/src/telegram/send.returns-undefined-empty-input.test.ts @@ -164,12 +164,10 @@ describe("sendMessageTelegram", () => { }); try { await sendMessageTelegram("123", "hi", { token: "tok" }); - expect(botCtorSpy).toHaveBeenCalledWith( - "tok", - expect.objectContaining({ - client: expect.objectContaining({ fetch: fetchSpy }), - }), - ); + const clientFetch = (botCtorSpy.mock.calls[0]?.[1] as { client?: { fetch?: unknown } }) + ?.client?.fetch; + expect(clientFetch).toBeTypeOf("function"); + expect(clientFetch).not.toBe(fetchSpy); } finally { globalThis.fetch = originalFetch; if (originalBun === undefined) {