diff --git a/packages/trigger-sdk/src/v3/ai-shared.ts b/packages/trigger-sdk/src/v3/ai-shared.ts index d0434e24672..bf0409660f4 100644 --- a/packages/trigger-sdk/src/v3/ai-shared.ts +++ b/packages/trigger-sdk/src/v3/ai-shared.ts @@ -278,3 +278,30 @@ export function waitBeforeEofResubscribe(attempt: number, signal: AbortSignal): if (signal.aborted) done(); }); } + +/** + * Combine multiple `AbortSignal` instances into a single signal, falling back + * to manual listener forwarding on runtimes without `AbortSignal.any` (such as + * Safari / iOS Safari < 17.4). + */ +export function anyAbortSignal(signals: AbortSignal[]): AbortSignal { + if (typeof AbortSignal.any === "function") { + return AbortSignal.any(signals); + } + + const controller = new AbortController(); + const aborted = signals.find((s) => s.aborted); + if (aborted) { + controller.abort(aborted.reason); + return controller.signal; + } + + for (const s of signals) { + s.addEventListener("abort", () => controller.abort(s.reason), { + once: true, + signal: controller.signal, + }); + } + return controller.signal; +} + diff --git a/packages/trigger-sdk/src/v3/chat-client.ts b/packages/trigger-sdk/src/v3/chat-client.ts index 1a421af4990..0f765f57d53 100644 --- a/packages/trigger-sdk/src/v3/chat-client.ts +++ b/packages/trigger-sdk/src/v3/chat-client.ts @@ -31,6 +31,7 @@ import { } from "@trigger.dev/core/v3"; import type { ChatInputChunk, ChatTaskWirePayload } from "./ai-shared.js"; import { + anyAbortSignal, chatRunTags, MAX_EOF_RESUBSCRIBES, slimSubmitMessageForWire, @@ -815,7 +816,7 @@ export class AgentChat { const internalAbort = new AbortController(); const combinedSignal = abortSignal - ? AbortSignal.any([abortSignal, internalAbort.signal]) + ? anyAbortSignal([abortSignal, internalAbort.signal]) : internalAbort.signal; // Detached once the stream ends (see the `finally` below): a caller reusing diff --git a/packages/trigger-sdk/src/v3/chat.test.ts b/packages/trigger-sdk/src/v3/chat.test.ts index a4d8b9841fa..fab2633f92b 100644 --- a/packages/trigger-sdk/src/v3/chat.test.ts +++ b/packages/trigger-sdk/src/v3/chat.test.ts @@ -2950,4 +2950,74 @@ describe("TriggerChatTransport", () => { expect(textChunks).toHaveLength(2); }); }); + + describe("Safari < 17.4 compatibility (no AbortSignal.any)", () => { + it("streams and propagates caller aborts when AbortSignal.any is undefined", async () => { + const originalAny = AbortSignal.any; + Object.defineProperty(AbortSignal, "any", { + value: undefined, + configurable: true, + writable: true, + }); + + try { + global.fetch = vi.fn().mockImplementation(async (url: string | URL) => { + const urlStr = typeof url === "string" ? url : url.toString(); + if (isSessionStreamAppendUrl(urlStr)) return defaultAppendResponse(); + if (isSessionOutSubscribeUrl(urlStr)) { + return defaultSseResponse([ + { type: "text-delta", id: "p1", delta: "Safari fallback" }, + { type: "trigger:turn-complete" }, + ]); + } + throw new Error(`Unexpected URL: ${urlStr}`); + }); + + const transport = new TriggerChatTransport({ + task: "my-chat-task", + accessToken: () => "pat", + sessions: { + "chat-safari": { publicAccessToken: "p", isStreaming: true }, + }, + }); + + const callerAbort = new AbortController(); + const stream = await transport.sendMessages({ + trigger: "submit-message", + chatId: "chat-safari", + messageId: undefined, + messages: [createUserMessage("Hi from Safari 16")], + abortSignal: callerAbort.signal, + }); + const chunks = await drainChunks(stream); + expect(chunks).toEqual([{ type: "text-delta", id: "p1", delta: "Safari fallback" }]); + + const preAborted = new AbortController(); + preAborted.abort(new Error("user stopped")); + let receivedAbortOnSubscribe = false; + global.fetch = vi.fn().mockImplementation(async (url: string | URL, init?: RequestInit) => { + const urlStr = typeof url === "string" ? url : url.toString(); + if (isSessionOutSubscribeUrl(urlStr)) { + receivedAbortOnSubscribe = Boolean(init?.signal?.aborted); + return defaultSseResponse([{ type: "trigger:turn-complete" }]); + } + throw new Error(`Unexpected URL: ${urlStr}`); + }); + + const resumed = await transport.reconnectToStream({ + chatId: "chat-safari", + abortSignal: preAborted.signal, + }); + await drainChunks(resumed!); + expect(receivedAbortOnSubscribe).toBe(true); + } finally { + Object.defineProperty(AbortSignal, "any", { + value: originalAny, + configurable: true, + writable: true, + }); + } + }); + }); }); + diff --git a/packages/trigger-sdk/src/v3/chat.ts b/packages/trigger-sdk/src/v3/chat.ts index ba74aff2cc2..ec6ba027a91 100644 --- a/packages/trigger-sdk/src/v3/chat.ts +++ b/packages/trigger-sdk/src/v3/chat.ts @@ -46,6 +46,7 @@ function byteLength(body: string): number { } import { ChatTabCoordinator } from "./chat-tab-coordinator.js"; import { + anyAbortSignal, MAX_EOF_RESUBSCRIBES, slimSubmitMessageForWire, waitBeforeEofResubscribe, @@ -1355,7 +1356,7 @@ export class TriggerChatTransport implements ChatTransport { this.activeStreams.set(options.chatId, abortController); const abortSignal = options.abortSignal - ? AbortSignal.any([options.abortSignal, abortController.signal]) + ? anyAbortSignal([options.abortSignal, abortController.signal]) : abortController.signal; return this.subscribeToSessionStream(state, abortSignal, options.chatId, { @@ -2064,7 +2065,7 @@ export class TriggerChatTransport implements ChatTransport { let didSettle = false; this.activeStreams.set(chatId, internalAbort); const combinedSignal = abortSignal - ? AbortSignal.any([abortSignal, internalAbort.signal]) + ? anyAbortSignal([abortSignal, internalAbort.signal]) : internalAbort.signal; if (abortSignal) {