fix(deps): patch eventsource so Bun stops breaking the runtime integration job (#6334)

## What

Fixes the intermittently-red `test / integration / runtime` **bun** leg.
Three commits, smallest blast radius first:

1. **`ci(runtime)`** — pin `bun-version` from `latest` to `1.3.14` so a
Bun release can't change module-resolution behaviour between runs. (Only
`bun-version: latest` in the repo.)
2. **`fix(deps)`** — **this is the actual fix.** Patch
`eventsource@3.0.7` to drop its `bun` export condition, via `pnpm patch`
+ `patchedDependencies`.
3. **`refactor(runtime)`** — module-graph hygiene: load the MCP SSE
transport lazily. Explicitly **not** a behaviour fix; commit 2 is.

## Root cause

```
TypeError: require() async module ".../eventsource@3.0.7/node_modules/eventsource/dist/index.js" is unsupported. use "await import()" instead.
    at .../@modelcontextprotocol/sdk/dist/cjs/client/sse.js:4:7
    at .../@ag-ui/mcp-apps-middleware/dist/index.js:1:983
    at processTicksAndRejections (unknown:7:39)
```

- `eventsource@3.0.7` maps its `bun` export condition to the **ESM**
build (`dist/index.js`). Bun resolves `bun` **before** `require`, so a
CJS `require("eventsource")` receives an async ESM module and throws.
The package ships a real CJS build (`dist/index.cjs`) behind `require`,
but Bun never reaches it.
- Two CJS consumers in our graph hit this: the MCP SDK's own
`dist/cjs/client/sse.js`, and `@ag-ui/mcp-apps-middleware@0.0.3` — a
CJS-only package (`main: ./dist/index.js`, no `exports`, no `type:
module`) that `require`s that SDK path unconditionally at module load.
- **Why intermittent:** it's a load-order race. If the ESM graph fully
evaluates `eventsource` first, the later CJS `require` can be served
synchronously and the run passes; otherwise it throws.

Dropping the `bun` key makes Bun fall through to `import` for ESM
consumers (same `dist/index.js` as before — no behaviour change) and to
`require` for CJS consumers (`dist/index.cjs`, which is what they need).
Only `bun` is touched; `deno`/`source`/`import`/`require`/`default` are
left alone.

**A version bump is not an alternative:** `eventsource@4.1.0` still
ships the same `bun` → ESM mapping.

## Patch diff

`patches/eventsource@3.0.7.patch` (header abridged — the file carries
the full rationale and an explicit deletion criterion so it doesn't
become permanent by accident):

```diff
# Drops the `bun` export condition from eventsource.
# ...
# DELETE THIS PATCH WHEN: eventsource drops the `bun` condition or points it at
# dist/index.cjs, OR Bun stops preferring `bun` over `require` for CJS requires.
diff --git a/package.json b/package.json
@@ -10,7 +10,6 @@
   "exports": {
     ".": {
       "deno": "./dist/index.js",
-      "bun": "./dist/index.js",
       "source": "./src/index.ts",
       "import": "./dist/index.js",
       "require": "./dist/index.cjs",
```

Root `package.json` gains:

```json
"patchedDependencies": { "eventsource@3.0.7": "patches/eventsource@3.0.7.patch" }
```

This repo had no `patches/` precedent (it uses `pnpm.overrides`), so
this sets one — hence the minimal one-line patch and the documented
removal criterion.

## Red-green proof

All four states. Local runs are the **same command on the same
machine**, differing only by whether the patch is applied. Bun 1.3.14,
macOS arm64, run from `packages/runtime`:

```sh
bun test src/v2/runtime/__tests__/integration/bun/bun-servers.integration.test.ts
```

A single green run proves nothing here — it's a race — so both local
states are N=20.

### 1. CI-RED

- This branch before the patch, run
[30835752558](https://github.com/CopilotKit/CopilotKit/actions/runs/30835752558)
@ `5456e308b9` — `runtime / node` success, **`runtime / bun` failure**:

```
4 | const eventsource_1 = require("eventsource");
TypeError: require() async module "/home/runner/work/CopilotKit/CopilotKit/node_modules/.pnpm/eventsource@3.0.7/node_modules/eventsource/dist/index.js" is unsupported. use "await import()" instead.
 0 pass
 1 fail
```

- Also on `main` @ `26a23bbf3a`, run
[30825667393](https://github.com/CopilotKit/CopilotKit/actions/runs/30825667393)
— same leg, same failure.

### 2. LOCAL-RED (eventsource UNPATCHED, N=20)

```
run  1:  72 pass  0 fail
run  2:   0 pass  1 fail
run  3:   0 pass  1 fail
run  4:   0 pass  1 fail
run  5:   0 pass  1 fail
run  6:   0 pass  1 fail
run  7:   0 pass  1 fail
run  8:   0 pass  1 fail
run  9:   0 pass  1 fail
run 10:  72 pass  0 fail
run 11:   0 pass  1 fail
run 12:   0 pass  1 fail
run 13:  72 pass  0 fail
run 14:   0 pass  1 fail
run 15:   0 pass  1 fail
run 16:  72 pass  0 fail
run 17:   0 pass  1 fail
run 18:  72 pass  0 fail
run 19:   0 pass  1 fail
run 20:   0 pass  1 fail
LOCAL-RED TOTAL: pass=5 fail=15  (out of 20)
```

### 3. LOCAL-GREEN (eventsource PATCHED, N=20)

```
run  1:  72 pass  0 fail
run  2:  72 pass  0 fail
run  3:  72 pass  0 fail
run  4:  72 pass  0 fail
run  5:  72 pass  0 fail
run  6:  72 pass  0 fail
run  7:  72 pass  0 fail
run  8:  72 pass  0 fail
run  9:  72 pass  0 fail
run 10:  72 pass  0 fail
run 11:  72 pass  0 fail
run 12:  72 pass  0 fail
run 13:  72 pass  0 fail
run 14:  72 pass  0 fail
run 15:  72 pass  0 fail
run 16:  72 pass  0 fail
run 17:  72 pass  0 fail
run 18:  72 pass  0 fail
run 19:  72 pass  0 fail
run 20:  72 pass  0 fail
LOCAL-GREEN TOTAL: pass=20 fail=0  (out of 20)
```

**5/20 → 20/20.**

### 4. CI-GREEN

The `test / integration / runtime` bun leg on this PR is the
load-bearing evidence. See checks below.

## Clean-install verification

A patch that only works incrementally is worthless in CI, so this was
verified from scratch — every `node_modules` in the workspace deleted,
then `pnpm install --frozen-lockfile`:

- Install exited **0** with `--frozen-lockfile` (lockfile is
self-consistent; no drift).
- Exactly one `eventsource` entry in the store, and it is the patched
one:

`node_modules/.pnpm/eventsource@3.0.7_patch_hash=427032a8df76e38f39988ff5fb919a02ccb70eb8a235e2b484007e4bebb1e67e/`
- Resolved `package.json` in the store after clean install:

`{"deno":"./dist/index.js","source":"./src/index.ts","import":"./dist/index.js","require":"./dist/index.cjs","default":"./dist/index.js"}`
— `bun` absent, everything else intact.
- Lockfile records it deterministically:
`patchedDependencies.eventsource@3.0.7` with `hash: 427032a8...` and
`path: patches/eventsource@3.0.7.patch`, and the dependency edge
resolves as `eventsource@3.0.7(patch_hash=427032a8...)`.
- `--frozen-lockfile` accepted the lockfile verbatim (it does not
rewrite), so the lockfile is self-consistent with the manifests.
- The comment header on the patch file does not break pnpm's patch
applier.
- **Lockfile diff is scoped to eventsource — 9 lines, 3 hunks, nothing
else.** An earlier revision of this branch carried incidental drift
(`vue-component-type-helpers` 3.3.8→3.3.9 and a `vite` peer-range
narrowing) picked up by a non-frozen install; that has been reverted so
the diff contains only the patch wiring.

## Tests

All from `packages/runtime`, with the patch applied:

| Suite | Command | Result |
|---|---|---|
| Full runtime suite | `pnpm exec vitest run` | **130 files / 1835 tests
passed**, 0 failed |
| Node integration (other CI leg) | `pnpm exec vitest run
src/v2/runtime/__tests__/integration/node-servers.integration.test.ts` |
**153 passed** |
| MCP + SSE transport | `pnpm exec vitest run
src/agent/__tests__/mcp-servers-integration.test.ts
src/agent/__tests__/mcp-clients.test.ts
src/v2/runtime/__tests__/mcp-apps-middleware-integration.test.ts` | **3
files / 22 passed** |
| Bun integration | `bun test .../bun-servers.integration.test.ts` |
**20/20** (was 5/20) |

Non-Bun consumers are unaffected by construction — Node never reads the
`bun` export condition — and the Node suites above confirm it. The SSE
path stays covered: `mcp-servers-integration.test.ts` exercises
`mcpServers: [{ type: "sse", url }]`, so it executes the new `await
import()`, which sits **outside** the `try/catch` that swallows
per-server connection failures.

## Module-graph proof for commit 3

Commit 3 is hygiene, so it gets its own narrower proof. Probe: Bun
populates `require.cache` with the resolved path of every module
actually loaded, so importing one module and inspecting that cache shows
whether `eventsource` entered the graph. Two controls run every time so
it can't pass vacuously.

```ts
const target = process.argv[2]!;
await import(target);
const keys = Object.keys(require.cache).filter(
  (k) => /eventsource/.test(k) && !/eventsource-parser/.test(k),
);
console.log(`${target}\n  eventsource loaded: ${keys.length > 0 ? "YES" : "NO"}`);
```

| Module | before commit 3 | after commit 3 |
|---|---|---|
| `@copilotkit/shared` (negative control) | NO | NO |
| `@modelcontextprotocol/sdk/client/sse.js` (positive control) | YES |
YES |
| `../src/agent/index.ts` (subject, non-SSE path) | **YES** | **NO** |

Both controls hold steady; only the subject flips. Measured on its own,
commit 3 does **not** move the bun pass rate (5/20 before, 3/20 after
within noise) — which is exactly why commit 2 exists.

## Typing

No `as any`, no `@ts-ignore`. `const { SSEClientTransport } = await
import(...)` keeps the class fully typed — TypeScript resolves
dynamic-import types statically. `packages/runtime/tsconfig.json`
already sets `"module": "es2022"` with the comment *"so dynamic import()
typechecks"*, so the pattern is anticipated.

Two adjacent bare `let` declarations (`transport`, `mcpClient`) gained
explicit annotations (`MCPTransport | undefined`, `MCPClient`) because
editors surface them as implicit-any suggestions. Both pre-existed on
`main`. Verified: `tsc --noEmit` clean; `tsc --noEmit --strict` error
set **identical to baseline** (3 pre-existing unrelated `TS2769`s);
`oxlint` warnings **unchanged from baseline** (2, both pre-existing).

`SSEClientTransport` is `@deprecated` in SDK 1.29.0 in favour of
`StreamableHTTPClientTransport`. That deprecation pre-exists on `main`
and is left alone: `type: "sse"` is documented public config, SSE and
Streamable HTTP are different wire protocols, and the SDK's own note
says clients "may need to support both transports during the migration
period." Migrating is a user-facing change for its own PR.

## Gates run

- `pnpm exec oxfmt --check packages/runtime/src/agent/index.ts` — clean
- `pnpm exec oxlint packages/runtime/src/agent/index.ts` — 0 errors, 2
warnings (both pre-existing on `main`)
- `pnpm nx run @copilotkit/runtime:check-types` — pass
- `pnpm exec commitlint --from HEAD~3 --to HEAD` — pass
- `pnpm install --frozen-lockfile` from a fully wiped workspace — exit 0
This commit is contained in:
Mark
2026-08-03 13:33:48 -07:00
committed by GitHub
5 changed files with 66 additions and 7 deletions
@@ -98,7 +98,9 @@ jobs:
- name: Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
bun-version: latest
# Pinned so a Bun release can't change module-resolution behaviour
# under us between runs.
bun-version: 1.3.14
- name: Install dependencies
run: pnpm install --frozen-lockfile
+3
View File
@@ -173,6 +173,9 @@
"@ai-sdk/mcp": "1.0.21",
"storybook@>=8.0.0 <8.6.17": "8.6.17",
"path-to-regexp@>=8.0.0 <8.4.0": ">=8.4.0"
},
"patchedDependencies": {
"eventsource@3.0.7": "patches/eventsource@3.0.7.patch"
}
}
}
+10 -4
View File
@@ -40,7 +40,7 @@ import type {
} from "ai";
import { streamText, tool as createVercelAISDKTool, stepCountIs } from "ai";
import { createMCPClient } from "@ai-sdk/mcp";
import type { MCPClient } from "@ai-sdk/mcp";
import type { MCPClient, MCPTransport } from "@ai-sdk/mcp";
import { Observable } from "rxjs";
import { createOpenAI } from "@ai-sdk/openai";
import { createAnthropic } from "@ai-sdk/anthropic";
@@ -55,7 +55,6 @@ import { convertAISDKStream } from "./converters/aisdk";
import { convertTanStackStream } from "./converters/tanstack";
import type { StreamableHTTPClientTransportOptions } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
import { randomUUID } from "@copilotkit/shared";
/**
@@ -1353,7 +1352,7 @@ export class BuiltInAgent extends AbstractAgent {
];
if (allMcpServers.length > 0) {
for (const serverConfig of allMcpServers) {
let transport;
let transport: MCPTransport | undefined;
if (serverConfig.type === "http") {
const url = new URL(serverConfig.url);
@@ -1362,6 +1361,13 @@ export class BuiltInAgent extends AbstractAgent {
serverConfig.options,
);
} else if (serverConfig.type === "sse") {
// Imported lazily: the SDK's SSE transport pulls in
// `eventsource`, whose `bun` export condition resolves to ESM
// and breaks the SDK's own CJS `require()` under Bun. Keeping
// it out of this module's static graph means only configs that
// actually ask for SSE ever load it.
const { SSEClientTransport } =
await import("@modelcontextprotocol/sdk/client/sse.js");
transport = new SSEClientTransport(
new URL(serverConfig.url),
serverConfig.headers,
@@ -1373,7 +1379,7 @@ export class BuiltInAgent extends AbstractAgent {
// bad auth) must NOT fail the whole run — skip it and continue
// with the healthy servers and the agent's own tools. The run
// degrades gracefully instead of erroring out.
let mcpClient;
let mcpClient: MCPClient;
try {
mcpClient = await createMCPClient({ transport });
} catch (err) {
+43
View File
@@ -0,0 +1,43 @@
# Drops the `bun` export condition from eventsource.
#
# Upstream defect: eventsource maps its `bun` condition to the ESM build
# (dist/index.js). Bun resolves `bun` BEFORE `require`, so when a CJS consumer
# does require("eventsource") under Bun it receives an async ESM module and
# throws:
#
# TypeError: require() async module ".../eventsource/dist/index.js" is
# unsupported. use "await import()" instead.
#
# The package ships a real CJS build (dist/index.cjs) behind `require`, but Bun
# never reaches it. Two CJS consumers in our graph hit this — the MCP SDK's own
# dist/cjs/client/sse.js, and @ag-ui/mcp-apps-middleware, which requires that
# SDK path unconditionally at module load. It surfaces as an intermittent
# failure of the runtime bun integration test, intermittent because it is a
# load-order race: if the ESM graph evaluates eventsource first, the later CJS
# require can be served synchronously and the run passes.
#
# Removing `bun` makes Bun fall through to `import` for ESM consumers (same
# dist/index.js as before, so no behaviour change) and to `require` for CJS
# consumers (dist/index.cjs, which is what they actually need). Only the `bun`
# key is touched; deno/source/import/require/default are left alone.
#
# A version bump is NOT an alternative: eventsource 4.1.0 still ships the same
# `bun` -> ESM mapping.
#
# DELETE THIS PATCH WHEN: eventsource drops the `bun` condition or points it at
# dist/index.cjs, OR Bun stops preferring `bun` over `require` for CJS requires.
# To verify it is still needed, remove the patch and run, from packages/runtime:
# bun test src/v2/runtime/__tests__/integration/bun/bun-servers.integration.test.ts
# repeatedly (it is a race, so a single green run proves nothing -- 20 runs).
diff --git a/package.json b/package.json
index 351e4e135068ea7611fa3f5f025b7a4a90b886bf..e21929c7a49ae1e2e5dc6e743d291e9c595bd78d 100644
--- a/package.json
+++ b/package.json
@@ -10,7 +10,6 @@
"exports": {
".": {
"deno": "./dist/index.js",
- "bun": "./dist/index.js",
"source": "./src/index.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs",
+7 -2
View File
@@ -81,6 +81,11 @@ overrides:
pnpmfileChecksum: sha256-Yeprb3L5TyOmoppd3E/Q880zwJi1e3M2rgrViqRvicc=
patchedDependencies:
eventsource@3.0.7:
hash: 427032a8df76e38f39988ff5fb919a02ccb70eb8a235e2b484007e4bebb1e67e
path: patches/eventsource@3.0.7.patch
importers:
.:
@@ -35313,7 +35318,7 @@ snapshots:
content-type: 1.0.5
cors: 2.8.5
cross-spawn: 7.0.6
eventsource: 3.0.7
eventsource: 3.0.7(patch_hash=427032a8df76e38f39988ff5fb919a02ccb70eb8a235e2b484007e4bebb1e67e)
eventsource-parser: 3.0.6
express: 5.2.1
express-rate-limit: 8.4.1(express@5.2.1)
@@ -45376,7 +45381,7 @@ snapshots:
eventsource-parser@3.0.8: {}
eventsource@3.0.7:
eventsource@3.0.7(patch_hash=427032a8df76e38f39988ff5fb919a02ccb70eb8a235e2b484007e4bebb1e67e):
dependencies:
eventsource-parser: 3.0.8