Skip to content

Commit 73983a7

Browse files
authored
fix(devtools): restore plugin marketplace and harden event bus connection (#466)
The plugin marketplace rendered empty ("No additional plugins available") because the `mounted` -> `package-json-read` round-trip could be lost: - ClientEventBus.emitToServer silently dropped events emitted while the WebSocket was still connecting. They are now buffered and flushed once the socket opens. - The marketplace re-requests package.json on every open and retries until it arrives, so re-opening always re-fetches the plugin list. - The basic React example reconnects to the server bus (connectToServerBus). - Added TanStack AI Devtools to the plugin marketplace registry.
1 parent 8afe61b commit 73983a7

6 files changed

Lines changed: 213 additions & 7 deletions

File tree

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
---
2+
'@tanstack/angular-devtools': patch
3+
'@tanstack/devtools': patch
4+
'@tanstack/devtools-a11y': patch
5+
'@tanstack/devtools-client': patch
6+
'@tanstack/devtools-ui': patch
7+
'@tanstack/devtools-utils': patch
8+
'@tanstack/devtools-vite': patch
9+
'@tanstack/devtools-event-bus': patch
10+
'@tanstack/devtools-event-client': patch
11+
'@tanstack/preact-devtools': patch
12+
'@tanstack/react-devtools': patch
13+
'@tanstack/solid-devtools': patch
14+
'@tanstack/vue-devtools': patch
15+
---
16+
17+
Fix the plugin marketplace rendering empty ("No additional plugins available")
18+
when it should list installable plugins.
19+
20+
- The client event bus no longer silently drops events emitted while its
21+
WebSocket is still connecting. Such events are now queued and flushed once
22+
the socket opens, so the marketplace's `mounted` request reliably reaches the
23+
server bus.
24+
- The marketplace now re-requests `package.json` every time it is opened and
25+
retries until the data arrives, so re-opening always re-fetches the plugin
26+
list.
27+
- Added TanStack AI Devtools (`@tanstack/react-ai-devtools`) to the plugin
28+
marketplace registry.

examples/react/basic/src/setup.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,9 @@ export default function DevtoolsExample() {
5959
return (
6060
<>
6161
<TanStackDevtools
62+
eventBusConfig={{
63+
connectToServerBus: true,
64+
}}
6265
config={{ sourceAction: 'copy-path' }}
6366
plugins={[
6467
{

packages/devtools/src/tabs/plugin-marketplace.tsx

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -189,14 +189,35 @@ export const PluginMarketplace = () => {
189189
},
190190
)
191191

192+
// Request the current package.json every time the marketplace opens.
193+
// The `mounted` -> `package-json-read` round-trip is only triggered here,
194+
// but the event can be dropped if the event bus WebSocket isn't connected
195+
// yet when we emit (it is sent without queueing). When that happens the
196+
// marketplace stays stuck on the empty "all installed" state. Retry until
197+
// the package.json actually arrives so re-opening always re-fetches.
198+
const requestPackageJson = () =>
199+
devtoolsEventClient.emit('mounted', undefined)
200+
201+
let refetchAttempts = 0
202+
const refetchInterval = setInterval(() => {
203+
if (currentPackageJson() || refetchAttempts >= 10) {
204+
clearInterval(refetchInterval)
205+
return
206+
}
207+
refetchAttempts++
208+
requestPackageJson()
209+
}, 400)
210+
192211
onCleanup(() => {
193212
cleanupJsonRead()
194213
cleanupJsonUpdated()
195214
cleanupDevtoolsInstalled()
196215
cleanupPluginAdded()
216+
clearInterval(refetchInterval)
197217
})
198-
// Emit mounted event to trigger package.json read
199-
devtoolsEventClient.emit('mounted', undefined)
218+
219+
// Kick off the initial request immediately on open.
220+
requestPackageJson()
200221
})
201222

202223
const updatePluginCards = (pkg: PackageJson | null) => {

packages/devtools/src/tabs/plugin-registry.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,29 @@ const PLUGIN_REGISTRY: Record<string, PluginMetadata> = {
226226
tags: ['TanStack', 'a11y'],
227227
},
228228

229+
// TanStack AI
230+
'@tanstack/react-ai-devtools': {
231+
packageName: '@tanstack/react-ai-devtools',
232+
title: 'TanStack AI Devtools',
233+
description:
234+
'Debug TanStack AI - inspect messages, token usage, streaming chunks, tool calls, and reasoning.',
235+
requires: {
236+
packageName: '@tanstack/ai-react',
237+
minVersion: '0.8.0',
238+
},
239+
pluginImport: {
240+
importName: 'aiDevtoolsPlugin',
241+
type: 'function',
242+
},
243+
pluginId: 'tanstack-ai',
244+
docsUrl: 'https://tanstack.com/ai',
245+
repoUrl: 'https://github.com/TanStack/ai',
246+
author: 'TanStack',
247+
framework: 'react',
248+
isNew: true,
249+
tags: ['TanStack', 'AI', 'streaming'],
250+
},
251+
229252
// ==========================================
230253
// THIRD-PARTY PLUGINS - Examples
231254
// ==========================================

packages/event-bus/src/client/client.ts

Lines changed: 38 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,10 @@ export class ClientEventBus {
7373
#debug: boolean
7474
#connectToServerBus: boolean
7575
#broadcastChannel: BroadcastChannel | null
76+
// Events emitted while the WebSocket is still establishing its connection.
77+
// They are buffered here and flushed once the socket opens so early events
78+
// (e.g. the marketplace's `mounted` request) are never silently dropped.
79+
#pendingServerEvents: Array<string> = []
7680
#dispatcher = (e: Event) => {
7781
const event = (e as CustomEvent).detail
7882
this.emitToServer(event)
@@ -126,14 +130,36 @@ export class ClientEventBus {
126130
this.#eventTarget.dispatchEvent(globalEvent)
127131
}
128132

133+
private flushPendingServerEvents() {
134+
if (!this.#socket || this.#socket.readyState !== WebSocket.OPEN) {
135+
return
136+
}
137+
const pending = this.#pendingServerEvents
138+
this.#pendingServerEvents = []
139+
for (const json of pending) {
140+
this.debugLog('Flushing queued event to server via WS')
141+
this.#socket.send(json)
142+
}
143+
}
144+
129145
private emitToServer(event: TanStackDevtoolsEvent<string, any>) {
130146
const json = stringifyWithBigInt(event)
131147
// try to emit it to the event bus first
132-
if (this.#socket && this.#socket.readyState === WebSocket.OPEN) {
133-
this.debugLog('Emitting event to server via WS', event)
134-
this.#socket.send(json)
135-
// try to emit to SSE if WebSocket is not available (this will only happen on the client side)
136-
} else if (this.#eventSource) {
148+
if (this.#socket) {
149+
if (this.#socket.readyState === WebSocket.OPEN) {
150+
this.debugLog('Emitting event to server via WS', event)
151+
this.#socket.send(json)
152+
} else if (this.#socket.readyState === WebSocket.CONNECTING) {
153+
// The socket handshake is still in flight. Buffer the event instead of
154+
// dropping it; it will be sent once the connection opens.
155+
this.debugLog('WebSocket still connecting, queueing event', event)
156+
this.#pendingServerEvents.push(json)
157+
}
158+
// CLOSING/CLOSED sockets cannot deliver; the event is dropped.
159+
return
160+
}
161+
// try to emit to SSE if WebSocket is not available (this will only happen on the client side)
162+
if (this.#eventSource) {
137163
this.debugLog('Emitting event to server via SSE', event)
138164

139165
fetch(`${this.#protocol}://${this.#host}:${this.#port}/__devtools/send`, {
@@ -178,6 +204,7 @@ export class ClientEventBus {
178204
this.#socket?.close()
179205
this.#socket = null
180206
this.#eventSource = null
207+
this.#pendingServerEvents = []
181208
}
182209
private getGlobalTarget() {
183210
if (typeof window !== 'undefined') {
@@ -209,13 +236,19 @@ export class ClientEventBus {
209236
this.#socket = new WebSocket(
210237
`${wsProtocol}://${this.#host}:${this.#port}/__devtools/ws`,
211238
)
239+
this.#socket.onopen = () => {
240+
this.debugLog('WebSocket connection opened')
241+
this.flushPendingServerEvents()
242+
}
212243
this.#socket.onmessage = (e) => {
213244
this.debugLog('Received message from server', e.data)
214245
this.handleEventReceived(e.data)
215246
}
216247
this.#socket.onclose = () => {
217248
this.debugLog('WebSocket connection closed')
218249
this.#socket = null
250+
// Drop any still-queued events — there is no open socket to deliver them.
251+
this.#pendingServerEvents = []
219252
}
220253
this.#socket.onerror = () => {
221254
this.debugLog('WebSocket connection error')

packages/event-bus/tests/client.test.ts

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,27 @@ function createMockEventSourceClass() {
4848
}
4949
}
5050

51+
function createConnectingWebSocketClass() {
52+
// Starts in CONNECTING state; tests flip readyState and fire onopen manually.
53+
return class ConnectingWebSocket {
54+
static CONNECTING = 0
55+
static OPEN = 1
56+
static CLOSED = 3
57+
url: string
58+
readyState = 0 // CONNECTING
59+
onopen: any = null
60+
onmessage: any = null
61+
onclose: any = null
62+
onerror: any = null
63+
send = vi.fn()
64+
close = vi.fn()
65+
constructor(url: string) {
66+
this.url = url
67+
mockWebSocketInstances.push(this)
68+
}
69+
}
70+
}
71+
5172
function createThrowingWebSocketClass() {
5273
return class ThrowingWebSocket {
5374
static OPEN = 1
@@ -262,6 +283,83 @@ describe('ClientEventBus', () => {
262283
})
263284
})
264285

286+
describe('emitToServer while WebSocket is connecting', () => {
287+
it('should queue events while connecting and flush them once the socket opens', () => {
288+
vi.stubGlobal('WebSocket', createConnectingWebSocketClass())
289+
290+
const bus = new ClientEventBus({ connectToServerBus: true })
291+
bus.start()
292+
293+
const socket = mockWebSocketInstances[0]
294+
expect(socket.readyState).toBe(0) // CONNECTING
295+
296+
// Events emitted while connecting must not be sent yet...
297+
window.dispatchEvent(
298+
new CustomEvent('tanstack-dispatch-event', {
299+
detail: { type: 'queued:event', payload: { n: 1 } },
300+
}),
301+
)
302+
window.dispatchEvent(
303+
new CustomEvent('tanstack-dispatch-event', {
304+
detail: { type: 'queued:event', payload: { n: 2 } },
305+
}),
306+
)
307+
expect(socket.send).not.toHaveBeenCalled()
308+
309+
// ...but flushed in order as soon as the connection opens.
310+
socket.readyState = 1 // OPEN
311+
socket.onopen?.()
312+
313+
expect(socket.send).toHaveBeenCalledTimes(2)
314+
expect(socket.send.mock.calls[0][0]).toContain('"n":1')
315+
expect(socket.send.mock.calls[1][0]).toContain('"n":2')
316+
bus.stop()
317+
})
318+
319+
it('should send immediately once the socket is open', () => {
320+
vi.stubGlobal('WebSocket', createConnectingWebSocketClass())
321+
322+
const bus = new ClientEventBus({ connectToServerBus: true })
323+
bus.start()
324+
325+
const socket = mockWebSocketInstances[0]
326+
socket.readyState = 1 // OPEN
327+
socket.onopen?.()
328+
329+
window.dispatchEvent(
330+
new CustomEvent('tanstack-dispatch-event', {
331+
detail: { type: 'live:event', payload: {} },
332+
}),
333+
)
334+
335+
expect(socket.send).toHaveBeenCalledTimes(1)
336+
bus.stop()
337+
})
338+
339+
it('should drop queued events if the socket closes before opening', () => {
340+
vi.stubGlobal('WebSocket', createConnectingWebSocketClass())
341+
342+
const bus = new ClientEventBus({ connectToServerBus: true })
343+
bus.start()
344+
345+
const socket = mockWebSocketInstances[0]
346+
window.dispatchEvent(
347+
new CustomEvent('tanstack-dispatch-event', {
348+
detail: { type: 'queued:event', payload: {} },
349+
}),
350+
)
351+
352+
// Connection fails before ever opening.
353+
socket.readyState = 3 // CLOSED
354+
socket.onclose?.()
355+
356+
// Re-flushing (e.g. a late open) must not send the dropped events.
357+
socket.onopen?.()
358+
expect(socket.send).not.toHaveBeenCalled()
359+
bus.stop()
360+
})
361+
})
362+
265363
describe('event dispatching', () => {
266364
it('should emit events to a subscribed listener', () => {
267365
const bus = new ClientEventBus()

0 commit comments

Comments
 (0)