Skip to content
Open
Changes from 1 commit
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
29433d8
docs: add design spec for network transport fallback in isolated serv…
AlemTuzlak Mar 12, 2026
42b3beb
docs: address spec review findings for network transport fallback
AlemTuzlak Mar 12, 2026
ed6e0a7
docs: clarify dual handler paths in network transport spec
AlemTuzlak Mar 12, 2026
c1b7af1
docs: add implementation plan for network transport fallback
AlemTuzlak Mar 12, 2026
76d7711
feat: add eventId and source fields to TanStackDevtoolsEvent interface
AlemTuzlak Mar 12, 2026
b4e9e7d
feat: add server bridge WebSocket connection support to ServerEventBus
AlemTuzlak Mar 12, 2026
884ba69
feat: add source-based routing to POST handlers for server bridge sup…
AlemTuzlak Mar 12, 2026
2d2b0f0
feat: add RingBuffer utility for event ID deduplication
AlemTuzlak Mar 12, 2026
e6a3e57
feat: add network transport detection and compile-time placeholders t…
AlemTuzlak Mar 12, 2026
76907ad
feat: add WebSocket network transport fallback to EventClient
AlemTuzlak Mar 12, 2026
1b7f1a2
fix: improve WebSocket error handling and destroy cleanup in EventClient
AlemTuzlak Mar 12, 2026
f17620a
test: add end-to-end integration tests for network transport fallback
AlemTuzlak Mar 12, 2026
693e472
docs: mark network transport fallback spec as implemented
AlemTuzlak Mar 12, 2026
64ae1bb
ci: apply automated fixes
autofix-ci[bot] Mar 12, 2026
ac41406
feat: add Nitro v3 and Cloudflare Workers test examples
AlemTuzlak Mar 12, 2026
3f82eeb
Merge branch 'main' into worktree-polished-cuddling-lark
AlemTuzlak Mar 13, 2026
c61ea0f
Refactor code structure for improved readability and maintainability
AlemTuzlak Mar 27, 2026
939ac70
Merge branch 'worktree-polished-cuddling-lark' of https://github.com/…
AlemTuzlak Mar 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
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.
  • Loading branch information
AlemTuzlak committed Mar 12, 2026
commit 42b3beb23dbb0c9c8599c891bddb87129c123b72
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

## Problem

When TanStack Start uses Nitro v3's `nitro()` Vite plugin (or any runtime that isolates server code in a separate thread/process), the devtools event system breaks. `ServerEventBus` creates and listens on `globalThis.__TANSTACK_EVENT_TARGET__` in the Vite main thread, but `EventClient` (in `@tanstack/ai` or any server-side library) emits to a different `globalThis.__TANSTACK_EVENT_TARGET__` in the isolated worker. Events never cross the boundary.
When TanStack Start uses Nitro v3's `nitro()` Vite plugin (or any runtime that isolates server code in a separate thread/process), the devtools event system breaks. `ServerEventBus` creates and listens on `globalThis.__TANSTACK_EVENT_TARGET__` in the Vite main thread, but in the isolated worker, `globalThis.__TANSTACK_EVENT_TARGET__` is `null` (no `ServerEventBus` there). When `EventClient` calls `getGlobalTarget()`, it falls through to creating a throwaway `EventTarget` that nobody is listening on. Events go nowhere.

With `nitroV2Plugin` this doesn't occur because it's build-only — in dev, Start uses `RunnableDevEnvironment` which runs in-process and shares the same global.

Expand All @@ -33,15 +33,17 @@ When `EventClient` detects it's in an isolated server environment (no shared `gl
2. `window` exists → use it (browser)
3. Create new `EventTarget` → goes nowhere (broken case)

**Change:** When we hit case 3, check if devtools server coordinates are available via compile-time placeholders:
**Change:** When we hit case 3, check if devtools server coordinates are available via compile-time placeholders. Follow the existing codebase convention (used in `packages/event-bus/src/client/client.ts`):

```typescript
const DEVTOOLS_PORT = '__TANSTACK_DEVTOOLS_PORT__' as any
const DEVTOOLS_HOST = '__TANSTACK_DEVTOOLS_HOST__' as any
const DEVTOOLS_PROTOCOL = '__TANSTACK_DEVTOOLS_PROTOCOL__' as any
declare const __TANSTACK_DEVTOOLS_PORT__: number | undefined
declare const __TANSTACK_DEVTOOLS_HOST__: string | undefined
declare const __TANSTACK_DEVTOOLS_PROTOCOL__: 'http' | 'https' | undefined
```

These are already replaced by the Vite plugin's `connection-injection` transform for packages matching `@tanstack/devtools*` or `@tanstack/event-bus*`. If replaced with real values (`typeof DEVTOOLS_PORT === 'number'`), activate network transport. If still literal strings, no-op (current behavior).
These are already replaced by the Vite plugin's `connection-injection` transform for packages matching `@tanstack/devtools*` or `@tanstack/event-bus*`. The package `@tanstack/devtools-event-client` matches via `@tanstack/devtools`. If replaced with real values (`typeof __TANSTACK_DEVTOOLS_PORT__ !== 'undefined'`), activate network transport. If still undefined, no-op (current behavior).

**One-time detection:** The `#useNetworkTransport` flag is set once on the first call to `getGlobalTarget()` and cached. Subsequent calls return the cached result without re-evaluating.

### ServerEventBus: Server Bridge Connections

Expand All @@ -51,10 +53,15 @@ These are already replaced by the Vite plugin's `connection-injection` transform

**Server bridge clients** (new): Messages go to `emit()` — both `emitEventToClients()` (browser devtools sees it) AND `emitToServer()` (in-process listeners get it). Conversely, in-process events already reach all WebSocket clients via `emitEventToClients()`, so server bridges receive them automatically.

**Differentiation:** Server bridges connect to `/__devtools/ws?bridge=server`. The upgrade handler checks the URL query parameter and tags the connection.
**Differentiation:** Server bridges connect to `/__devtools/ws?bridge=server`. This requires two changes to the existing upgrade handlers:

1. **URL matching:** The current upgrade handlers use exact string equality (`req.url === '/__devtools/ws'`). This must change to prefix matching or URL parsing (e.g., `req.url?.startsWith('/__devtools/ws')`) to support the `?bridge=server` query parameter.
2. **`handleNewConnection` signature:** The current `wss.on('connection', (ws: WebSocket) => {...})` callback only receives `ws`. It must also accept the `req` parameter (which `wss.emit('connection', ws, req)` already passes) to inspect the URL and tag the connection as a server bridge.

**Echo prevention:** Events include a unique `eventId`. The sending `EventClient` tracks sent IDs in a ring buffer (200 entries) and ignores incoming events with matching IDs.

**Multi-worker echo safety:** When multiple isolated workers each have bridge connections, an event from worker A is broadcast by `ServerEventBus` to worker B (correct) and back to worker A (deduped by ring buffer). Worker B's listeners may fire but should not re-emit the same event — this is application-level responsibility (plugins should not blindly echo). No framework-level concern here since `emit()` and `on()` are separate code paths.

### EventClient: Network Transport Flow

**New private fields:**
Expand Down Expand Up @@ -98,42 +105,56 @@ interface TanStackDevtoolsEvent<TEventName extends string, TPayload = any> {
}
```

- `eventId`: Short random string via `crypto.randomUUID()` or counter+timestamp. Used by sending `EventClient` to ignore echoed events. Ring buffer of 200 entries bounds memory.
- `source`: Set to `"server-bridge"` by network-transport `EventClient`. `ServerEventBus` checks this to decide routing: present → `emit()` (broadcast), absent → `emitToServer()` (current browser behavior).
- `eventId`: Short random string via counter+timestamp (preferred for broad runtime compatibility over `crypto.randomUUID()` which may not be available in all edge runtimes). Used by sending `EventClient` to ignore echoed events. Ring buffer of 200 entries bounds memory.
- `source`: Set to `"server-bridge"` by network-transport `EventClient`. `ServerEventBus` uses this for routing decisions. For WebSocket connections, the `?bridge=server` URL param is the primary differentiator. For the HTTP POST fallback (`/__devtools/send`), the `source` field in the JSON body is inspected to determine routing: `"server-bridge"` → `emit()` (broadcast to browser clients AND in-process EventTarget), absent → `emitToServer()` only (current browser client behavior).

Additive changes — existing events without these fields work exactly as before.

## Error Handling and Edge Cases

**WebSocket unavailability:** Some runtimes lack native `WebSocket` and won't have `ws` package. Fall back to HTTP-only: POST to `/__devtools/send` for emit, no receive. Degraded mode (emit-only) but better than nothing.
**WebSocket unavailability:** Some runtimes lack native `WebSocket` and won't have `ws` package. Fall back to HTTP-only: POST to `/__devtools/send` for emit, no receive. Degraded mode (emit-only) but better than nothing. The POST handler must check the `source` field to route server-bridge messages through `emit()` (broadcast) rather than just `emitToServer()`.

**Dev-only guard:** Network transport only activates when placeholders are replaced. In production, `removeDevtoolsOnBuild` strips devtools code. Even without that, unreplaced placeholders prevent activation (`typeof DEVTOOLS_PORT === 'number'` check).

**HMR / server restart:** WebSocket breaks on server restart. `EventClient` reconnects with exponential backoff. Events queue during reconnection.

**Multiple EventClients in same worker:** Each instance independently connects via WebSocket. Fine for v1 — shared connection optimization possible later.

**Queue preservation on network fallback:** The current `stopConnectLoop()` clears `#queuedEvents`. When transitioning from failed in-process handshake to network transport, the queue must be preserved. The network transport path should not call `stopConnectLoop()` or should preserve the queue before it's cleared.

**Ordering:** WebSocket is ordered (TCP). No reordering concerns.

## Files Changed

### `packages/event-bus/src/server/server.ts` (ServerEventBus)
- Add optional `eventId` and `source` fields to `TanStackDevtoolsEvent` interface
- Track server bridge vs browser client WebSocket connections
- Route server bridge messages through `emit()` (both directions)
- Parse `source` field to determine routing
- Check upgrade request URL for `?bridge=server` query param
- Change upgrade URL matching from exact equality (`=== '/__devtools/ws'`) to prefix matching or URL parsing to support `?bridge=server` query param
- Extend `handleNewConnection` to accept the `req` parameter from WebSocket `connection` event
- Track server bridge vs browser client WebSocket connections (tag based on `?bridge=server`)
- Route server bridge WebSocket messages through `emit()` (both `emitEventToClients` and `emitToServer`)
- Update POST handler (`/__devtools/send`) to check `source` field and route `"server-bridge"` messages through `emit()` instead of just `emitToServer()`

### `packages/event-bus-client/src/plugin.ts` (EventClient)
- Add compile-time placeholder constants for devtools server coordinates
- Modify `getGlobalTarget()` to detect isolated server environment and set `#useNetworkTransport`
- Add `declare const __TANSTACK_DEVTOOLS_PORT__` / `__TANSTACK_DEVTOOLS_HOST__` / `__TANSTACK_DEVTOOLS_PROTOCOL__` placeholders (following existing codebase convention from `client.ts`)
- Modify `getGlobalTarget()` to detect isolated server environment and set `#useNetworkTransport` (one-time, cached)
- Add WebSocket connection logic (lazy, on first emit)
- Add `eventId` generation and dedup ring buffer (200 entries)
- Add `eventId` generation (counter+timestamp) and dedup ring buffer (200 entries)
- Add reconnect with exponential backoff
- Incoming WebSocket messages dispatched on local EventTarget for `.on()` listeners
- HTTP POST fallback when WebSocket unavailable
- Preserve queued events when transitioning from failed in-process to network transport

### `packages/event-bus/src/client/client.ts` (ClientEventBus)
- Add optional `eventId` and `source` fields to its copy of `TanStackDevtoolsEvent` interface (must stay in sync with server.ts and plugin.ts copies)

### `packages/event-bus-client/src/plugin.ts` (EventClient interface)
- Add optional `eventId` and `source` fields to its copy of `TanStackDevtoolsEvent` interface

### Tests
- `packages/event-bus/tests/` — tests for server bridge connection routing, POST source-based routing
- `packages/event-bus-client/tests/` — tests for network transport detection, fallback, dedup, reconnection

### No changes to:
- Vite plugin (`devtools-vite`) — placeholder injection already covers `@tanstack/devtools-event-client`
- Browser-side `ClientEventBus` — unaffected
- Vite plugin (`devtools-vite`) — placeholder injection already covers `@tanstack/devtools-event-client` (matches via `@tanstack/devtools` in package name)
- Browser-side `ClientEventBus` — unaffected beyond the interface update
- Any consuming libraries (`@tanstack/ai`, etc.) — transparent