feat: network transport fallback for isolated server runtimes#384
feat: network transport fallback for isolated server runtimes#384AlemTuzlak wants to merge 16 commits intomainfrom
Conversation
…er 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.
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.
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).
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.
- 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()
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.
- 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
|
|
| Command | Status | Duration | Result |
|---|---|---|---|
nx affected --targets=test:eslint,test:sherif,t... |
❌ Failed | 2m 17s | View ↗ |
nx run-many --targets=build --exclude=examples/** |
✅ Succeeded | 22s | View ↗ |
☁️ Nx Cloud last updated this comment at 2026-03-12 14:38:15 UTC
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.
📝 WalkthroughWalkthroughThis pull request implements a network transport fallback system for TanStack devtools to support isolated server runtimes (Cloudflare Workers, Nitro). It introduces optional WebSocket bridging with HTTP POST fallback, adds event deduplication using a RingBuffer utility, and extends the event protocol with eventId and source tracking. Two new example projects demonstrate the integrated solution. Changes
Sequence DiagramsequenceDiagram
participant Client as Isolated Client<br/>(EventClient)
participant WS as WebSocket<br/>Connection
participant HTTP as HTTP Fallback<br/>POST Endpoint
participant Server as ServerEventBus<br/>(Main Process)
Note over Client,Server: Network Transport Initialization
Client->>WS: connectWebSocket()<br/>/__devtools/ws?bridge=server
alt WebSocket Available
WS->>Server: Upgrade request (bridge=server)
Server->>Server: Track in bridgeClients
Server-->>WS: Connection established
Client->>Client: Store WebSocket reference
else WebSocket Unavailable
Client->>Client: scheduleReconnect()<br/>with backoff
end
Note over Client,Server: Event Emission & Routing
Client->>Client: Generate eventId
Client->>Client: Add eventId to RingBuffer
alt WebSocket Connected
Client->>WS: Send event{eventId,source}
WS->>Server: Receive message
Server->>Server: Route to in-process<br/>& browser clients
Server-->>WS: Broadcast to browsers
else WebSocket Disconnected
Client->>Client: Queue event
Client->>HTTP: Fallback POST<br/>/__devtools/send
HTTP->>Server: Receive {eventId,source}
Server->>Server: Route via POST handler
Server-->>WS: Broadcast to browsers
end
Note over Client,Server: Server→Client Event Delivery
Server->>Server: Dispatch server event
Server->>WS: Send to bridge client
WS->>Client: Receive event
Client->>Client: Check RingBuffer.has(eventId)
alt Not Duplicate
Client->>Client: Deliver to listeners
else Self-Echo (Dedup)
Client->>Client: Discard (skip)
end
Note over Client,Server: Reconnection with Buffering
WS->>WS: Connection lost
Client->>Client: Queue pending events
Client->>Client: scheduleReconnect()<br/>exponential backoff
Client->>WS: Retry connectWebSocket()
WS->>Server: Reconnect
Server-->>WS: OK
Client->>Client: Flush queued events
Client->>WS: Send all buffered<br/>with fresh eventIds
Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches
🧪 Generate unit tests (beta)
📝 Coding Plan
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 5
🧹 Nitpick comments (8)
examples/react/start-cloudflare/.gitignore (1)
6-11: Consider adding env variant ignores to reduce secret leak risk.You already ignore
.env; adding.env.*and Cloudflare local secret files (for example.dev.vars/.dev.vars.*) would provide better protection against accidental commits in local/dev workflows.Proposed update
.env +.env.* +.dev.vars +.dev.vars.* .nitro .tanstack .wrangler🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@examples/react/start-cloudflare/.gitignore` around lines 6 - 11, Update the .gitignore entry that currently lists ".env" to also ignore environment file variants and common Cloudflare local secret files: add patterns like ".env.*", "*.env.local", ".dev.vars" and ".dev.vars.*" (or other local secret naming used in this project) so accidental commits of local/dev secrets are prevented; ensure these patterns are added alongside existing entries (".env", ".nitro", ".tanstack", ".wrangler", ".output", ".vinxi") in the examples/react/start-cloudflare/.gitignore file.examples/react/start-cloudflare/package.json (1)
12-20: Consider moving build-time Vite plugins to devDependencies.The following packages are Vite plugins used only during development/build and do not run at runtime in Cloudflare Workers:
@cloudflare/vite-plugin(line 12)@tanstack/router-plugin(line 17)vite-tsconfig-paths(line 20)Moving them to
devDependenciesmore accurately reflects their purpose and can reduce confusion about what runs in the deployed worker.♻️ Proposed fix
"dependencies": { - "@cloudflare/vite-plugin": "^1.13.8", "@tanstack/devtools-event-client": "workspace:*", "@tanstack/react-devtools": "workspace:*", "@tanstack/react-router": "^1.132.0", "@tanstack/react-start": "^1.132.0", - "@tanstack/router-plugin": "^1.132.0", "react": "^19.2.0", - "react-dom": "^19.2.0", - "vite-tsconfig-paths": "^6.0.2" + "react-dom": "^19.2.0" }, "devDependencies": { + "@cloudflare/vite-plugin": "^1.13.8", "@tanstack/devtools-vite": "workspace:*", + "@tanstack/router-plugin": "^1.132.0", "@types/node": "^22.15.2", "@types/react": "^19.2.0", "@types/react-dom": "^19.2.0", "@vitejs/plugin-react": "^5.0.4", "typescript": "~5.9.2", "vite": "^7.1.7", + "vite-tsconfig-paths": "^6.0.2", "wrangler": "^4.40.3" }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@examples/react/start-cloudflare/package.json` around lines 12 - 20, The package.json currently lists build-time Vite plugins as runtime dependencies; remove "@cloudflare/vite-plugin", "@tanstack/router-plugin", and "vite-tsconfig-paths" from the "dependencies" section and add them to "devDependencies" with the same version strings so they are only installed for build/dev. Ensure you update the package.json keys accordingly (keep other packages untouched) and run your package manager to refresh lockfiles so the change is reflected in installs.examples/react/start-nitro/package.json (1)
16-16: Pinnitroto a specific version instead of usinglatest.At Line 16,
"nitro": "latest"is inconsistent with the rest of the dependencies and makes this example non-reproducible. The lockfile resolves tonitro@3.0.1-alpha.2; pin the dependency to this version or a specific stable release:- "nitro": "latest", + "nitro": "^3.0.1-alpha.2",🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@examples/react/start-nitro/package.json` at line 16, The dependency entry "nitro": "latest" in package.json makes the example non-reproducible; update the "nitro" dependency (the "nitro" property in package.json) to a specific version (e.g., "3.0.1-alpha.2" or a chosen stable release) so the example and lockfile remain deterministic, then run npm/yarn install to refresh the lockfile.examples/react/start-cloudflare/src/routes/index.tsx (1)
60-63: Consider adding error handling for server function calls.The async button handlers call server functions without try/catch. If a server function fails (network error, worker crash), the promise rejection will be unhandled.
💡 Example error handling
onClick={async () => { + try { const msg = await greet() addResult(msg) + } catch (e) { + addResult(`Error: ${e instanceof Error ? e.message : 'Unknown error'}`) + } }}Also applies to: 78-81, 96-99
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@examples/react/start-cloudflare/src/routes/index.tsx` around lines 60 - 63, Wrap each async server-call handler (the onClick callbacks that call greet(), and similar handlers at the other spots) in a try/catch: call the server function inside try, await the result, and then call addResult(msg) on success; in catch, log the error and surface a user-friendly message (e.g., via addResult or an error state) so promise rejections from greet() (or other server functions) are handled and do not stay unhandled. Ensure you update all three handlers (the one invoking greet() and the other handlers at the 78-81 and 96-99 locations) and reference the existing functions greet() and addResult when implementing the try/catch handling.examples/react/start-cloudflare/src/devtools/ServerEventsPanel.tsx (1)
105-107: Consider a more unique key for event list items.Using
${ev.timestamp}-${index}as the key could cause React reconciliation issues if events arrive with identical timestamps (possible with rapid emission). Since events are prepended, the index of existing items shifts on each new event, which combined with timestamp collisions could lead to incorrect DOM updates.💡 Consider adding a unique identifier
If events gain an
eventIdfield from the transport layer (as mentioned in the PR objectives), use that instead:- <div - key={`${ev.timestamp}-${index}`} + <div + key={ev.eventId ?? `${ev.timestamp}-${index}`}Alternatively, generate a client-side ID when storing events.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@examples/react/start-cloudflare/src/devtools/ServerEventsPanel.tsx` around lines 105 - 107, The list key for events in ServerEventsPanel (events.map -> key={`${ev.timestamp}-${index}`}) is unstable; change it to use a stable unique id: prefer using ev.eventId (if provided by the transport) or a client-generated id assigned when the event is created/added (e.g., set ev.id or event.id inside the event insertion logic). Update the mapping to use that stable id (key={ev.eventId || ev.id}) so prepending new events won't shift keys and break React reconciliation.packages/event-bus/src/server/server.ts (1)
156-167: Extract the POST bridge routing into one helper.Both server modes now duplicate the same
source === 'server-bridge'branch. Pulling that into a shared helper will keep standalone and piggyback mode from drifting the next time the bridge protocol changes.Also applies to: 274-288
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/event-bus/src/server/server.ts` around lines 156 - 167, Extract the duplicated POST bridge routing into a single helper (e.g. handleDevtoolsSendRequest) that performs the body collection, calls parseWithBigInt, logs via this.debugLog('Received event from client', msg) and then calls either this.emit(msg) or this.emitToServer(msg) based on msg.source === 'server-bridge'; replace the duplicated blocks currently handling req.url === '/__devtools/send' in both server modes (the block around the parseWithBigInt/this.debugLog/this.emit/this.emitToServer sequence and the similar block at the other location) with calls to this new helper so both modes share the same logic.packages/event-bus-client/src/plugin.ts (1)
581-595: Use plain comments for these private helpers.
@internalJSDoc on private methods will still flow into generated docs, which makes the website output noisy for implementation details. A normal//comment here is a better fit.As per coding guidelines,
**/*.{js,ts,jsx,tsx}: JSDoc should read like documentation as it gets converted to markdown docs for the website.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/event-bus-client/src/plugin.ts` around lines 581 - 595, The JSDoc `@internal` on private helper methods is leaking into generated docs; replace the /** `@internal` */ JSDoc blocks on the private helpers (specifically ___enableNetworkTransport and ___destroyNetworkTransport) with plain single-line comments (e.g., // internal — only for testing) so they are treated as implementation comments rather than documentation and won’t be emitted into the site output.examples/react/start-nitro/src/devtools/ServerEventsPanel.tsx (1)
20-28: Consider hydration safety for time formatting.
toLocaleTimeStringcan produce different output on server vs client due to locale/timezone differences, potentially causing hydration mismatches. Since this is a devtools panel (client-only rendering context), this is likely fine, but worth noting.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@examples/react/start-nitro/src/devtools/ServerEventsPanel.tsx` around lines 20 - 28, formatTime currently uses toLocaleTimeString which can differ between server and client and cause hydration mismatches; make the output deterministic by specifying an explicit timeZone (e.g., add timeZone: 'UTC' to the options) or move formatting to run only on the client (compute inside a useEffect or when rendering client-only components) so timezone/locale differences won't cause hydration errors — update the formatTime function to use Intl.DateTimeFormat or toLocaleTimeString with a fixed timeZone option, or ensure calls to formatTime occur only on the client side.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@examples/react/start-nitro/package.json`:
- Line 19: Move the "vite-tsconfig-paths" dependency from runtime dependencies
into devDependencies: remove the "vite-tsconfig-paths": "^6.0.2" entry under
dependencies in package.json and add the same entry under devDependencies (so
build-time only tools used by vite.config.ts are not bundled at runtime); ensure
package-lock/yarn lock is updated by reinstalling or running the package manager
after the change.
In `@packages/event-bus-client/src/plugin.ts`:
- Around line 50-54: The event ID generation (generateEventId using
globalEventIdCounter) is only unique per module instance and can collide across
workers; create a process-scoped random/unique prefix (e.g., instancePrefix
generated once at module init using crypto.randomBytes or a high-entropy
Math.random hex) and prepend it to the existing
`${++globalEventIdCounter}-${Date.now()}` scheme so generateEventId returns
`${instancePrefix}-${++globalEventIdCounter}-${Date.now()}`, ensuring
instance-scoped entropy and avoiding cross-worker collisions; ensure
instancePrefix is initialized once (top-level) and referenced in
generateEventId.
- Around line 475-483: The branch inside the this.#useNetworkTransport path
incorrectly queues events when the client has given up on WebSocket; update the
logic in the block that calls createEventPayload so that if this.#wsGaveUp is
true you bypass queuing and connectWebSocket(), and immediately call
sendViaNetwork (or sendViaHttp for non-network transport) instead; specifically,
in the code handling this.#useNetworkTransport, check this.#wsGaveUp before
pushing to this.#queuedEvents and short-circuit to sendViaNetwork/sendViaHttp,
leaving connectWebSocket() only for cases where a reconnect attempt should
actually be made.
- Around line 304-308: The bridge currently uses JSON.parse/JSON.stringify which
drops BigInt values; change the ws message handler in
ws.addEventListener('message') to call parseWithBigInt(data) instead of
JSON.parse(data), and update the bridge send methods (the functions around the
send calls at the locations corresponding to lines 408 and 428) to use
stringifyWithBigInt(payload) instead of JSON.stringify(payload). Ensure
parseWithBigInt and stringifyWithBigInt are imported from the event-bus
utilities and replace all plain JSON.parse/JSON.stringify usages in those
handlers/sendters to maintain BigInt-safe wire format compatibility.
In `@packages/event-bus-client/src/ring-buffer.ts`:
- Around line 7-11: The RingBuffer constructor must validate the incoming
capacity and fail fast for non-positive or non-integer values: in the
constructor (class RingBuffer) add a guard that checks capacity is a positive
integer (capacity > 0 && Number.isInteger(capacity)) and throw a clear
RangeError or TypeError if not, so fields like `#capacity`, `#buffer` and `#index`
remain valid and add() cannot enter a broken state; update the constructor that
currently sets this.#capacity, this.#buffer and this.#set to perform this
validation before initializing those fields.
---
Nitpick comments:
In `@examples/react/start-cloudflare/.gitignore`:
- Around line 6-11: Update the .gitignore entry that currently lists ".env" to
also ignore environment file variants and common Cloudflare local secret files:
add patterns like ".env.*", "*.env.local", ".dev.vars" and ".dev.vars.*" (or
other local secret naming used in this project) so accidental commits of
local/dev secrets are prevented; ensure these patterns are added alongside
existing entries (".env", ".nitro", ".tanstack", ".wrangler", ".output",
".vinxi") in the examples/react/start-cloudflare/.gitignore file.
In `@examples/react/start-cloudflare/package.json`:
- Around line 12-20: The package.json currently lists build-time Vite plugins as
runtime dependencies; remove "@cloudflare/vite-plugin",
"@tanstack/router-plugin", and "vite-tsconfig-paths" from the "dependencies"
section and add them to "devDependencies" with the same version strings so they
are only installed for build/dev. Ensure you update the package.json keys
accordingly (keep other packages untouched) and run your package manager to
refresh lockfiles so the change is reflected in installs.
In `@examples/react/start-cloudflare/src/devtools/ServerEventsPanel.tsx`:
- Around line 105-107: The list key for events in ServerEventsPanel (events.map
-> key={`${ev.timestamp}-${index}`}) is unstable; change it to use a stable
unique id: prefer using ev.eventId (if provided by the transport) or a
client-generated id assigned when the event is created/added (e.g., set ev.id or
event.id inside the event insertion logic). Update the mapping to use that
stable id (key={ev.eventId || ev.id}) so prepending new events won't shift keys
and break React reconciliation.
In `@examples/react/start-cloudflare/src/routes/index.tsx`:
- Around line 60-63: Wrap each async server-call handler (the onClick callbacks
that call greet(), and similar handlers at the other spots) in a try/catch: call
the server function inside try, await the result, and then call addResult(msg)
on success; in catch, log the error and surface a user-friendly message (e.g.,
via addResult or an error state) so promise rejections from greet() (or other
server functions) are handled and do not stay unhandled. Ensure you update all
three handlers (the one invoking greet() and the other handlers at the 78-81 and
96-99 locations) and reference the existing functions greet() and addResult when
implementing the try/catch handling.
In `@examples/react/start-nitro/package.json`:
- Line 16: The dependency entry "nitro": "latest" in package.json makes the
example non-reproducible; update the "nitro" dependency (the "nitro" property in
package.json) to a specific version (e.g., "3.0.1-alpha.2" or a chosen stable
release) so the example and lockfile remain deterministic, then run npm/yarn
install to refresh the lockfile.
In `@examples/react/start-nitro/src/devtools/ServerEventsPanel.tsx`:
- Around line 20-28: formatTime currently uses toLocaleTimeString which can
differ between server and client and cause hydration mismatches; make the output
deterministic by specifying an explicit timeZone (e.g., add timeZone: 'UTC' to
the options) or move formatting to run only on the client (compute inside a
useEffect or when rendering client-only components) so timezone/locale
differences won't cause hydration errors — update the formatTime function to use
Intl.DateTimeFormat or toLocaleTimeString with a fixed timeZone option, or
ensure calls to formatTime occur only on the client side.
In `@packages/event-bus-client/src/plugin.ts`:
- Around line 581-595: The JSDoc `@internal` on private helper methods is leaking
into generated docs; replace the /** `@internal` */ JSDoc blocks on the private
helpers (specifically ___enableNetworkTransport and ___destroyNetworkTransport)
with plain single-line comments (e.g., // internal — only for testing) so they
are treated as implementation comments rather than documentation and won’t be
emitted into the site output.
In `@packages/event-bus/src/server/server.ts`:
- Around line 156-167: Extract the duplicated POST bridge routing into a single
helper (e.g. handleDevtoolsSendRequest) that performs the body collection, calls
parseWithBigInt, logs via this.debugLog('Received event from client', msg) and
then calls either this.emit(msg) or this.emitToServer(msg) based on msg.source
=== 'server-bridge'; replace the duplicated blocks currently handling req.url
=== '/__devtools/send' in both server modes (the block around the
parseWithBigInt/this.debugLog/this.emit/this.emitToServer sequence and the
similar block at the other location) with calls to this new helper so both modes
share the same logic.
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 23c57afd-2ae3-40ad-89fd-677242f015bd
📒 Files selected for processing (32)
docs/superpowers/plans/2026-03-12-network-transport-fallback.mddocs/superpowers/specs/2026-03-12-network-transport-fallback-design.mdexamples/react/start-cloudflare/.gitignoreexamples/react/start-cloudflare/package.jsonexamples/react/start-cloudflare/src/devtools/ServerEventsPanel.tsxexamples/react/start-cloudflare/src/devtools/index.tsexamples/react/start-cloudflare/src/devtools/server-event-client.tsexamples/react/start-cloudflare/src/router.tsxexamples/react/start-cloudflare/src/routes/__root.tsxexamples/react/start-cloudflare/src/routes/index.tsxexamples/react/start-cloudflare/tsconfig.jsonexamples/react/start-cloudflare/vite.config.tsexamples/react/start-cloudflare/wrangler.jsoncexamples/react/start-nitro/.gitignoreexamples/react/start-nitro/package.jsonexamples/react/start-nitro/src/devtools/ServerEventsPanel.tsxexamples/react/start-nitro/src/devtools/index.tsexamples/react/start-nitro/src/devtools/server-event-client.tsexamples/react/start-nitro/src/router.tsxexamples/react/start-nitro/src/routes/__root.tsxexamples/react/start-nitro/src/routes/index.tsxexamples/react/start-nitro/tsconfig.jsonexamples/react/start-nitro/vite.config.tspackages/event-bus-client/src/index.tspackages/event-bus-client/src/plugin.tspackages/event-bus-client/src/ring-buffer.tspackages/event-bus-client/tests/integration.test.tspackages/event-bus-client/tests/network-transport.test.tspackages/event-bus-client/tests/ring-buffer.test.tspackages/event-bus/src/client/client.tspackages/event-bus/src/server/server.tspackages/event-bus/tests/server.test.ts
| "nitro": "latest", | ||
| "react": "^19.2.0", | ||
| "react-dom": "^19.2.0", | ||
| "vite-tsconfig-paths": "^6.0.2" |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "Search for vite-tsconfig-paths usage:"
rg -n 'vite-tsconfig-paths|tsconfigPaths\s*\(' --glob '**/*.{ts,tsx,js,jsx,mjs,cjs,mts,cts}'Repository: TanStack/devtools
Length of output: 484
🏁 Script executed:
cat examples/react/start-nitro/package.jsonRepository: TanStack/devtools
Length of output: 860
Move vite-tsconfig-paths to devDependencies.
This package is imported only in vite.config.ts (build-time configuration). It should not be in dependencies since it's not used at runtime.
Proposed change
"dependencies": {
"@tanstack/devtools-event-client": "workspace:*",
"@tanstack/react-devtools": "workspace:*",
"@tanstack/react-router": "^1.132.0",
"@tanstack/react-start": "^1.132.0",
"@tanstack/router-plugin": "^1.132.0",
"nitro": "latest",
"react": "^19.2.0",
"react-dom": "^19.2.0",
- "vite-tsconfig-paths": "^6.0.2"
},
"devDependencies": {
"@tanstack/devtools-vite": "workspace:*",
"@types/node": "^22.15.2",
"@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0",
"@vitejs/plugin-react": "^5.0.4",
+ "vite-tsconfig-paths": "^6.0.2",
"typescript": "~5.9.2",
"vite": "^7.1.7"
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| "vite-tsconfig-paths": "^6.0.2" | |
| "dependencies": { | |
| "@tanstack/devtools-event-client": "workspace:*", | |
| "@tanstack/react-devtools": "workspace:*", | |
| "@tanstack/react-router": "^1.132.0", | |
| "@tanstack/react-start": "^1.132.0", | |
| "@tanstack/router-plugin": "^1.132.0", | |
| "nitro": "latest", | |
| "react": "^19.2.0", | |
| "react-dom": "^19.2.0" | |
| }, | |
| "devDependencies": { | |
| "@tanstack/devtools-vite": "workspace:*", | |
| "@types/node": "^22.15.2", | |
| "@types/react": "^19.2.0", | |
| "@types/react-dom": "^19.2.0", | |
| "@vitejs/plugin-react": "^5.0.4", | |
| "vite-tsconfig-paths": "^6.0.2", | |
| "typescript": "~5.9.2", | |
| "vite": "^7.1.7" | |
| } |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@examples/react/start-nitro/package.json` at line 19, Move the
"vite-tsconfig-paths" dependency from runtime dependencies into devDependencies:
remove the "vite-tsconfig-paths": "^6.0.2" entry under dependencies in
package.json and add the same entry under devDependencies (so build-time only
tools used by vite.config.ts are not bundled at runtime); ensure
package-lock/yarn lock is updated by reinstalling or running the package manager
after the change.
| let globalEventIdCounter = 0 | ||
|
|
||
| function generateEventId(): string { | ||
| return `${++globalEventIdCounter}-${Date.now()}` | ||
| } |
There was a problem hiding this comment.
Make eventId unique across worker instances.
++counter + Date.now() is only unique inside one module instance. Two isolated workers can both generate the same first IDs in the same millisecond, and then falsely drop each other's events as echoes. Add a per-process random prefix or similar instance-scoped entropy.
🔧 One low-dependency fix
let globalEventIdCounter = 0
+const eventIdPrefix =
+ `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`
function generateEventId(): string {
- return `${++globalEventIdCounter}-${Date.now()}`
+ return `${eventIdPrefix}-${++globalEventIdCounter}`
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| let globalEventIdCounter = 0 | |
| function generateEventId(): string { | |
| return `${++globalEventIdCounter}-${Date.now()}` | |
| } | |
| let globalEventIdCounter = 0 | |
| const eventIdPrefix = | |
| `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}` | |
| function generateEventId(): string { | |
| return `${eventIdPrefix}-${++globalEventIdCounter}` | |
| } |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@packages/event-bus-client/src/plugin.ts` around lines 50 - 54, The event ID
generation (generateEventId using globalEventIdCounter) is only unique per
module instance and can collide across workers; create a process-scoped
random/unique prefix (e.g., instancePrefix generated once at module init using
crypto.randomBytes or a high-entropy Math.random hex) and prepend it to the
existing `${++globalEventIdCounter}-${Date.now()}` scheme so generateEventId
returns `${instancePrefix}-${++globalEventIdCounter}-${Date.now()}`, ensuring
instance-scoped entropy and avoiding cross-worker collisions; ensure
instancePrefix is initialized once (top-level) and referenced in
generateEventId.
| ws.addEventListener('message', (e) => { | ||
| try { | ||
| const data = typeof e.data === 'string' ? e.data : e.data.toString() | ||
| const event = JSON.parse(data) | ||
|
|
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Compare serializer usage in the bridge and core event-bus transport code.
fd 'plugin\.ts$|client\.ts$|server\.ts$' packages/event-bus packages/event-bus-client -t f \
| xargs rg -n 'parseWithBigInt|stringifyWithBigInt|JSON\.parse|JSON\.stringify'Repository: TanStack/devtools
Length of output: 1336
🏁 Script executed:
cat -n packages/event-bus-client/src/plugin.ts | sed -n '300,315p'Repository: TanStack/devtools
Length of output: 703
🏁 Script executed:
cat -n packages/event-bus-client/src/plugin.ts | sed -n '385,435p'Repository: TanStack/devtools
Length of output: 2030
🏁 Script executed:
head -20 packages/event-bus-client/src/plugin.ts | cat -nRepository: TanStack/devtools
Length of output: 983
🏁 Script executed:
ls -la packages/event-bus-client/src/ | head -20Repository: TanStack/devtools
Length of output: 358
🏁 Script executed:
fd -t f 'json\.' packages/event-bus-client packages/event-busRepository: TanStack/devtools
Length of output: 139
🏁 Script executed:
cat packages/event-bus/src/utils/json.tsRepository: TanStack/devtools
Length of output: 855
Use parseWithBigInt and stringifyWithBigInt for bridge serialization to match the rest of the event-bus.
The server and browser bus use parseWithBigInt / stringifyWithBigInt for transport serialization. The bridge currently uses plain JSON.parse and JSON.stringify, which silently loses BigInt values and breaks wire format compatibility. Update line 307 (message handler) and lines 408, 428 (send methods) to use the BigInt-safe serializers imported from the event-bus utilities.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@packages/event-bus-client/src/plugin.ts` around lines 304 - 308, The bridge
currently uses JSON.parse/JSON.stringify which drops BigInt values; change the
ws message handler in ws.addEventListener('message') to call
parseWithBigInt(data) instead of JSON.parse(data), and update the bridge send
methods (the functions around the send calls at the locations corresponding to
lines 408 and 428) to use stringifyWithBigInt(payload) instead of
JSON.stringify(payload). Ensure parseWithBigInt and stringifyWithBigInt are
imported from the event-bus utilities and replace all plain
JSON.parse/JSON.stringify usages in those handlers/sendters to maintain
BigInt-safe wire format compatibility.
| if (this.#useNetworkTransport) { | ||
| const event = this.createEventPayload(eventSuffix, payload) | ||
| if (!this.#connected) { | ||
| this.#queuedEvents.push(event) | ||
| this.connectWebSocket() | ||
| return | ||
| } | ||
| this.sendViaNetwork(event) | ||
| return |
There was a problem hiding this comment.
HTTP-only mode never sends later emits.
After scheduleReconnect() sets #wsGaveUp = true, this branch still queues on !#connected and connectWebSocket() immediately returns, so every subsequent event stays in #queuedEvents forever. Short-circuit to sendViaNetwork() / sendViaHttp() once the client has already given up on WebSocket.
💡 Minimal fix
if (this.#useNetworkTransport) {
const event = this.createEventPayload(eventSuffix, payload)
+ if (this.#wsGaveUp) {
+ this.sendViaNetwork(event)
+ return
+ }
if (!this.#connected) {
this.#queuedEvents.push(event)
this.connectWebSocket()
return
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| if (this.#useNetworkTransport) { | |
| const event = this.createEventPayload(eventSuffix, payload) | |
| if (!this.#connected) { | |
| this.#queuedEvents.push(event) | |
| this.connectWebSocket() | |
| return | |
| } | |
| this.sendViaNetwork(event) | |
| return | |
| if (this.#useNetworkTransport) { | |
| const event = this.createEventPayload(eventSuffix, payload) | |
| if (this.#wsGaveUp) { | |
| this.sendViaNetwork(event) | |
| return | |
| } | |
| if (!this.#connected) { | |
| this.#queuedEvents.push(event) | |
| this.connectWebSocket() | |
| return | |
| } | |
| this.sendViaNetwork(event) | |
| return |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@packages/event-bus-client/src/plugin.ts` around lines 475 - 483, The branch
inside the this.#useNetworkTransport path incorrectly queues events when the
client has given up on WebSocket; update the logic in the block that calls
createEventPayload so that if this.#wsGaveUp is true you bypass queuing and
connectWebSocket(), and immediately call sendViaNetwork (or sendViaHttp for
non-network transport) instead; specifically, in the code handling
this.#useNetworkTransport, check this.#wsGaveUp before pushing to
this.#queuedEvents and short-circuit to sendViaNetwork/sendViaHttp, leaving
connectWebSocket() only for cases where a reconnect attempt should actually be
made.
| constructor(capacity: number) { | ||
| this.#capacity = capacity | ||
| this.#buffer = new Array(capacity).fill('') | ||
| this.#set = new Set() | ||
| } |
There was a problem hiding this comment.
Reject invalid capacities in the constructor.
capacity <= 0 leaves add() in a broken state (#index becomes NaN) and negative values surface a generic Array() error. Since RingBuffer is exported, fail fast with a clear positive-integer check.
🛡️ Proposed guard
constructor(capacity: number) {
+ if (!Number.isInteger(capacity) || capacity <= 0) {
+ throw new RangeError('RingBuffer capacity must be a positive integer')
+ }
this.#capacity = capacity
this.#buffer = new Array(capacity).fill('')
this.#set = new Set()
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| constructor(capacity: number) { | |
| this.#capacity = capacity | |
| this.#buffer = new Array(capacity).fill('') | |
| this.#set = new Set() | |
| } | |
| constructor(capacity: number) { | |
| if (!Number.isInteger(capacity) || capacity <= 0) { | |
| throw new RangeError('RingBuffer capacity must be a positive integer') | |
| } | |
| this.#capacity = capacity | |
| this.#buffer = new Array(capacity).fill('') | |
| this.#set = new Set() | |
| } |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@packages/event-bus-client/src/ring-buffer.ts` around lines 7 - 11, The
RingBuffer constructor must validate the incoming capacity and fail fast for
non-positive or non-integer values: in the constructor (class RingBuffer) add a
guard that checks capacity is a positive integer (capacity > 0 &&
Number.isInteger(capacity)) and throw a clear RangeError or TypeError if not, so
fields like `#capacity`, `#buffer` and `#index` remain valid and add() cannot enter a
broken state; update the constructor that currently sets this.#capacity,
this.#buffer and this.#set to perform this validation before initializing those
fields.

Summary
Fixes TanStack/ai#339
When TanStack Start uses Nitro v3's
nitro()Vite plugin (or any runtime that isolates server code in a separate thread/process), devtools events break becauseglobalThis.__TANSTACK_EVENT_TARGET__is not shared across isolation boundaries.This PR adds automatic network transport fallback:
ServerEventBusvia WebSocket?bridge=server) from browser clients and routes bridge messages through bothemitEventToClients()(browser devtools) andemitToServer()(in-process EventTarget)Changes
packages/event-bus/src/server/server.ts— bridge WebSocket support, POST source-based routingpackages/event-bus/src/client/client.ts— interface update (eventId, source fields)packages/event-bus-client/src/plugin.ts— network transport detection, WebSocket connection, emit/receive, reconnect, HTTP fallbackpackages/event-bus-client/src/ring-buffer.ts— new RingBuffer utility for deduppackages/event-bus-client/src/index.ts— exportcreateNetworkTransportClientTest plan
nitro()plugin inexamples/react/startSummary by CodeRabbit
New Features
createNetworkTransportClientAPI for enabling network-based event communication.Documentation