GeezerVison is showing github.com — bug: Chat transport crashes on Safari < 17.4: AbortSignal.any is not a function · Issue #5098 · triggerdotdev/trigger.dev
bug: Chat transport crashes on Safari < 17.4: AbortSignal.any is not a function #5098DescriptionProvide environment information
System:
OS: macOS 27.0.1
CPU: (12) arm64 Apple M4 Pro
Memory: 24.00 GB
Shell: 5.9 - zsh
Binaries:
Node: 24.12.0
npm: 11.6.2
pnpm: 10.33.0
bun: 1.3.7
Affected client (end user's browser):
Safari 17.2.1 on macOS (Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2.1 Safari/605.1.15)
Packages:
@trigger.dev/sdk 4.8.3 (also reproduced on 4.7.3)
ai 7.0.162
next 16 (App Router), React 19
Describe the bug
The browser-side chat transport (useTriggerChatTransport from @trigger.dev/sdk/chat/react) calls AbortSignal.any without checking that it exists. Safari only added AbortSignal.any in 17.4, so on Safari 17.0–17.3 (and 16.x) every chat request throws:
TypeError: AbortSignal.any is not a function. (In 'AbortSignal.any([t,o.signal])', 'AbortSignal.any' is undefined)
at subscribeToSessionStream
The AI SDK always passes an abortSignal to sendMessages / reconnectToStream, so the AbortSignal.any branch is hit on every send and every resume. Chat is unusable for these users.
Next.js supports Safari 16.4+ by default, so these browsers are still in the supported range for most apps using the SDK.
Unguarded call sites in 4.8.3:
- dist/esm/v3/chat.js:614 (reconnectToStream)
- dist/esm/v3/chat.js:1210 (subscribeToSessionStream)
- dist/esm/v3/chat-client.js:571
(and the matching commonjs builds)
Reproduction repo
No standalone repo. Any app that uses useTriggerChatTransport with useChat reproduces it on Safari < 17.4, or on any browser after removing the method (see steps below).
To reproduce
- Set up a chat using
useTriggerChatTransport from @trigger.dev/sdk/chat/react with useChat from @ai-sdk/react.
- Open the app in Safari 17.2 (or any Safari < 17.4).
Alternatively, in any modern browser, run delete AbortSignal.any in the devtools console before the chat mounts.
- Send a message, or reload a page with an active chat so it resumes the stream.
- The transport throws
TypeError: AbortSignal.any is not a function and the chat request fails.
Suggested fix
v3/sessions.js already guards this for Node 18 (if (typeof AbortSignal.any === "function") ... else { wire the caller signal manually }). Applying the same guard, or a small shared helper, to the chat transport would fix it. For example:
function anySignal(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;
}
and use it at the three call sites in chat.js and chat-client.js.
Additional information
We've worked around it on our side by polyfilling AbortSignal.any in instrumentation-client.ts. It would still be good to fix in the SDK, since apps using the chat transport won't know they need the polyfill until older-Safari users report broken chat. Activitypriyanshumakes commented on Nov 10, 2026 Thanks for the detailed writeup, I put together a verified fix. Sharing it here since I am not vouched to open PRs yet.
Root cause: TriggerChatTransport (chat.ts) and AgentChat (chat-client.ts) call AbortSignal.any() unconditionally in three places (chat.ts:1358, chat.ts:2067, chat-client.ts:818). Safari only added AbortSignal.any in 17.4, so on 17.0-17.3 and older every chat request throws TypeError: AbortSignal.any is not a function.
Suggested fix: a small anyAbortSignal() helper in ai-shared.ts that uses the native AbortSignal.any when available and falls back to listener forwarding on older runtimes, with matching semantics: an already-aborted input aborts the result immediately with the same reason, the first abort wins, and listeners detach once the combined signal fires. The three call sites each become a one-line swap.
Verification: added anyAbortSignal.test.ts with 8 tests covering the native path and the fallback path (Safari < 17.4 simulated by removing AbortSignal.any). Ran the SDK's chat test suites locally: 212 tests across 11 files, all passing (204 existing + 8 new). No new typecheck errors from the changed files.
Full diff (4 files, +142/-3)
diff --git a/packages/trigger-sdk/src/v3/ai-shared.ts b/packages/trigger-sdk/src/v3/ai-shared.ts
index d0434e2..08d655b 100644
--- a/packages/trigger-sdk/src/v3/ai-shared.ts
+++ b/packages/trigger-sdk/src/v3/ai-shared.ts
@@ -278,3 +278,40 @@ export function waitBeforeEofResubscribe(attempt: number, signal: AbortSignal):
if (signal.aborted) done();
});
}
+
+/**
+ * Combine several `AbortSignal`s into one that aborts when any of them does.
+ *
+ * Uses the native `AbortSignal.any` when available and falls back to manual
+ * listener forwarding on runtimes without it (Safari / iOS Safari < 17.4, and
+ * older browsers in general). Semantics match the native version: an
+ * already-aborted input produces an immediately-aborted result with the same
+ * `reason`, and the first abort wins.
+ */
+export function anyAbortSignal(signals: AbortSignal[]): AbortSignal {
+ if (typeof AbortSignal.any === "function") {
+ return AbortSignal.any(signals);
+ }
+
+ const controller = new AbortController();
+
+ // `AbortSignal.any` reflects inputs that are already aborted, so the
+ // fallback must too (an already-aborted signal never fires `abort` again).
+ for (const signal of signals) {
+ if (signal.aborted) {
+ controller.abort(signal.reason);
+ return controller.signal;
+ }
+ }
+
+ for (const signal of signals) {
+ signal.addEventListener("abort", () => controller.abort(signal.reason), {
+ once: true,
+ // Detach the listeners on the remaining inputs once the combined
+ // signal aborts, so long-lived signals don't accumulate them.
+ signal: controller.signal,
+ });
+ }
+
+ return controller.signal;
+}
diff --git a/packages/trigger-sdk/src/v3/anyAbortSignal.test.ts b/packages/trigger-sdk/src/v3/anyAbortSignal.test.ts
new file mode 100644
index 0000000..0bb3979
--- /dev/null
+++ b/packages/trigger-sdk/src/v3/anyAbortSignal.test.ts
@@ -0,0 +1,100 @@
+import { describe, it, expect, afterEach } from "vitest";
+import { anyAbortSignal } from "./ai-shared.js";
+
+/**
+ * `AbortSignal.any` only exists on Safari >= 17.4 (and modern Chromium /
+ * Firefox / Node). The fallback path is exercised by temporarily removing
+ * the static method, which is exactly the situation older Safari hits.
+ */
+const nativeAny = AbortSignal.any;
+
+function simulateSafariBelow174() {
+ // @ts-expect-error - deliberately removing the static method to simulate older runtimes
+ delete AbortSignal.any;
+}
+
+afterEach(() => {
+ if (typeof AbortSignal.any !== "function") {
+ AbortSignal.any = nativeAny;
+ }
+});
+
+describe("anyAbortSignal", () => {
+ it("uses native AbortSignal.any when available", () => {
+ const a = new AbortController();
+ const b = new AbortController();
+ const combined = anyAbortSignal([a.signal, b.signal]);
+
+ expect(combined.aborted).toBe(false);
+ a.abort("native-reason");
+ expect(combined.aborted).toBe(true);
+ expect(combined.reason).toBe("native-reason");
+ });
+
+ describe("fallback (runtimes without AbortSignal.any, e.g. Safari < 17.4)", () => {
+ it("starts un-aborted when no input is aborted", () => {
+ simulateSafariBelow174();
+ const a = new AbortController();
+ const b = new AbortController();
+ expect(anyAbortSignal([a.signal, b.signal]).aborted).toBe(false);
+ });
+
+ it("aborts when the first input aborts, preserving the reason", () => {
+ simulateSafariBelow174();
+ const a = new AbortController();
+ const b = new AbortController();
+ const combined = anyAbortSignal([a.signal, b.signal]);
+
+ a.abort("first-reason");
+ expect(combined.aborted).toBe(true);
+ expect(combined.reason).toBe("first-reason");
+ });
+
+ it("aborts when the second input aborts, preserving the reason", () => {
+ simulateSafariBelow174();
+ const a = new AbortController();
+ const b = new AbortController();
+ const combined = anyAbortSignal([a.signal, b.signal]);
+
+ b.abort("second-reason");
+ expect(combined.aborted).toBe(true);
+ expect(combined.reason).toBe("second-reason");
+ });
+
+ it("reflects an already-aborted input immediately", () => {
+ simulateSafariBelow174();
+ const a = new AbortController();
+ a.abort("already-aborted");
+ const b = new AbortController();
+
+ const combined = anyAbortSignal([a.signal, b.signal]);
+ expect(combined.aborted).toBe(true);
+ expect(combined.reason).toBe("already-aborted");
+ });
+
+ it("does not abort twice when more inputs abort after the first", () => {
+ simulateSafariBelow174();
+ const a = new AbortController();
+ const b = new AbortController();
+ const combined = anyAbortSignal([a.signal, b.signal]);
+
+ a.abort("winner");
+ b.abort("late");
+ expect(combined.reason).toBe("winner");
+ });
+
+ it("handles a single input", () => {
+ simulateSafariBelow174();
+ const a = new AbortController();
+ const combined = anyAbortSignal([a.signal]);
+
+ a.abort();
+ expect(combined.aborted).toBe(true);
+ });
+
+ it("never aborts for an empty input list, matching native behavior", () => {
+ simulateSafariBelow174();
+ expect(anyAbortSignal([]).aborted).toBe(false);
+ });
+ });
+});
diff --git a/packages/trigger-sdk/src/v3/chat-client.ts b/packages/trigger-sdk/src/v3/chat-client.ts
index 1a421af..0f765f5 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<TAgent = unknown> {
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.ts b/packages/trigger-sdk/src/v3/chat.ts
index ba74aff..ec6ba02 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<UIMessage> {
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<UIMessage> {
let didSettle = false;
this.activeStreams.set(chatId, internalAbort);
const combinedSignal = abortSignal
- ? AbortSignal.any([abortSignal, internalAbort.signal])
+ ? anyAbortSignal([abortSignal, internalAbort.signal])
: internalAbort.signal;
if (abortSignal) {
Feel free to apply it directly, or I can open a PR if I get vouched.
Disclosure: I worked on this with an AI coding assistant. ghazy2001 commented on Nov 10, 2026 Opened PR #5107 to address this:
- Introduced \�nySignal\ in \packages/trigger-sdk/src/v3/ai-shared.ts\ with native \AbortSignal.any\ delegation and an \AbortController\ fallback for Safari < 17.4 and Node < 20.3.
- Includes listener cleanup upon first abort to prevent event listener leaks on long-lived caller signals.
- Replaced direct \AbortSignal.any\ calls across \chat.ts, \chat-client.ts, \chat-server.ts, and \�i.ts.
- Added unit test suites in \�i-shared.test.ts\ and \chat.test.ts\ verifying fallback and signal combinations (all passing).
MetadataMetadataDevelopmentNo branches or pull requestsIssue actions
|