Skip to content

Commit 958c683

Browse files
feat: deliver devtools events from isolated server runtimes via Vite HotChannel (#384)
* docs: add design spec for network transport fallback in isolated server runtimes Addresses the issue where devtools events are lost when server code runs in isolated environments (Nitro v3 worker threads, Cloudflare Workers, etc.) that don't share globalThis with the Vite main thread. * docs: address spec review findings for network transport fallback Fix problem description precision, URL matching and handleNewConnection signature issues, POST handler routing, placeholder convention, triplicate interface sync, queue preservation, and multi-worker echo safety. * docs: clarify dual handler paths in network transport spec Disambiguate that both standalone and external server POST/upgrade handlers need updates, and that only WebSocket URL matching needs prefix change (not SSE/POST URLs). * docs: add implementation plan for network transport fallback 7-task plan covering: interface updates, ServerEventBus bridge support, POST handler routing, RingBuffer utility, EventClient network transport detection, WebSocket connection/emit/receive, and integration tests. * feat: add eventId and source fields to TanStackDevtoolsEvent interface * feat: add server bridge WebSocket connection support to ServerEventBus - Accept WebSocket connections with ?bridge=server query parameter - Track bridge clients separately for proper routing - Bridge messages route through emit() (broadcast to WS clients + EventTarget) - Regular browser messages route through emitToServer() (EventTarget only) - Clean up bridge client tracking on disconnect and stop() * feat: add source-based routing to POST handlers for server bridge support * feat: add RingBuffer utility for event ID deduplication * feat: add network transport detection and compile-time placeholders to EventClient * feat: add WebSocket network transport fallback to EventClient When EventClient detects it is in an isolated server environment (no shared globalThis.__TANSTACK_EVENT_TARGET__, no window), it automatically connects to ServerEventBus via WebSocket. Bidirectional: events emitted in the worker reach the devtools panel, and events from the devtools panel reach listeners in the worker. Includes echo prevention via 200-entry ring buffer, exponential backoff reconnection, HTTP POST fallback, and event queuing. * fix: improve WebSocket error handling and destroy cleanup in EventClient - Add scheduleReconnect() call in error handler for non-browser runtimes where 'close' may not follow 'error' - Reset wsGaveUp, wsReconnectAttempts, wsReconnectDelay in ___destroyNetworkTransport for safe reuse * test: add end-to-end integration tests for network transport fallback * docs: mark network transport fallback spec as implemented * ci: apply automated fixes * feat: add Nitro v3 and Cloudflare Workers test examples Two minimal examples for manually testing the network transport fallback: - examples/react/start-nitro — TanStack Start + Nitro v3 (worker threads) - examples/react/start-cloudflare — TanStack Start + Cloudflare Workers Both emit devtools events from server functions and display them in a custom "Server Events" devtools panel. If events appear in the panel, the network transport fallback is working correctly. * Refactor code structure for improved readability and maintainability * docs: design spec for native Vite HotChannel runtime bridge * docs: implementation plan for native Vite HotChannel runtime bridge * feat(devtools-vite): generate worker-side runtime bridge code * feat(devtools-vite): inject runtime bridge into event-client for server envs * feat(devtools-vite): wire dev-server hot channels to ServerEventBus * feat(devtools-vite): hook runtime bridge into plugin and dev server * chore: add devtools-vite changeset, remove planning docs from PR * fix(devtools-vite): tear down runtime-bridge hot handlers to prevent duplicate events * ci: apply automated fixes * chore: align start-nitro/start-cloudflare examples to vite 8 (fix sherif) * test(devtools-vite): use explicit function types in runtime-bridge mocks (eslint) --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
1 parent c637466 commit 958c683

28 files changed

Lines changed: 2052 additions & 42 deletions
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
'@tanstack/devtools-vite': minor
3+
---
4+
5+
feat: deliver devtools events from isolated server runtimes over Vite's native HotChannel
6+
7+
Server code running in an isolated runtime (Nitro v3 worker thread, Cloudflare `workerd`, or any separate thread/process) does not share `globalThis.__TANSTACK_EVENT_TARGET__` with the Vite dev process, so devtools events emitted on the server never reached the panel.
8+
9+
The Vite plugin now bridges those events over the framework's existing `import.meta.hot` HotChannel — the same connection the runtime already uses for HMR. It injects a tiny, dev-only bridge into the event client when it runs in a non-client environment and wires each server environment's hot channel to the in-process `ServerEventBus`. No new WebSocket, no fetch, no reconnect logic, and no new runtime dependencies; the bridge is fully tree-shaken in production.

.gitignore

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ yarn.lock
5454
.nx/workspace-data
5555
vite.config.js.timestamp-*
5656
vite.config.ts.timestamp-*
57-
57+
.claude/worktrees
5858
.angular
5959
.nitro
6060
.sonda
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
node_modules
2+
.DS_Store
3+
dist
4+
dist-ssr
5+
*.local
6+
.env
7+
.nitro
8+
.tanstack
9+
.wrangler
10+
.output
11+
.vinxi
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
{
2+
"name": "start-cloudflare",
3+
"private": true,
4+
"type": "module",
5+
"scripts": {
6+
"dev": "vite dev --port 3002",
7+
"build": "vite build",
8+
"preview": "vite preview",
9+
"deploy": "npm run build && wrangler deploy"
10+
},
11+
"dependencies": {
12+
"@cloudflare/vite-plugin": "^1.13.8",
13+
"@tanstack/devtools-event-client": "workspace:*",
14+
"@tanstack/react-devtools": "workspace:*",
15+
"@tanstack/react-router": "^1.132.0",
16+
"@tanstack/react-start": "^1.132.0",
17+
"@tanstack/router-plugin": "^1.132.0",
18+
"react": "^19.2.0",
19+
"react-dom": "^19.2.0",
20+
"vite-tsconfig-paths": "^6.0.2"
21+
},
22+
"devDependencies": {
23+
"@tanstack/devtools-vite": "workspace:*",
24+
"@types/node": "^22.15.2",
25+
"@types/react": "^19.2.0",
26+
"@types/react-dom": "^19.2.0",
27+
"@vitejs/plugin-react": "^6.0.1",
28+
"typescript": "~5.9.2",
29+
"vite": "^8.0.0",
30+
"wrangler": "^4.40.3"
31+
}
32+
}
Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
import { useEffect, useState } from 'react'
2+
import { serverEventClient } from './server-event-client'
3+
import type { ServerEvent } from './server-event-client'
4+
5+
export function ServerEventsPanel() {
6+
const [events, setEvents] = useState<Array<ServerEvent>>([])
7+
8+
useEffect(() => {
9+
const cleanup = serverEventClient.on(
10+
'server-fn-called',
11+
(event) => {
12+
setEvents((prev) => [event.payload, ...prev].slice(0, 100))
13+
},
14+
{ withEventTarget: true },
15+
)
16+
17+
return cleanup
18+
}, [])
19+
20+
const formatTime = (timestamp: number) => {
21+
return new Date(timestamp).toLocaleTimeString('en-US', {
22+
hour12: false,
23+
hour: '2-digit',
24+
minute: '2-digit',
25+
second: '2-digit',
26+
fractionalSecondDigits: 3,
27+
})
28+
}
29+
30+
return (
31+
<div
32+
style={{
33+
padding: '16px',
34+
fontFamily: 'system-ui, sans-serif',
35+
height: '100%',
36+
overflow: 'auto',
37+
}}
38+
>
39+
<div
40+
style={{
41+
display: 'flex',
42+
justifyContent: 'space-between',
43+
alignItems: 'center',
44+
marginBottom: '16px',
45+
}}
46+
>
47+
<h2
48+
style={{
49+
margin: 0,
50+
fontSize: '16px',
51+
fontWeight: 600,
52+
color: 'inherit',
53+
}}
54+
>
55+
Server Events ({events.length})
56+
</h2>
57+
<button
58+
onClick={() => setEvents([])}
59+
style={{
60+
padding: '4px 12px',
61+
fontSize: '12px',
62+
borderRadius: '4px',
63+
border: '1px solid currentColor',
64+
background: 'transparent',
65+
color: 'inherit',
66+
cursor: 'pointer',
67+
opacity: 0.7,
68+
}}
69+
>
70+
Clear
71+
</button>
72+
</div>
73+
74+
<div
75+
style={{
76+
padding: '12px',
77+
borderRadius: '8px',
78+
background: 'rgba(249, 115, 22, 0.1)',
79+
border: '1px solid rgba(249, 115, 22, 0.3)',
80+
marginBottom: '16px',
81+
fontSize: '13px',
82+
lineHeight: 1.5,
83+
}}
84+
>
85+
These events are emitted from <strong>server functions</strong> running
86+
in Cloudflare Workers' isolated environment. If you see events appearing
87+
here, the network transport fallback is working correctly.
88+
</div>
89+
90+
{events.length === 0 ? (
91+
<div
92+
style={{
93+
padding: '24px',
94+
textAlign: 'center',
95+
opacity: 0.5,
96+
fontSize: '14px',
97+
}}
98+
>
99+
No server events yet.
100+
<br />
101+
Click "Call Server Function" to emit an event.
102+
</div>
103+
) : (
104+
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
105+
{events.map((ev, index) => (
106+
<div
107+
key={`${ev.timestamp}-${index}`}
108+
style={{
109+
padding: '12px',
110+
borderRadius: '8px',
111+
background: 'rgba(128, 128, 128, 0.1)',
112+
border: '1px solid rgba(128, 128, 128, 0.2)',
113+
}}
114+
>
115+
<div
116+
style={{
117+
display: 'flex',
118+
justifyContent: 'space-between',
119+
alignItems: 'flex-start',
120+
marginBottom: '4px',
121+
}}
122+
>
123+
<span
124+
style={{
125+
fontWeight: 600,
126+
fontSize: '14px',
127+
color: '#f97316',
128+
}}
129+
>
130+
{ev.name}
131+
</span>
132+
<span
133+
style={{
134+
fontSize: '11px',
135+
opacity: 0.6,
136+
fontFamily: 'monospace',
137+
}}
138+
>
139+
{formatTime(ev.timestamp)}
140+
</span>
141+
</div>
142+
{ev.data !== undefined && (
143+
<pre
144+
style={{
145+
fontSize: '12px',
146+
fontFamily: 'monospace',
147+
opacity: 0.8,
148+
margin: 0,
149+
whiteSpace: 'pre-wrap',
150+
wordBreak: 'break-all',
151+
}}
152+
>
153+
{JSON.stringify(ev.data, null, 2)}
154+
</pre>
155+
)}
156+
</div>
157+
))}
158+
</div>
159+
)}
160+
</div>
161+
)
162+
}
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export { ServerEventsPanel } from './ServerEventsPanel'
2+
export { emitServerEvent } from './server-event-client'
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { EventClient } from '@tanstack/devtools-event-client'
2+
3+
export interface ServerEvent {
4+
name: string
5+
timestamp: number
6+
data?: unknown
7+
}
8+
9+
type ServerEventMap = {
10+
'server-fn-called': ServerEvent
11+
}
12+
13+
class ServerEventClient extends EventClient<ServerEventMap> {
14+
constructor() {
15+
super({
16+
pluginId: 'server-events',
17+
})
18+
}
19+
}
20+
21+
export const serverEventClient = new ServerEventClient()
22+
23+
/**
24+
* Emit a devtools event from a server function.
25+
* In Cloudflare Workers, server functions run in an isolated environment.
26+
* Without the network transport fallback, these events would be lost.
27+
*/
28+
export function emitServerEvent(name: string, data?: unknown) {
29+
if (process.env.NODE_ENV !== 'development') return
30+
31+
serverEventClient.emit('server-fn-called', {
32+
name,
33+
timestamp: Date.now(),
34+
data,
35+
})
36+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { createRouter } from '@tanstack/react-router'
2+
import { routeTree } from './routeTree.gen'
3+
4+
export const getRouter = () => {
5+
const router = createRouter({
6+
routeTree,
7+
context: {},
8+
scrollRestoration: true,
9+
defaultPreloadStaleTime: 0,
10+
})
11+
12+
return router
13+
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { HeadContent, Scripts, createRootRoute } from '@tanstack/react-router'
2+
import { TanStackDevtools } from '@tanstack/react-devtools'
3+
import { ServerEventsPanel } from '../devtools'
4+
5+
export const Route = createRootRoute({
6+
head: () => ({
7+
meta: [
8+
{ charSet: 'utf-8' },
9+
{ name: 'viewport', content: 'width=device-width, initial-scale=1' },
10+
{ title: 'Cloudflare Workers Devtools Test' },
11+
],
12+
}),
13+
shellComponent: RootDocument,
14+
})
15+
16+
function RootDocument({ children }: { children: React.ReactNode }) {
17+
return (
18+
<html lang="en">
19+
<head>
20+
<HeadContent />
21+
</head>
22+
<body style={{ margin: 0, fontFamily: 'system-ui, sans-serif' }}>
23+
{children}
24+
<TanStackDevtools
25+
eventBusConfig={{
26+
connectToServerBus: true,
27+
}}
28+
config={{ position: 'bottom-right' }}
29+
plugins={[
30+
{
31+
id: 'server-events',
32+
name: 'Server Events',
33+
render: <ServerEventsPanel />,
34+
},
35+
]}
36+
/>
37+
<Scripts />
38+
</body>
39+
</html>
40+
)
41+
}

0 commit comments

Comments
 (0)