Skip to content

EventClient.on() double-prefixes event names + broken on server without withEventTarget #341

@tecoad

Description

@tecoad

Describe the bug

aiEventClient.on() does not work as expected due to two issues in EventClient from @tanstack/devtools-event-client:

1. Double-prefixed event names

The AIDevtoolsEventMap type uses fully-prefixed keys (e.g. "tanstack-ai-devtools:text:message:created"), but both emit() and on() already prepend the pluginId at runtime:

// EventClient.on()
const eventName = `${this.#pluginId}:${eventSuffix}`;
// → "tanstack-ai-devtools:tanstack-ai-devtools:text:message:created" ❌

The emit() calls in @tanstack/ai source correctly use the short suffix:

// src/activities/chat/index.ts
aiEventClient.emit('text:message:created', { ... }) // ✅ correct

So on() also needs the short suffix to match, but TypeScript demands the full key from AIDevtoolsEventMap:

// TypeScript expects this (from the type map), but it double-prefixes at runtime:
aiEventClient.on("tanstack-ai-devtools:text:message:created", cb) // ❌ won't fire

// This works at runtime, but TypeScript errors:
aiEventClient.on("text:message:created", cb) // ✅ works, but TS error

Fix: The keys in AIDevtoolsEventMap should use the short suffix (without the tanstack-ai-devtools: prefix), since the EventClient already prepends the pluginId.

2. on() doesn't work on the server without { withEventTarget: true }

On server environments (Cloudflare Workers, Node, Bun) where there is no window and globalThis.__TANSTACK_EVENT_TARGET__ is not set, getGlobalTarget() falls through to:

const eventTarget = typeof EventTarget !== "undefined" ? new EventTarget() : void 0;
return eventTarget;

Since getGlobalTarget() is not cached (called via this.#eventTarget() each time), every call creates a new EventTarget instance. This means emit() and on() dispatch/listen on different targets — events never reach the listener.

Workaround: Passing { withEventTarget: true } to on() forces use of this.#internalEventTarget, which is a single shared instance on the EventClient. Then emit() also dispatches to it.

aiEventClient.on(
  // @ts-expect-error -- see issue #1 above
  "text:message:created",
  (e) => console.log(e.payload),
  { withEventTarget: true } // required on server
)

Fix: getGlobalTarget() should cache the EventTarget it creates, so the same instance is used across all on()/emit() calls.

Your minimal, reproducible example

// In a TanStack Start server handler (e.g. API route on Cloudflare Workers)
import { aiEventClient, chat } from "@tanstack/ai"

// ❌ Does NOT fire — double prefix + separate EventTargets on server
aiEventClient.on("tanstack-ai-devtools:text:message:created", (e) => {
  console.log(e.payload.content)
})

// ✅ Works with both fixes applied
aiEventClient.on(
  // @ts-expect-error
  "text:message:created",
  (e) => console.log(e.payload.content),
  { withEventTarget: true }
)

Steps to reproduce

  1. Set up a TanStack Start app with @tanstack/ai on a server environment (Cloudflare Workers, Node, or Bun)
  2. Register a listener with aiEventClient.on("tanstack-ai-devtools:text:message:created", cb)
  3. Trigger a chat() call
  4. Observe: the callback never fires

Expected behavior

aiEventClient.on("text:message:created", cb) should work without @ts-expect-error and without needing { withEventTarget: true } on the server.

How often does this bug happen?

Every time

Package version

@tanstack/ai 0.6.1, @tanstack/devtools-event-client 0.4.0

TypeScript version

5.x

Additional context

Found while building a chat API route in a TanStack Start + Cloudflare Workers app. The { withEventTarget: true } option works as a workaround for both issues.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions