From ec8aba7ff780c6cb641c53d15b0bb8c2a0179533 Mon Sep 17 00:00:00 2001 From: yulei666 Date: Tue, 20 Oct 2026 15:48:09 +0800 Subject: [PATCH] fix(client): tolerate AbortSignal without throwIfAborted on React Native (#630) React Native's abort-controller@3 polyfill exposes only `aborted`, so the direct AI paths threw "undefined is not a function" on Android before any request was sent. Feature-detect throwIfAborted and fall back to `aborted`. Fixes #629 Co-authored-by: Claude Opus 5.5 --- packages/client/src/abort-signal.ts | 12 ++++++++ packages/client/src/ai-direct-stream.test.mjs | 30 +++++++++++++++++++ .../client/src/companion-direct-stream.ts | 3 +- packages/client/src/index.ts | 9 +++--- packages/client/src/infographic-direct.ts | 3 +- 5 files changed, 51 insertions(+), 6 deletions(-) create mode 100644 packages/client/src/abort-signal.ts diff --git a/packages/client/src/abort-signal.ts b/packages/client/src/abort-signal.ts new file mode 100644 index 00000000..a76c0434 --- /dev/null +++ b/packages/client/src/abort-signal.ts @@ -0,0 +1,12 @@ +// React Native's AbortSignal polyfill (abort-controller@3) has neither +// throwIfAborted() nor reason, so feature-detect before relying on them. +export const throwIfAborted = (signal: AbortSignal | undefined) => { + if (!signal) return; + if (typeof signal.throwIfAborted === "function") { + signal.throwIfAborted(); + return; + } + if (!signal.aborted) return; + if (signal.reason !== undefined) throw signal.reason; + throw Object.assign(new Error("The operation was aborted."), { name: "AbortError" }); +}; diff --git a/packages/client/src/ai-direct-stream.test.mjs b/packages/client/src/ai-direct-stream.test.mjs index 90e2c8c2..504b0ddd 100644 --- a/packages/client/src/ai-direct-stream.test.mjs +++ b/packages/client/src/ai-direct-stream.test.mjs @@ -65,6 +65,36 @@ describe("direct AI generation client", () => { expect(events.some(e=>e.type==="text-delta"&&e.text==="Hello")).toBe(true);expect(paths).toEqual(["/api/v1/ai/generate/prepare"]);expect(calls).toBe(1); }); + // React Native's abort-controller@3 polyfill exposes only `aborted` (#629). + const legacySignal = (aborted) => ({ aborted, addEventListener() {}, removeEventListener() {} }); + + test("streams with a React Native AbortSignal that lacks throwIfAborted", async () => { + const client = createEdgeEverClient({ + directAiGeneration: true, + fetch: async () => Response.json({ provider: "openai-compatible", baseUrl: "https://model.example/v1", apiKey: "key", modelId: "model", system: "Summarize", prompt: "Body", maxOutputTokens: 32, resultBoundary: { start: "", end: "" } }), + providerFetch: async () => new Response('data: {"choices":[{"delta":{"content":"Hello"}}]}\n\ndata: [DONE]\n\n'), + }); + const events = []; + await client.streamAiGeneration( + { action: "summarize", title: "Note", contentMarkdown: "Body" }, + { signal: legacySignal(false), onEvent: (event) => events.push(event) }, + ); + expect(events.some((event) => event.type === "text-delta" && event.text === "Hello")).toBe(true); + }); + + test("rejects an aborted React Native AbortSignal with AbortError before preparing", async () => { + let prepared = false; + const client = createEdgeEverClient({ + directAiGeneration: true, + fetch: async () => { prepared = true; return new Response("{}", { status: 404 }); }, + }); + await expect(client.streamAiGeneration( + { action: "summarize", title: "Note", contentMarkdown: "Body" }, + { signal: legacySignal(true), onEvent() {} }, + )).rejects.toMatchObject({ name: "AbortError" }); + expect(prepared).toBe(false); + }); + test("fails without instance generation when direct prepare is missing", async () => { const urls = []; const client = createEdgeEverClient({ diff --git a/packages/client/src/companion-direct-stream.ts b/packages/client/src/companion-direct-stream.ts index 6555e35e..2f51dee5 100644 --- a/packages/client/src/companion-direct-stream.ts +++ b/packages/client/src/companion-direct-stream.ts @@ -12,6 +12,7 @@ import type { } from "@edgeever/shared"; import type { ModelMessage } from "ai"; import { companionFailureDiagnostics, sealCompanionProcess } from "@edgeever/shared"; +import { throwIfAborted } from "./abort-signal"; import { createClientAiModel } from "./ai-model"; const asRecord = (value: unknown): Record | null => @@ -232,7 +233,7 @@ export const streamDirectCompanion = async ( abortSignal: options.signal, }); for await (const part of result.fullStream) { - options.signal?.throwIfAborted(); + throwIfAborted(options.signal); if (part.type === "error") throw part.error; if (part.type !== "text-delta") continue; const delta = typeof part.text === "string" ? part.text : ""; diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index e261d8f8..e2694572 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -2,6 +2,7 @@ import { parsePreparedAiGeneration, streamDirectAiGeneration, } from "./ai-direct-stream"; +import { throwIfAborted } from "./abort-signal"; import { createPluginCapabilities } from './plugin-capabilities'; import type { @@ -690,7 +691,7 @@ export const createEdgeEverClient = (options: EdgeEverClientOptions = {}) => { payload: unknown, streamOptions: { signal?: AbortSignal; onEvent: (event: CompanionEvent) => void }, ) => { - streamOptions.signal?.throwIfAborted(); + throwIfAborted(streamOptions.signal); const { context, response } = await send(preparePath, { method: "POST", body: JSON.stringify(payload), signal: streamOptions.signal, }); @@ -707,10 +708,10 @@ export const createEdgeEverClient = (options: EdgeEverClientOptions = {}) => { prepare: () => Promise

; run: (prepared: P) => Promise; }): Promise => { - args.signal?.throwIfAborted(); + throwIfAborted(args.signal); const prepared = await args.prepare(); if (prepared == null) throw directAiUnavailable(); - args.signal?.throwIfAborted(); + throwIfAborted(args.signal); return args.run(prepared); }; @@ -1077,7 +1078,7 @@ export const createEdgeEverClient = (options: EdgeEverClientOptions = {}) => { }, streamAiGeneration: async (payload: AiGenerateInput, streamOptions: { signal?: AbortSignal; onEvent: (event: AiStreamEvent) => void }) => { - streamOptions.signal?.throwIfAborted(); + throwIfAborted(streamOptions.signal); const { context, response } = await send("/api/v1/ai/generate/prepare", { method: "POST", body: JSON.stringify(payload), signal: streamOptions.signal, }); diff --git a/packages/client/src/infographic-direct.ts b/packages/client/src/infographic-direct.ts index c3b988c4..ba586fbe 100644 --- a/packages/client/src/infographic-direct.ts +++ b/packages/client/src/infographic-direct.ts @@ -1,6 +1,7 @@ import { isStepCount, tool, ToolLoopAgent, type LanguageModel } from "ai"; import { z } from "zod"; import { infographicRequestsTimeline, type InfographicAgentEvent, type InfographicAgentRequest } from "@edgeever/shared"; +import { throwIfAborted } from "./abort-signal"; export const runInfographicAgent = async (args: { input: InfographicAgentRequest; @@ -64,7 +65,7 @@ export const runInfographicAgent = async (args: { ]; const result = await agent.stream({ messages, abortSignal: args.signal }); for await (const part of result.fullStream) { - args.signal.throwIfAborted(); + throwIfAborted(args.signal); if (part.type === "error") throw part.error; if (part.type !== "text-delta" || !part.text || !proposed || asked) continue; response += part.text;