From 29433d848a7ee166be692e1da7c8cc2aeca0978d Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Thu, 12 Mar 2026 12:00:26 +0100 Subject: [PATCH 01/15] 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. --- ...03-12-network-transport-fallback-design.md | 139 ++++++++++++++++++ 1 file changed, 139 insertions(+) create mode 100644 docs/superpowers/specs/2026-03-12-network-transport-fallback-design.md diff --git a/docs/superpowers/specs/2026-03-12-network-transport-fallback-design.md b/docs/superpowers/specs/2026-03-12-network-transport-fallback-design.md new file mode 100644 index 00000000..819bb630 --- /dev/null +++ b/docs/superpowers/specs/2026-03-12-network-transport-fallback-design.md @@ -0,0 +1,139 @@ +# Network Transport Fallback for Isolated Server Runtimes + +**Date:** 2026-03-12 +**Status:** Draft +**Issue:** https://github.com/TanStack/ai/issues/339 + +## 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. + +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. + +This affects any isolation layer: Nitro v3 worker threads, Cloudflare Workers, separate Node processes, etc. + +## Solution: Network Transport Fallback in EventClient + +When `EventClient` detects it's in an isolated server environment (no shared `globalThis.__TANSTACK_EVENT_TARGET__`, no `window`), it automatically falls back to a WebSocket connection to `ServerEventBus`. This is fully bidirectional — events emitted in the worker reach the devtools panel, and events from the devtools panel reach listeners in the worker. + +### Design Principles + +- **Zero API changes** — existing consumers of `EventClient` work unchanged +- **Zero configuration** — detection and fallback are automatic +- **Universal** — works for any isolation layer (worker threads, separate processes, edge runtimes) +- **Dev-only** — network transport only activates when the Vite plugin has replaced compile-time placeholders + +## Architecture + +### Detection: When to Use Network Transport + +`EventClient.getGlobalTarget()` currently has this fallback chain: + +1. `globalThis.__TANSTACK_EVENT_TARGET__` exists → use it (in-process, `ServerEventBus` is here) +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: + +```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 +``` + +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). + +### ServerEventBus: Server Bridge Connections + +`ServerEventBus` must distinguish two types of WebSocket clients: + +**Browser clients** (current): Messages go to `emitToServer()` only — dispatches on in-process EventTarget. Correct because the browser already has the event locally. + +**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. + +**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. + +### EventClient: Network Transport Flow + +**New private fields:** +- `#useNetworkTransport: boolean` +- `#ws: WebSocket | null` +- `#sentEventIds: RingBuffer` (200 entries) + +**Initialization:** +- Constructor unchanged — no API changes +- `getGlobalTarget()` detects isolated environment, sets `#useNetworkTransport = true` +- Returns a local `EventTarget` for internal event dispatching (`.on()` listeners register here) + +**Connection (lazy, on first `emit()`):** +- Skip `tanstack-connect` handshake, go straight to WebSocket: `ws://${DEVTOOLS_HOST}:${DEVTOOLS_PORT}/__devtools/ws?bridge=server` +- On open: set `#connected = true`, flush `#queuedEvents` +- On message: parse event, check `eventId` against `#sentEventIds` for dedup, dispatch on local EventTarget (`.on()` listeners fire) +- On close/error: reconnect with exponential backoff (100ms → 200ms → 400ms... up to 5s) + +**Emit path (when `#useNetworkTransport`):** +- Generate unique `eventId`, add to `#sentEventIds` +- Set `source: "server-bridge"` on the event +- If connected: send JSON over WebSocket +- If not yet connected: queue (existing queuing logic reused) + +**Listen path (`.on()` / `.onAll()` / `.onAllPluginEvents()`):** +- Register on local EventTarget as they do now +- Incoming WebSocket messages dispatched as CustomEvents on local EventTarget +- Listeners work transparently — they don't know events came from the network + +### Event Protocol Changes + +Two new optional fields added to `TanStackDevtoolsEvent`: + +```typescript +interface TanStackDevtoolsEvent { + type: TEventName + payload: TPayload + pluginId?: string + eventId?: string // unique per emission, for dedup + source?: 'server-bridge' // helps ServerEventBus route +} +``` + +- `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). + +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. + +**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. + +**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 + +### `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 WebSocket connection logic (lazy, on first emit) +- Add `eventId` generation 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 + +### No changes to: +- Vite plugin (`devtools-vite`) — placeholder injection already covers `@tanstack/devtools-event-client` +- Browser-side `ClientEventBus` — unaffected +- Any consuming libraries (`@tanstack/ai`, etc.) — transparent From 42b3beb23dbb0c9c8599c891bddb87129c123b72 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Thu, 12 Mar 2026 12:05:36 +0100 Subject: [PATCH 02/15] 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. --- ...03-12-network-transport-fallback-design.md | 59 +++++++++++++------ 1 file changed, 40 insertions(+), 19 deletions(-) diff --git a/docs/superpowers/specs/2026-03-12-network-transport-fallback-design.md b/docs/superpowers/specs/2026-03-12-network-transport-fallback-design.md index 819bb630..0da1cf2a 100644 --- a/docs/superpowers/specs/2026-03-12-network-transport-fallback-design.md +++ b/docs/superpowers/specs/2026-03-12-network-transport-fallback-design.md @@ -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. @@ -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 @@ -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:** @@ -98,14 +105,14 @@ interface TanStackDevtoolsEvent { } ``` -- `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). @@ -113,27 +120,41 @@ Additive changes — existing events without these fields work exactly as before **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 From ed6e0a7e2af0e90bd63ad55516fbc2e33469ca02 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Thu, 12 Mar 2026 12:08:31 +0100 Subject: [PATCH 03/15] 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). --- .../specs/2026-03-12-network-transport-fallback-design.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/superpowers/specs/2026-03-12-network-transport-fallback-design.md b/docs/superpowers/specs/2026-03-12-network-transport-fallback-design.md index 0da1cf2a..f3a80d24 100644 --- a/docs/superpowers/specs/2026-03-12-network-transport-fallback-design.md +++ b/docs/superpowers/specs/2026-03-12-network-transport-fallback-design.md @@ -55,7 +55,7 @@ These are already replaced by the Vite plugin's `connection-injection` transform **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. +1. **URL matching:** The WebSocket upgrade handlers use exact string equality (`req.url === '/__devtools/ws'`) in both the standalone server (line 305) and external server (line 273) code paths. Both must change to prefix matching or URL parsing (e.g., `req.url?.startsWith('/__devtools/ws')`) to support the `?bridge=server` query parameter. Note: the SSE (`/__devtools/sse`) and POST (`/__devtools/send`) URL checks do NOT need this change since they don't use query parameters. 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. @@ -132,7 +132,7 @@ Additive changes — existing events without these fields work exactly as before - 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()` +- Update POST handler (`/__devtools/send`) to check `source` field and route `"server-bridge"` messages through `emit()` instead of just `emitToServer()` — both the standalone handler (in `createSSEServer()`) and the external server handler (in `start()`) need this change ### `packages/event-bus-client/src/plugin.ts` (EventClient) - Add `declare const __TANSTACK_DEVTOOLS_PORT__` / `__TANSTACK_DEVTOOLS_HOST__` / `__TANSTACK_DEVTOOLS_PROTOCOL__` placeholders (following existing codebase convention from `client.ts`) From c1b7af1deadba7aabb7f4c274c5c9ec4cffcb971 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Thu, 12 Mar 2026 12:42:53 +0100 Subject: [PATCH 04/15] 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. --- .../2026-03-12-network-transport-fallback.md | 1507 +++++++++++++++++ 1 file changed, 1507 insertions(+) create mode 100644 docs/superpowers/plans/2026-03-12-network-transport-fallback.md diff --git a/docs/superpowers/plans/2026-03-12-network-transport-fallback.md b/docs/superpowers/plans/2026-03-12-network-transport-fallback.md new file mode 100644 index 00000000..fb90de43 --- /dev/null +++ b/docs/superpowers/plans/2026-03-12-network-transport-fallback.md @@ -0,0 +1,1507 @@ +# Network Transport Fallback Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Enable devtools events to flow bidirectionally across process/thread isolation boundaries (Nitro v3 workers, Cloudflare Workers, etc.) via automatic WebSocket fallback. + +**Architecture:** When `EventClient` detects it's in an isolated server environment (no `globalThis.__TANSTACK_EVENT_TARGET__`, no `window`), it falls back to a WebSocket connection to `ServerEventBus`. `ServerEventBus` distinguishes "server bridge" WebSocket connections from browser clients and routes bridge messages through both `emitEventToClients()` and `emitToServer()`. Echo prevention uses a 200-entry ring buffer of event IDs. + +**Tech Stack:** TypeScript, Vitest, WebSocket (native `globalThis.WebSocket` with HTTP POST fallback), Node.js EventTarget + +**Spec:** `docs/superpowers/specs/2026-03-12-network-transport-fallback-design.md` + +--- + +## Chunk 1: Event Protocol + ServerEventBus Changes + +### Task 1: Update TanStackDevtoolsEvent interface in all 3 locations + +**Files:** +- Modify: `packages/event-bus/src/server/server.ts:7-14` +- Modify: `packages/event-bus/src/client/client.ts:29-33` +- Modify: `packages/event-bus-client/src/plugin.ts:1-5` + +- [ ] **Step 1: Write failing type test for new fields** + +Create a file that verifies the new fields exist on the interface. Run existing tests first to confirm green baseline. + +Run: `cd packages/event-bus && pnpm test:lib --run` +Run: `cd packages/event-bus-client && pnpm test:lib --run` +Expected: All existing tests PASS + +- [ ] **Step 2: Add `eventId` and `source` to server.ts interface** + +```typescript +// packages/event-bus/src/server/server.ts lines 7-14 +export interface TanStackDevtoolsEvent< + TEventName extends string, + TPayload = any, +> { + type: TEventName + payload: TPayload + pluginId?: string + eventId?: string + source?: 'server-bridge' +} +``` + +- [ ] **Step 3: Add `eventId` and `source` to client.ts interface** + +```typescript +// packages/event-bus/src/client/client.ts lines 29-33 +interface TanStackDevtoolsEvent { + type: TEventName + payload: TPayload + pluginId?: string + eventId?: string + source?: 'server-bridge' +} +``` + +- [ ] **Step 4: Add `eventId` and `source` to plugin.ts interface** + +```typescript +// packages/event-bus-client/src/plugin.ts lines 1-5 +interface TanStackDevtoolsEvent { + type: TEventName + payload: TPayload + pluginId?: string + eventId?: string + source?: 'server-bridge' +} +``` + +- [ ] **Step 5: Run all tests to confirm no regressions** + +Run: `cd packages/event-bus && pnpm test:lib --run` +Run: `cd packages/event-bus-client && pnpm test:lib --run` +Expected: All tests PASS (additive change, fully backward compatible) + +- [ ] **Step 6: Commit** + +```bash +git add packages/event-bus/src/server/server.ts packages/event-bus/src/client/client.ts packages/event-bus-client/src/plugin.ts +git commit -m "feat: add eventId and source fields to TanStackDevtoolsEvent interface" +``` + +--- + +### Task 2: ServerEventBus — server bridge WebSocket support + +**Files:** +- Modify: `packages/event-bus/src/server/server.ts:186-200` (handleNewConnection) +- Modify: `packages/event-bus/src/server/server.ts:50-53` (new bridge tracking set) +- Modify: `packages/event-bus/src/server/server.ts:273` (external upgrade URL matching) +- Modify: `packages/event-bus/src/server/server.ts:305` (standalone upgrade URL matching) +- Test: `packages/event-bus/tests/server.test.ts` + +- [ ] **Step 1: Write failing test — bridge WebSocket connection is accepted** + +Add to `packages/event-bus/tests/server.test.ts`: + +```typescript +import WebSocket from 'ws' + +describe('server bridge connections', () => { + it('should accept WebSocket connections with ?bridge=server query param', async () => { + bus = new ServerEventBus({ port: 0 }) + const port = await bus.start() + + const ws = new WebSocket(`ws://localhost:${port}/__devtools/ws?bridge=server`) + await new Promise((resolve, reject) => { + ws.on('open', () => resolve()) + ws.on('error', (err) => reject(err)) + }) + + expect(ws.readyState).toBe(WebSocket.OPEN) + ws.close() + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd packages/event-bus && pnpm test:lib --run` +Expected: FAIL — connection refused or not upgraded (exact equality `req.url === '/__devtools/ws'` doesn't match `/__devtools/ws?bridge=server`) + +- [ ] **Step 3: Fix URL matching in both upgrade handlers** + +In `packages/event-bus/src/server/server.ts`, change the standalone upgrade handler (line 305): + +```typescript +// Before: +if (req.url === '/__devtools/ws') { +// After: +if (req.url === '/__devtools/ws' || req.url?.startsWith('/__devtools/ws?')) { +``` + +And the external server upgrade handler (line 273): + +```typescript +// Before: +if (req.url === '/__devtools/ws') { +// After: +if (req.url === '/__devtools/ws' || req.url?.startsWith('/__devtools/ws?')) { +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cd packages/event-bus && pnpm test:lib --run` +Expected: PASS + +- [ ] **Step 5: Write failing test — bridge messages are broadcast to browser clients** + +```typescript +it('should broadcast server bridge messages to other WebSocket clients', async () => { + bus = new ServerEventBus({ port: 0 }) + const port = await bus.start() + + // Connect a "browser" client (no ?bridge=server) + const browserWs = new WebSocket(`ws://localhost:${port}/__devtools/ws`) + await new Promise((resolve) => browserWs.on('open', resolve)) + + // Connect a "server bridge" client + const bridgeWs = new WebSocket(`ws://localhost:${port}/__devtools/ws?bridge=server`) + await new Promise((resolve) => bridgeWs.on('open', resolve)) + + // Listen for messages on the browser client + const received = new Promise((resolve) => { + browserWs.on('message', (data) => resolve(JSON.parse(data.toString()))) + }) + + // Send event from bridge + bridgeWs.send(JSON.stringify({ + type: 'test:event', + payload: { foo: 'bar' }, + pluginId: 'test', + source: 'server-bridge', + })) + + const event = await received + expect(event.type).toBe('test:event') + expect(event.payload).toEqual({ foo: 'bar' }) + + browserWs.close() + bridgeWs.close() +}) +``` + +- [ ] **Step 6: Run test to verify it fails** + +Run: `cd packages/event-bus && pnpm test:lib --run` +Expected: FAIL — bridge message goes to `emitToServer()` only, browser client never receives it + +- [ ] **Step 7: Implement bridge connection tracking and routing** + +Add a bridge tracking set and modify `handleNewConnection`: + +```typescript +// In ServerEventBus class, add new field after #clients: +#bridgeClients = new Set() + +// Replace handleNewConnection method: +private handleNewConnection(wss: WebSocketServer) { + wss.on('connection', (ws: WebSocket, req: http.IncomingMessage) => { + const isBridge = req?.url?.includes('bridge=server') ?? false + this.debugLog(`New WebSocket client connected (bridge: ${isBridge})`) + this.#clients.add(ws) + if (isBridge) { + this.#bridgeClients.add(ws) + } + ws.on('close', () => { + this.debugLog('WebSocket client disconnected') + this.#clients.delete(ws) + this.#bridgeClients.delete(ws) + }) + ws.on('message', (msg) => { + this.debugLog('Received message from WebSocket client', msg.toString()) + const data = parseWithBigInt(msg.toString()) + if (isBridge) { + // Bridge messages go to both browser clients and in-process EventTarget + this.emit(data) + } else { + // Browser messages go to in-process EventTarget only + this.emitToServer(data) + } + }) + }) +} +``` + +Also update `stop()` to clear `#bridgeClients`: + +```typescript +// In stop() method, after this.#clients.clear(): +this.#bridgeClients.clear() +``` + +- [ ] **Step 8: Run tests to verify they pass** + +Run: `cd packages/event-bus && pnpm test:lib --run` +Expected: All tests PASS including the new bridge tests + +- [ ] **Step 9: Write test — bridge messages also dispatch on in-process EventTarget** + +```typescript +it('should dispatch server bridge messages on in-process EventTarget', async () => { + bus = new ServerEventBus({ port: 0 }) + const port = await bus.start() + + const eventTarget = globalThis.__TANSTACK_EVENT_TARGET__! + const received = new Promise((resolve) => { + eventTarget.addEventListener('test:event', (e) => { + resolve((e as CustomEvent).detail) + }) + }) + + const bridgeWs = new WebSocket(`ws://localhost:${port}/__devtools/ws?bridge=server`) + await new Promise((resolve) => bridgeWs.on('open', resolve)) + + bridgeWs.send(JSON.stringify({ + type: 'test:event', + payload: { data: 123 }, + pluginId: 'test', + source: 'server-bridge', + })) + + const event = await received + expect(event.type).toBe('test:event') + expect(event.payload).toEqual({ data: 123 }) + + bridgeWs.close() +}) +``` + +- [ ] **Step 10: Run test to verify it passes** + +Run: `cd packages/event-bus && pnpm test:lib --run` +Expected: PASS (already handled by `emit()` calling `emitToServer()`) + +- [ ] **Step 11: Write test — regular browser messages do NOT broadcast to other clients** + +```typescript +it('should NOT broadcast regular browser client messages to other WebSocket clients', async () => { + bus = new ServerEventBus({ port: 0 }) + const port = await bus.start() + + const browserWs1 = new WebSocket(`ws://localhost:${port}/__devtools/ws`) + await new Promise((resolve) => browserWs1.on('open', resolve)) + + const browserWs2 = new WebSocket(`ws://localhost:${port}/__devtools/ws`) + await new Promise((resolve) => browserWs2.on('open', resolve)) + + let received = false + browserWs2.on('message', () => { received = true }) + + // Send from browser client 1 (no bridge) + browserWs1.send(JSON.stringify({ + type: 'test:event', + payload: {}, + })) + + // Wait a bit + await new Promise((resolve) => setTimeout(resolve, 100)) + + // Browser client 2 should NOT have received it (browser→server only) + expect(received).toBe(false) + + browserWs1.close() + browserWs2.close() +}) +``` + +- [ ] **Step 12: Run test to verify it passes** + +Run: `cd packages/event-bus && pnpm test:lib --run` +Expected: PASS + +- [ ] **Step 13: Commit** + +```bash +git add packages/event-bus/src/server/server.ts packages/event-bus/tests/server.test.ts +git commit -m "feat: add server bridge WebSocket connection support to ServerEventBus" +``` + +--- + +### Task 3: ServerEventBus — POST handler source-based routing + +**Files:** +- Modify: `packages/event-bus/src/server/server.ts:153-165` (standalone POST handler) +- Modify: `packages/event-bus/src/server/server.ts:249-264` (external POST handler) +- Test: `packages/event-bus/tests/server.test.ts` + +- [ ] **Step 1: Write failing test — POST with source=server-bridge broadcasts to clients** + +```typescript +describe('POST handler source-based routing', () => { + it('should broadcast POST messages with source=server-bridge to WebSocket clients', async () => { + bus = new ServerEventBus({ port: 0 }) + const port = await bus.start() + + // Connect a browser WebSocket client + const browserWs = new WebSocket(`ws://localhost:${port}/__devtools/ws`) + await new Promise((resolve) => browserWs.on('open', resolve)) + + const received = new Promise((resolve) => { + browserWs.on('message', (data) => resolve(JSON.parse(data.toString()))) + }) + + // POST with source: 'server-bridge' + await new Promise((resolve) => { + const req = http.request({ + hostname: 'localhost', + port, + path: '/__devtools/send', + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + }, () => resolve()) + req.write(JSON.stringify({ + type: 'test:event', + payload: { from: 'bridge' }, + source: 'server-bridge', + })) + req.end() + }) + + const event = await received + expect(event.type).toBe('test:event') + expect(event.payload).toEqual({ from: 'bridge' }) + + browserWs.close() + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd packages/event-bus && pnpm test:lib --run` +Expected: FAIL — POST handler calls `emitToServer()` only, browser client never receives + +- [ ] **Step 3: Update standalone POST handler to check source field** + +In `createSSEServer()`, change the POST handler (lines 153-165): + +```typescript +if (req.url === '/__devtools/send' && req.method === 'POST') { + let body = '' + req.on('data', (chunk) => (body += chunk)) + req.on('end', () => { + try { + const msg = parseWithBigInt(body) + this.debugLog('Received event from client', msg) + if (msg.source === 'server-bridge') { + this.emit(msg) + } else { + this.emitToServer(msg) + } + } catch {} + }) + res.writeHead(200).end() + return +} +``` + +- [ ] **Step 4: Update external server POST handler** + +In `start()`, change the external POST handler (lines 249-264): + +```typescript +if (req.url === '/__devtools/send' && req.method === 'POST') { + let body = '' + req.on('data', (chunk) => (body += chunk)) + req.on('end', () => { + try { + const msg = parseWithBigInt(body) + this.debugLog('Received event from client (external server)', msg) + if (msg.source === 'server-bridge') { + this.emit(msg) + } else { + this.emitToServer(msg) + } + } catch {} + }) + res.writeHead(200).end() + return +} +``` + +- [ ] **Step 5: Run tests to verify all pass** + +Run: `cd packages/event-bus && pnpm test:lib --run` +Expected: All PASS + +- [ ] **Step 6: Write test for external server POST routing** + +```typescript +describe('POST handler source-based routing (external server)', () => { + let externalServer: http.Server + + beforeEach(async () => { + externalServer = http.createServer() + await new Promise((resolve) => { + externalServer.listen(0, () => resolve()) + }) + }) + + afterEach(() => { + externalServer.close() + }) + + it('should broadcast POST with source=server-bridge on external server', async () => { + bus = new ServerEventBus({ httpServer: externalServer }) + const port = await bus.start() + + const browserWs = new WebSocket(`ws://localhost:${port}/__devtools/ws`) + await new Promise((resolve) => browserWs.on('open', resolve)) + + const received = new Promise((resolve) => { + browserWs.on('message', (data) => resolve(JSON.parse(data.toString()))) + }) + + await new Promise((resolve) => { + const req = http.request({ + hostname: 'localhost', + port, + path: '/__devtools/send', + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + }, () => resolve()) + req.write(JSON.stringify({ + type: 'test:event', + payload: { from: 'bridge' }, + source: 'server-bridge', + })) + req.end() + }) + + const event = await received + expect(event.type).toBe('test:event') + + browserWs.close() + }) +}) +``` + +- [ ] **Step 7: Run tests** + +Run: `cd packages/event-bus && pnpm test:lib --run` +Expected: All PASS + +- [ ] **Step 8: Commit** + +```bash +git add packages/event-bus/src/server/server.ts packages/event-bus/tests/server.test.ts +git commit -m "feat: add source-based routing to POST handlers for server bridge support" +``` + +--- + +## Chunk 2: EventClient Network Transport + +### Task 4: EventClient — ring buffer utility + +**Files:** +- Create: `packages/event-bus-client/src/ring-buffer.ts` +- Test: `packages/event-bus-client/tests/ring-buffer.test.ts` + +- [ ] **Step 1: Write failing tests for ring buffer** + +Create `packages/event-bus-client/tests/ring-buffer.test.ts`: + +```typescript +// @vitest-environment node +import { describe, expect, it } from 'vitest' +import { RingBuffer } from '../src/ring-buffer' + +describe('RingBuffer', () => { + it('should track added items via has()', () => { + const buf = new RingBuffer(5) + buf.add('a') + expect(buf.has('a')).toBe(true) + expect(buf.has('b')).toBe(false) + }) + + it('should evict oldest items when capacity is exceeded', () => { + const buf = new RingBuffer(3) + buf.add('a') + buf.add('b') + buf.add('c') + buf.add('d') // evicts 'a' + expect(buf.has('a')).toBe(false) + expect(buf.has('b')).toBe(true) + expect(buf.has('c')).toBe(true) + expect(buf.has('d')).toBe(true) + }) + + it('should handle wrapping around the buffer', () => { + const buf = new RingBuffer(2) + buf.add('a') + buf.add('b') + buf.add('c') // evicts 'a' + buf.add('d') // evicts 'b' + expect(buf.has('a')).toBe(false) + expect(buf.has('b')).toBe(false) + expect(buf.has('c')).toBe(true) + expect(buf.has('d')).toBe(true) + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd packages/event-bus-client && pnpm test:lib --run` +Expected: FAIL — module not found + +- [ ] **Step 3: Implement RingBuffer** + +Create `packages/event-bus-client/src/ring-buffer.ts`: + +```typescript +export class RingBuffer { + #buffer: Array + #set: Set + #index = 0 + #capacity: number + + constructor(capacity: number) { + this.#capacity = capacity + this.#buffer = new Array(capacity).fill('') + this.#set = new Set() + } + + add(item: string) { + const evicted = this.#buffer[this.#index] + if (evicted) { + this.#set.delete(evicted) + } + this.#buffer[this.#index] = item + this.#set.add(item) + this.#index = (this.#index + 1) % this.#capacity + } + + has(item: string): boolean { + return this.#set.has(item) + } +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cd packages/event-bus-client && pnpm test:lib --run` +Expected: All PASS + +- [ ] **Step 5: Commit** + +```bash +git add packages/event-bus-client/src/ring-buffer.ts packages/event-bus-client/tests/ring-buffer.test.ts +git commit -m "feat: add RingBuffer utility for event ID deduplication" +``` + +--- + +### Task 5: EventClient — network transport detection + +**Files:** +- Modify: `packages/event-bus-client/src/plugin.ts:1-8` (add placeholders) +- Modify: `packages/event-bus-client/src/plugin.ts:14-27` (add new private fields) +- Modify: `packages/event-bus-client/src/plugin.ts:121-160` (modify getGlobalTarget) +- Test: `packages/event-bus-client/tests/network-transport.test.ts` + +- [ ] **Step 1: Write failing test for network transport detection** + +Create `packages/event-bus-client/tests/network-transport.test.ts`: + +```typescript +// @vitest-environment node +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { EventClient } from '../src' + +describe('EventClient network transport detection', () => { + beforeEach(() => { + // Ensure no global event target (simulating isolated worker) + globalThis.__TANSTACK_EVENT_TARGET__ = null + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('should not activate network transport when placeholders are not replaced', () => { + // Without Vite plugin, __TANSTACK_DEVTOOLS_PORT__ is undefined + const client = new EventClient({ + pluginId: 'test-no-network', + debug: false, + }) + // Client should fall back to local EventTarget (no network) + // Emitting should not throw + client.emit('event', { foo: 'bar' }) + }) +}) +``` + +- [ ] **Step 2: Run test to verify baseline behavior works** + +Run: `cd packages/event-bus-client && pnpm test:lib --run` +Expected: PASS (current behavior — creates local EventTarget, events go nowhere but no crash) + +- [ ] **Step 3: Add compile-time placeholders to plugin.ts** + +Add at top of `packages/event-bus-client/src/plugin.ts`, after the interface: + +```typescript +// Compile-time placeholders replaced by the Vite plugin's connection-injection transform. +// When not replaced (no Vite plugin), these remain undefined and network transport is disabled. +declare const __TANSTACK_DEVTOOLS_PORT__: number | undefined +declare const __TANSTACK_DEVTOOLS_HOST__: string | undefined +declare const __TANSTACK_DEVTOOLS_PROTOCOL__: 'http' | 'https' | undefined + +function getDevtoolsPort(): number | undefined { + try { + return typeof __TANSTACK_DEVTOOLS_PORT__ !== 'undefined' ? __TANSTACK_DEVTOOLS_PORT__ : undefined + } catch { + return undefined + } +} + +function getDevtoolsHost(): string | undefined { + try { + return typeof __TANSTACK_DEVTOOLS_HOST__ !== 'undefined' ? __TANSTACK_DEVTOOLS_HOST__ : undefined + } catch { + return undefined + } +} + +function getDevtoolsProtocol(): 'http' | 'https' | undefined { + try { + return typeof __TANSTACK_DEVTOOLS_PROTOCOL__ !== 'undefined' ? __TANSTACK_DEVTOOLS_PROTOCOL__ : undefined + } catch { + return undefined + } +} +``` + +- [ ] **Step 4: Add new private fields to EventClient class** + +Add to the class after `#internalEventTarget`: + +```typescript +#useNetworkTransport = false +#networkTransportDetected = false // one-time detection flag +#cachedLocalTarget: EventTarget | null = null // cached for consistent listener registration +#ws: WebSocket | null = null +#wsConnecting = false +#wsReconnectTimer: ReturnType | null = null +#wsReconnectDelay = 100 // exponential backoff: 100, 200, 400, ... 5000ms +#wsMaxReconnectAttempts = 10 // give up on WebSocket after this many failures +#wsReconnectAttempts = 0 +#wsGaveUp = false // true when WebSocket is permanently unavailable, use HTTP-only +#sentEventIds: RingBuffer = new RingBuffer(200) +#networkPort: number | undefined = undefined +#networkHost: string | undefined = undefined +#networkProtocol: 'http' | 'https' | undefined = undefined +``` + +Import `RingBuffer` at the top: + +```typescript +import { RingBuffer } from './ring-buffer' +``` + +- [ ] **Step 5: Modify getGlobalTarget() for network transport detection** + +Replace the `getGlobalTarget()` method. **Critical: cache the local EventTarget** so `.on()` listeners and `emit()` use the same instance: + +```typescript +private getGlobalTarget() { + // server one is the global event target + if ( + typeof globalThis !== 'undefined' && + globalThis.__TANSTACK_EVENT_TARGET__ + ) { + this.debugLog('Using global event target') + return globalThis.__TANSTACK_EVENT_TARGET__ + } + // Client event target is the browser window object + if ( + typeof window !== 'undefined' && + typeof window.addEventListener !== 'undefined' + ) { + this.debugLog('Using window as event target') + return window + } + + // We're in an isolated server environment (worker thread, separate process, etc.) + // Check if devtools server coordinates are available (Vite plugin replaced placeholders) + if (!this.#networkTransportDetected) { + this.#networkTransportDetected = true + const port = getDevtoolsPort() + if (port !== undefined) { + this.#useNetworkTransport = true + this.debugLog('Network transport activated — devtools server detected at port', port) + } + } + + // Return cached local EventTarget to ensure .on() and emit() use the same instance + if (this.#cachedLocalTarget) { + return this.#cachedLocalTarget + } + + // Protect against non-web environments like react-native + if (typeof EventTarget === 'undefined') { + this.debugLog( + 'No event mechanism available, running in non-web environment', + ) + const noop = { + addEventListener: () => {}, + removeEventListener: () => {}, + dispatchEvent: () => false, + } + this.#cachedLocalTarget = noop as any + return noop + } + + const eventTarget = new EventTarget() + this.#cachedLocalTarget = eventTarget + this.debugLog('Using cached local EventTarget as fallback') + return eventTarget +} +``` + +- [ ] **Step 6: Run all tests to verify no regressions** + +Run: `cd packages/event-bus-client && pnpm test:lib --run` +Expected: All PASS (network transport does nothing yet, existing behavior preserved) + +- [ ] **Step 7: Commit** + +```bash +git add packages/event-bus-client/src/plugin.ts packages/event-bus-client/src/ring-buffer.ts packages/event-bus-client/tests/network-transport.test.ts +git commit -m "feat: add network transport detection and compile-time placeholders to EventClient" +``` + +--- + +### Task 6: EventClient — WebSocket connection, emit, and receive + +**Files:** +- Modify: `packages/event-bus-client/src/plugin.ts` (add connection, emit, receive logic) +- Test: `packages/event-bus-client/tests/network-transport.test.ts` + +This is the core task. We add: lazy WebSocket connection on first `emit()`, event ID stamping, sending via WebSocket, receiving and deduplicating incoming messages, and reconnection. + +- [ ] **Step 1: Write failing integration test — emit via network transport reaches ServerEventBus** + +Add to `packages/event-bus-client/tests/network-transport.test.ts`. Note: all imports at top of file: + +```typescript +import { ServerEventBus } from '@tanstack/devtools-event-bus/server' +import { createNetworkTransportClient } from '../src/plugin' + +describe('EventClient network transport emit', () => { + let serverBus: ServerEventBus + + beforeEach(async () => { + globalThis.__TANSTACK_EVENT_TARGET__ = null + }) + + afterEach(async () => { + serverBus?.stop() + globalThis.__TANSTACK_EVENT_TARGET__ = null + await new Promise((resolve) => setTimeout(resolve, 50)) + }) + + it('should emit events to ServerEventBus via WebSocket when using network transport', async () => { + // Start a server bus to receive events + serverBus = new ServerEventBus({ port: 0 }) + const port = await serverBus.start() + + // Save the server's event target before we null it for the client + const serverEventTarget = globalThis.__TANSTACK_EVENT_TARGET__! + + // Null out the global so EventClient detects isolation + globalThis.__TANSTACK_EVENT_TARGET__ = null + + const client = createNetworkTransportClient({ + pluginId: 'test-network', + port, + host: 'localhost', + protocol: 'http', + }) + + // Listen on the server's event target for the event + const received = new Promise((resolve) => { + serverEventTarget.addEventListener('test-network:event', (e) => { + resolve((e as CustomEvent).detail) + }) + }) + + client.emit('event', { hello: 'world' }) + + // Wait for WebSocket connection + message delivery + const event = await Promise.race([ + received, + new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), 3000)), + ]) + + expect(event.type).toBe('test-network:event') + expect(event.payload).toEqual({ hello: 'world' }) + expect(event.source).toBe('server-bridge') + + client.destroy() + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd packages/event-bus-client && pnpm test:lib --run` +Expected: FAIL — `createNetworkTransportClient` doesn't exist yet + +- [ ] **Step 3: Add event ID generation helper** + +Add to `packages/event-bus-client/src/plugin.ts`: + +```typescript +let globalEventIdCounter = 0 + +function generateEventId(): string { + return `${++globalEventIdCounter}-${Date.now()}` +} +``` + +- [ ] **Step 4: Add WebSocket connection method to EventClient** + +Add to the EventClient class: + +```typescript +private connectWebSocket() { + if (this.#wsConnecting || this.#ws) return + this.#wsConnecting = true + + const port = getDevtoolsPort() + const host = getDevtoolsHost() ?? 'localhost' + const protocol = getDevtoolsProtocol() ?? 'http' + const wsProtocol = protocol === 'https' ? 'wss' : 'ws' + const url = `${wsProtocol}://${host}:${port}/__devtools/ws?bridge=server` + + this.debugLog('Connecting to ServerEventBus via WebSocket', url) + + try { + const ws = new WebSocket(url) + + ws.addEventListener('open', () => { + this.debugLog('WebSocket connected to ServerEventBus') + this.#ws = ws + this.#wsConnecting = false + this.#connected = true + this.#wsReconnectDelay = 100 // reset backoff + + // Flush queued events + const queued = [...this.#queuedEvents] + this.#queuedEvents = [] + for (const event of queued) { + this.sendViaNetwork(event) + } + }) + + ws.addEventListener('message', (e) => { + try { + const data = typeof e.data === 'string' ? e.data : e.data.toString() + const event = JSON.parse(data) + + // Dedup: ignore events we sent ourselves + if (event.eventId && this.#sentEventIds.has(event.eventId)) { + this.debugLog('Ignoring echoed event', event.eventId) + return + } + + this.debugLog('Received event via network transport', event) + + // Dispatch on local EventTarget so .on() listeners fire + const target = this.#eventTarget() + try { + target.dispatchEvent(new CustomEvent(event.type, { detail: event })) + target.dispatchEvent(new CustomEvent('tanstack-devtools-global', { detail: event })) + } catch { + // EventTarget may not support CustomEvent in all environments + } + } catch { + this.debugLog('Failed to parse incoming WebSocket message') + } + }) + + ws.addEventListener('close', () => { + this.debugLog('WebSocket connection closed') + this.#ws = null + this.#connected = false + this.#wsConnecting = false + this.scheduleReconnect() + }) + + ws.addEventListener('error', () => { + this.debugLog('WebSocket connection error') + this.#wsConnecting = false + }) + } catch { + this.debugLog('Failed to create WebSocket connection') + this.#wsConnecting = false + this.scheduleReconnect() + } +} + +private scheduleReconnect() { + if (this.#wsReconnectTimer) return + if (!this.#useNetworkTransport) return + + this.debugLog(`Scheduling reconnect in ${this.#wsReconnectDelay}ms`) + this.#wsReconnectTimer = setTimeout(() => { + this.#wsReconnectTimer = null + this.connectWebSocket() + }, this.#wsReconnectDelay) + + // Exponential backoff, max 5s + this.#wsReconnectDelay = Math.min(this.#wsReconnectDelay * 2, 5000) +} + +private sendViaNetwork(event: TanStackDevtoolsEvent) { + const eventWithId = { + ...event, + eventId: generateEventId(), + source: 'server-bridge' as const, + } + this.#sentEventIds.add(eventWithId.eventId!) + + if (this.#ws && this.#ws.readyState === WebSocket.OPEN) { + this.debugLog('Sending event via WebSocket', eventWithId) + this.#ws.send(JSON.stringify(eventWithId)) + } else { + // HTTP POST fallback + this.sendViaHttp(eventWithId) + } +} + +private sendViaHttp(event: TanStackDevtoolsEvent) { + const port = getDevtoolsPort() + const host = getDevtoolsHost() ?? 'localhost' + const protocol = getDevtoolsProtocol() ?? 'http' + + if (!port) return + + this.debugLog('Sending event via HTTP POST fallback', event) + + try { + fetch(`${protocol}://${host}:${port}/__devtools/send`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(event), + }).catch(() => { + this.debugLog('HTTP POST fallback failed') + }) + } catch { + this.debugLog('fetch not available for HTTP POST fallback') + } +} +``` + +- [ ] **Step 5: Modify the `emit()` method — network transport BEFORE `#failedToConnect`** + +**Critical ordering:** The network transport check must come BEFORE `#failedToConnect`, because in an isolated worker the in-process connect loop always fails and sets `#failedToConnect = true`. If we check after, network transport is permanently blocked. + +In the `emit()` method, add the network transport path AFTER the `#internalEventTarget` dispatch block and BEFORE the `if (this.#failedToConnect)` check: + +```typescript +// Network transport path — skip in-process handshake entirely. +// Must come BEFORE #failedToConnect check because in isolated workers +// the in-process handshake always fails. +if (this.#useNetworkTransport) { + const event = this.createEventPayload(eventSuffix, payload) + if (!this.#connected) { + this.#queuedEvents.push(event) + this.connectWebSocket() + return + } + this.sendViaNetwork(event) + return +} +``` + +Also, add queue preservation. When `getGlobalTarget()` first detects network transport (during the first `emit()`), events may have already been queued by the in-process path. Since `stopConnectLoop()` clears `#queuedEvents`, we need to prevent the in-process connect loop from ever starting when `#useNetworkTransport` is true. The ordering above achieves this — network transport check happens first, so `#connectFunction` / `startConnectLoop` are never called. + +- [ ] **Step 6: Add `createNetworkTransportClient` test helper and `destroy` method** + +Add internal methods to EventClient class: + +```typescript +/** @internal — only for testing and createNetworkTransportClient */ +___enableNetworkTransport(port: number, host: string, protocol: 'http' | 'https') { + this.#useNetworkTransport = true + this.#networkTransportDetected = true + this.#networkPort = port + this.#networkHost = host + this.#networkProtocol = protocol +} + +/** @internal */ +___destroyNetworkTransport() { + if (this.#wsReconnectTimer) { + clearTimeout(this.#wsReconnectTimer) + this.#wsReconnectTimer = null + } + if (this.#ws) { + this.#ws.close() + this.#ws = null + } + this.#connected = false + this.#useNetworkTransport = false +} +``` + +Add to `packages/event-bus-client/src/plugin.ts` at the end of the file: + +```typescript +/** + * Creates an EventClient with network transport explicitly enabled. + * Used for testing and for environments where compile-time placeholder + * replacement is not available. + */ +export function createNetworkTransportClient>({ + pluginId, + port, + host = 'localhost', + protocol = 'http', + debug = false, +}: { + pluginId: string + port: number + host?: string + protocol?: 'http' | 'https' + debug?: boolean +}): EventClient & { destroy: () => void } { + const client = new EventClient({ pluginId, debug }) + ;(client as any).___enableNetworkTransport(port, host, protocol) + // Attach destroy directly — keeps the original instance with all its methods intact + ;(client as any).destroy = () => (client as any).___destroyNetworkTransport() + return client as EventClient & { destroy: () => void } +} +``` + +Also export it from `packages/event-bus-client/src/index.ts`: + +```typescript +export { EventClient, createNetworkTransportClient } from './plugin' +``` + +Update `connectWebSocket()` to use override coordinates when available, and **add WebSocket retry limit** to fall back to HTTP-only: + +```typescript +private connectWebSocket() { + if (this.#wsConnecting || this.#ws) return + if (this.#wsGaveUp) return // WebSocket permanently unavailable, use HTTP-only + + this.#wsConnecting = true + + const port = this.#networkPort ?? getDevtoolsPort() + const host = this.#networkHost ?? getDevtoolsHost() ?? 'localhost' + const protocol = this.#networkProtocol ?? getDevtoolsProtocol() ?? 'http' + // ... rest unchanged +``` + +Update `scheduleReconnect()` to track attempts and give up: + +```typescript +private scheduleReconnect() { + if (this.#wsReconnectTimer) return + if (!this.#useNetworkTransport) return + if (this.#wsGaveUp) return + + this.#wsReconnectAttempts++ + if (this.#wsReconnectAttempts > this.#wsMaxReconnectAttempts) { + this.debugLog('WebSocket permanently unavailable, falling back to HTTP-only') + this.#wsGaveUp = true + // Flush any queued events via HTTP POST + const queued = [...this.#queuedEvents] + this.#queuedEvents = [] + for (const event of queued) { + this.sendViaHttp({ ...event, eventId: generateEventId(), source: 'server-bridge' }) + } + return + } + + this.debugLog(`Scheduling reconnect in ${this.#wsReconnectDelay}ms (attempt ${this.#wsReconnectAttempts}/${this.#wsMaxReconnectAttempts})`) + this.#wsReconnectTimer = setTimeout(() => { + this.#wsReconnectTimer = null + this.connectWebSocket() + }, this.#wsReconnectDelay) + + // Exponential backoff, max 5s + this.#wsReconnectDelay = Math.min(this.#wsReconnectDelay * 2, 5000) +} +``` + +Similarly update `sendViaHttp()`: + +```typescript +private sendViaHttp(event: TanStackDevtoolsEvent) { + const port = this.#networkPort ?? getDevtoolsPort() + const host = this.#networkHost ?? getDevtoolsHost() ?? 'localhost' + const protocol = this.#networkProtocol ?? getDevtoolsProtocol() ?? 'http' + // ... rest unchanged +``` + +Update `sendViaNetwork()` to use HTTP-only when WebSocket gave up: + +```typescript +private sendViaNetwork(event: TanStackDevtoolsEvent) { + const eventWithId = { + ...event, + eventId: generateEventId(), + source: 'server-bridge' as const, + } + this.#sentEventIds.add(eventWithId.eventId!) + + if (this.#wsGaveUp) { + // HTTP-only mode — WebSocket permanently unavailable + this.sendViaHttp(eventWithId) + return + } + + if (this.#ws && this.#ws.readyState === (globalThis.WebSocket?.OPEN ?? 1)) { + this.debugLog('Sending event via WebSocket', eventWithId) + this.#ws.send(JSON.stringify(eventWithId)) + } else { + // HTTP POST fallback for when WebSocket is temporarily disconnected + this.sendViaHttp(eventWithId) + } +} +``` + +- [ ] **Step 7: Run tests** + +Run: `cd packages/event-bus-client && pnpm test:lib --run` +Expected: All PASS including the new network transport test + +- [ ] **Step 8: Write test — receive events from ServerEventBus via network transport** + +Add to the network transport test file: + +```typescript +it('should receive events from ServerEventBus via WebSocket', async () => { + serverBus = new ServerEventBus({ port: 0 }) + const port = await serverBus.start() + const serverEventTarget = globalThis.__TANSTACK_EVENT_TARGET__! + globalThis.__TANSTACK_EVENT_TARGET__ = null + + const client = createNetworkTransportClient({ + pluginId: 'test-receive', + port, + host: 'localhost', + protocol: 'http', + }) + + // Register a listener + const received = new Promise((resolve) => { + client.on('incoming', (event) => resolve(event)) + }) + + // Trigger an emit to force the WebSocket connection to open + client.emit('ping', {}) + // Wait for connection + await new Promise((resolve) => setTimeout(resolve, 500)) + + // Now dispatch an event from the server side (simulating another plugin) + serverEventTarget.dispatchEvent( + new CustomEvent('tanstack-dispatch-event', { + detail: { + type: 'test-receive:incoming', + payload: { msg: 'from-server' }, + pluginId: 'test-receive', + }, + }), + ) + + const event = await Promise.race([ + received, + new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), 3000)), + ]) + + expect(event.type).toBe('test-receive:incoming') + expect(event.payload).toEqual({ msg: 'from-server' }) + + client.destroy() +}) +``` + +- [ ] **Step 9: Run tests** + +Run: `cd packages/event-bus-client && pnpm test:lib --run` +Expected: All PASS + +- [ ] **Step 10: Write test — echo deduplication** + +```typescript +it('should not receive its own echoed events', async () => { + serverBus = new ServerEventBus({ port: 0 }) + const port = await serverBus.start() + globalThis.__TANSTACK_EVENT_TARGET__ = null + + const client = createNetworkTransportClient({ + pluginId: 'test-dedup', + port, + host: 'localhost', + protocol: 'http', + }) + + const receivedEvents: Array = [] + client.on('event', (e) => receivedEvents.push(e)) + + // Emit — this goes to server, server broadcasts back, client should dedup + client.emit('event', { data: 'test' }) + + // Wait for round-trip + await new Promise((resolve) => setTimeout(resolve, 1000)) + + // Should not have received our own event back + expect(receivedEvents.length).toBe(0) + + client.destroy() +}) +``` + +- [ ] **Step 11: Run tests** + +Run: `cd packages/event-bus-client && pnpm test:lib --run` +Expected: All PASS + +- [ ] **Step 12: Write test — events queue during connection and flush on connect** + +```typescript +it('should queue events during connection and flush when connected', async () => { + serverBus = new ServerEventBus({ port: 0 }) + const port = await serverBus.start() + const serverEventTarget = globalThis.__TANSTACK_EVENT_TARGET__! + globalThis.__TANSTACK_EVENT_TARGET__ = null + + const client = createNetworkTransportClient({ + pluginId: 'test-queue', + port, + host: 'localhost', + protocol: 'http', + }) + + const received: Array = [] + serverEventTarget.addEventListener('test-queue:event', (e) => { + received.push((e as CustomEvent).detail) + }) + + // Emit multiple events before connection is established + client.emit('event', { n: 1 }) + client.emit('event', { n: 2 }) + client.emit('event', { n: 3 }) + + // Wait for connection + flush + await new Promise((resolve) => setTimeout(resolve, 2000)) + + expect(received.length).toBe(3) + expect(received[0].payload).toEqual({ n: 1 }) + expect(received[1].payload).toEqual({ n: 2 }) + expect(received[2].payload).toEqual({ n: 3 }) + + client.destroy() +}) +``` + +- [ ] **Step 13: Run tests** + +Run: `cd packages/event-bus-client && pnpm test:lib --run` +Expected: All PASS + +- [ ] **Step 14: Verify existing tests still pass** + +Run: `cd packages/event-bus && pnpm test:lib --run` +Run: `cd packages/event-bus-client && pnpm test:lib --run` +Expected: All PASS + +- [ ] **Step 15: Commit** + +```bash +git add packages/event-bus-client/src/plugin.ts packages/event-bus-client/tests/network-transport.test.ts +git commit -m "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." +``` + +--- + +## Chunk 3: Final verification + +### Task 7: Full cross-package integration test + +**Files:** +- Test: `packages/event-bus-client/tests/integration.test.ts` + +- [ ] **Step 1: Write end-to-end integration test** + +Create `packages/event-bus-client/tests/integration.test.ts`: + +```typescript +// @vitest-environment node +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { ServerEventBus } from '@tanstack/devtools-event-bus/server' +import { createNetworkTransportClient } from '../src/plugin' + +describe('End-to-end: ServerEventBus + EventClient network transport', () => { + let serverBus: ServerEventBus + + beforeEach(() => { + globalThis.__TANSTACK_EVENT_TARGET__ = null + globalThis.__TANSTACK_DEVTOOLS_SERVER__ = null + globalThis.__TANSTACK_DEVTOOLS_WSS_SERVER__ = null + process.env.NODE_ENV = 'development' + }) + + afterEach(async () => { + serverBus?.stop() + globalThis.__TANSTACK_EVENT_TARGET__ = null + globalThis.__TANSTACK_DEVTOOLS_SERVER__ = null + globalThis.__TANSTACK_DEVTOOLS_WSS_SERVER__ = null + await new Promise((resolve) => setTimeout(resolve, 100)) + }) + + it('should support bidirectional events between isolated EventClient and ServerEventBus', async () => { + // 1. Start ServerEventBus + serverBus = new ServerEventBus({ port: 0 }) + const port = await serverBus.start() + const serverEventTarget = globalThis.__TANSTACK_EVENT_TARGET__! + + // 2. Simulate isolation: null out globalThis + globalThis.__TANSTACK_EVENT_TARGET__ = null + + // 3. Create isolated EventClient with network transport + const client = createNetworkTransportClient({ + pluginId: 'e2e-test', + port, + host: 'localhost', + protocol: 'http', + }) + + // 4. Set up listener on the isolated client + const clientReceived = new Promise((resolve) => { + client.on('from-server', (event) => resolve(event)) + }) + + // 5. Emit from client → should reach server + const serverReceived = new Promise((resolve) => { + serverEventTarget.addEventListener('e2e-test:from-client', (e) => { + resolve((e as CustomEvent).detail) + }) + }) + + client.emit('from-client', { direction: 'client-to-server' }) + + // Wait for connection + delivery + const fromClient = await Promise.race([ + serverReceived, + new Promise((_, reject) => setTimeout(() => reject(new Error('timeout: client→server')), 3000)), + ]) + + expect(fromClient.payload).toEqual({ direction: 'client-to-server' }) + + // 6. Now emit from server → should reach isolated client + // Wait a moment for WebSocket to be fully ready for receiving + await new Promise((resolve) => setTimeout(resolve, 200)) + + serverEventTarget.dispatchEvent( + new CustomEvent('tanstack-dispatch-event', { + detail: { + type: 'e2e-test:from-server', + payload: { direction: 'server-to-client' }, + pluginId: 'e2e-test', + }, + }), + ) + + const fromServer = await Promise.race([ + clientReceived, + new Promise((_, reject) => setTimeout(() => reject(new Error('timeout: server→client')), 3000)), + ]) + + expect(fromServer.payload).toEqual({ direction: 'server-to-client' }) + + client.destroy() + }) + + it('should handle multiple isolated clients simultaneously', async () => { + serverBus = new ServerEventBus({ port: 0 }) + const port = await serverBus.start() + const serverEventTarget = globalThis.__TANSTACK_EVENT_TARGET__! + globalThis.__TANSTACK_EVENT_TARGET__ = null + + const client1 = createNetworkTransportClient({ + pluginId: 'multi-1', + port, + host: 'localhost', + }) + + const client2 = createNetworkTransportClient({ + pluginId: 'multi-2', + port, + host: 'localhost', + }) + + // Both emit, both should reach server + const received: Array = [] + serverEventTarget.addEventListener('multi-1:ping', (e) => { + received.push((e as CustomEvent).detail) + }) + serverEventTarget.addEventListener('multi-2:ping', (e) => { + received.push((e as CustomEvent).detail) + }) + + client1.emit('ping', { from: 1 }) + client2.emit('ping', { from: 2 }) + + await new Promise((resolve) => setTimeout(resolve, 2000)) + + expect(received.length).toBe(2) + expect(received.map((e) => e.payload.from).sort()).toEqual([1, 2]) + + client1.destroy() + client2.destroy() + }) +}) +``` + +- [ ] **Step 2: Run integration tests** + +Run: `cd packages/event-bus-client && pnpm test:lib --run` +Expected: All PASS + +- [ ] **Step 3: Run ALL package tests to confirm no regressions** + +Run: `cd packages/event-bus && pnpm test:lib --run` +Run: `cd packages/event-bus-client && pnpm test:lib --run` +Expected: All PASS + +- [ ] **Step 4: Commit** + +```bash +git add packages/event-bus-client/tests/integration.test.ts +git commit -m "test: add end-to-end integration tests for network transport fallback" +``` + +- [ ] **Step 5: Final commit — update spec status** + +Update `docs/superpowers/specs/2026-03-12-network-transport-fallback-design.md` status from "Draft" to "Implemented". + +```bash +git add docs/superpowers/specs/2026-03-12-network-transport-fallback-design.md +git commit -m "docs: mark network transport fallback spec as implemented" +``` From 76d771196fc60033293f46e5cff1833bf58cdaa5 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Thu, 12 Mar 2026 12:55:09 +0100 Subject: [PATCH 05/15] feat: add eventId and source fields to TanStackDevtoolsEvent interface --- packages/event-bus-client/src/plugin.ts | 2 ++ packages/event-bus/src/client/client.ts | 2 ++ packages/event-bus/src/server/server.ts | 2 ++ 3 files changed, 6 insertions(+) diff --git a/packages/event-bus-client/src/plugin.ts b/packages/event-bus-client/src/plugin.ts index 9fd2d07b..fa23b9af 100644 --- a/packages/event-bus-client/src/plugin.ts +++ b/packages/event-bus-client/src/plugin.ts @@ -2,6 +2,8 @@ interface TanStackDevtoolsEvent { type: TEventName payload: TPayload pluginId?: string // Optional pluginId to filter events by plugin + eventId?: string + source?: 'server-bridge' } declare global { var __TANSTACK_EVENT_TARGET__: EventTarget | null diff --git a/packages/event-bus/src/client/client.ts b/packages/event-bus/src/client/client.ts index a3c2f7b9..5544893c 100644 --- a/packages/event-bus/src/client/client.ts +++ b/packages/event-bus/src/client/client.ts @@ -30,6 +30,8 @@ interface TanStackDevtoolsEvent { type: TEventName payload: TPayload pluginId?: string // Optional pluginId to filter events by plugin + eventId?: string + source?: 'server-bridge' } export interface ClientEventBusConfig { diff --git a/packages/event-bus/src/server/server.ts b/packages/event-bus/src/server/server.ts index 603df0b2..a5f3c07a 100644 --- a/packages/event-bus/src/server/server.ts +++ b/packages/event-bus/src/server/server.ts @@ -11,6 +11,8 @@ export interface TanStackDevtoolsEvent< type: TEventName payload: TPayload pluginId?: string // Optional pluginId to filter events by plugin + eventId?: string + source?: 'server-bridge' } // Used so no new server starts up when HMR happens declare global { From b4e9e7d2bb1b08203a991773a10febee8ca21402 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Thu, 12 Mar 2026 13:07:50 +0100 Subject: [PATCH 06/15] 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() --- packages/event-bus/src/server/server.ts | 30 ++++- packages/event-bus/tests/server.test.ts | 170 ++++++++++++++++++++++++ 2 files changed, 195 insertions(+), 5 deletions(-) diff --git a/packages/event-bus/src/server/server.ts b/packages/event-bus/src/server/server.ts index a5f3c07a..6cefd873 100644 --- a/packages/event-bus/src/server/server.ts +++ b/packages/event-bus/src/server/server.ts @@ -52,6 +52,7 @@ export interface ServerEventBusConfig { export class ServerEventBus { #eventTarget: EventTarget #clients = new Set() + #bridgeClients = new Set() #sseClients = new Set() #server: http.Server | null = null #wssServer: WebSocketServer | null = null @@ -186,17 +187,35 @@ export class ServerEventBus { } private handleNewConnection(wss: WebSocketServer) { - wss.on('connection', (ws: WebSocket) => { - this.debugLog('New WebSocket client connected') + wss.on('connection', (ws: WebSocket, req: http.IncomingMessage) => { + const isBridge = (() => { + try { + const url = new URL(req?.url ?? '', 'http://localhost') + return url.searchParams.get('bridge') === 'server' + } catch { + return false + } + })() + this.debugLog(`New WebSocket client connected (bridge: ${isBridge})`) this.#clients.add(ws) + if (isBridge) { + this.#bridgeClients.add(ws) + } ws.on('close', () => { this.debugLog('WebSocket client disconnected') this.#clients.delete(ws) + this.#bridgeClients.delete(ws) }) ws.on('message', (msg) => { this.debugLog('Received message from WebSocket client', msg.toString()) const data = parseWithBigInt(msg.toString()) - this.emitToServer(data) + if (isBridge) { + // Bridge messages go to both browser clients and in-process EventTarget + this.emit(data) + } else { + // Browser messages go to in-process EventTarget only + this.emitToServer(data) + } }) }) } @@ -272,7 +291,7 @@ export class ServerEventBus { socket: Duplex, head: Buffer, ) => { - if (req.url === '/__devtools/ws') { + if (req.url === '/__devtools/ws' || req.url?.startsWith('/__devtools/ws?')) { wss.handleUpgrade(req, socket, head, (ws) => { this.debugLog( 'WebSocket connection established (external server)', @@ -304,7 +323,7 @@ export class ServerEventBus { // Handle connection upgrade for WebSocket server.on('upgrade', (req, socket, head) => { - if (req.url === '/__devtools/ws') { + if (req.url === '/__devtools/ws' || req.url?.startsWith('/__devtools/ws?')) { wss.handleUpgrade(req, socket, head, (ws) => { this.debugLog('WebSocket connection established') wss.emit('connection', ws, req) @@ -375,6 +394,7 @@ export class ServerEventBus { }) this.debugLog('Clearing all connections') this.#clients.clear() + this.#bridgeClients.clear() this.#sseClients.forEach((res) => res.end()) this.#sseClients.clear() this.debugLog('Cleared all WS/SSE connections') diff --git a/packages/event-bus/tests/server.test.ts b/packages/event-bus/tests/server.test.ts index e36ad165..c40e59a3 100644 --- a/packages/event-bus/tests/server.test.ts +++ b/packages/event-bus/tests/server.test.ts @@ -1,5 +1,6 @@ import http from 'node:http' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import WebSocket from 'ws' import { ServerEventBus } from '../src/server/server' // Clear globalThis between tests to avoid cross-test contamination @@ -248,4 +249,173 @@ describe('ServerEventBus', () => { logSpy.mockRestore() }) }) + + describe('server bridge connections', () => { + it('should accept WebSocket connections with ?bridge=server query param', async () => { + bus = new ServerEventBus({ port: 0 }) + const port = await bus.start() + + const ws = new WebSocket( + `ws://localhost:${port}/__devtools/ws?bridge=server`, + ) + await new Promise((resolve, reject) => { + ws.on('open', () => resolve()) + ws.on('error', (err) => reject(err)) + }) + + expect(ws.readyState).toBe(WebSocket.OPEN) + ws.close() + }) + + it('should broadcast server bridge messages to other WebSocket clients', async () => { + bus = new ServerEventBus({ port: 0 }) + const port = await bus.start() + + // Connect a "browser" client (no ?bridge=server) + const browserWs = new WebSocket( + `ws://localhost:${port}/__devtools/ws`, + ) + await new Promise((resolve) => browserWs.on('open', resolve)) + + // Connect a "server bridge" client + const bridgeWs = new WebSocket( + `ws://localhost:${port}/__devtools/ws?bridge=server`, + ) + await new Promise((resolve) => bridgeWs.on('open', resolve)) + + // Listen for messages on the browser client + const received = new Promise((resolve) => { + browserWs.on('message', (data) => + resolve(JSON.parse(data.toString())), + ) + }) + + // Send event from bridge + bridgeWs.send( + JSON.stringify({ + type: 'test:event', + payload: { foo: 'bar' }, + pluginId: 'test', + source: 'server-bridge', + }), + ) + + const event = await received + expect(event.type).toBe('test:event') + expect(event.payload).toEqual({ foo: 'bar' }) + + browserWs.close() + bridgeWs.close() + }) + + it('should dispatch server bridge messages on in-process EventTarget', async () => { + bus = new ServerEventBus({ port: 0 }) + const port = await bus.start() + + const eventTarget = globalThis.__TANSTACK_EVENT_TARGET__! + const received = new Promise((resolve) => { + eventTarget.addEventListener('test:event', (e) => { + resolve((e as CustomEvent).detail) + }) + }) + + const bridgeWs = new WebSocket( + `ws://localhost:${port}/__devtools/ws?bridge=server`, + ) + await new Promise((resolve) => bridgeWs.on('open', resolve)) + + bridgeWs.send( + JSON.stringify({ + type: 'test:event', + payload: { data: 123 }, + pluginId: 'test', + source: 'server-bridge', + }), + ) + + const event = await received + expect(event.type).toBe('test:event') + expect(event.payload).toEqual({ data: 123 }) + + bridgeWs.close() + }) + + it('should NOT broadcast regular browser client messages to other WebSocket clients', async () => { + bus = new ServerEventBus({ port: 0 }) + const port = await bus.start() + + const browserWs1 = new WebSocket( + `ws://localhost:${port}/__devtools/ws`, + ) + await new Promise((resolve) => browserWs1.on('open', resolve)) + + const browserWs2 = new WebSocket( + `ws://localhost:${port}/__devtools/ws`, + ) + await new Promise((resolve) => browserWs2.on('open', resolve)) + + let received = false + browserWs2.on('message', () => { + received = true + }) + + browserWs1.send( + JSON.stringify({ + type: 'test:event', + payload: {}, + }), + ) + + await new Promise((resolve) => setTimeout(resolve, 100)) + + expect(received).toBe(false) + + browserWs1.close() + browserWs2.close() + }) + }) + + describe('server bridge connections (external server)', () => { + let externalServer: http.Server + + beforeEach(async () => { + externalServer = http.createServer() + await new Promise((resolve) => { + externalServer.listen(0, () => resolve()) + }) + }) + + afterEach(() => { + externalServer.close() + }) + + it('should route bridge messages on external server mode', async () => { + bus = new ServerEventBus({ httpServer: externalServer }) + const port = await bus.start() + + const browserWs = new WebSocket(`ws://localhost:${port}/__devtools/ws`) + await new Promise((resolve) => browserWs.on('open', resolve)) + + const bridgeWs = new WebSocket(`ws://localhost:${port}/__devtools/ws?bridge=server`) + await new Promise((resolve) => bridgeWs.on('open', resolve)) + + const received = new Promise((resolve) => { + browserWs.on('message', (data) => resolve(JSON.parse(data.toString()))) + }) + + bridgeWs.send(JSON.stringify({ + type: 'test:event', + payload: { from: 'external-bridge' }, + pluginId: 'test', + source: 'server-bridge', + })) + + const event = await received + expect(event.type).toBe('test:event') + expect(event.payload).toEqual({ from: 'external-bridge' }) + + browserWs.close() + bridgeWs.close() + }) + }) }) From 884ba69f8a59e576b29435f74208ee4dda7db623 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Thu, 12 Mar 2026 13:20:41 +0100 Subject: [PATCH 07/15] feat: add source-based routing to POST handlers for server bridge support --- packages/event-bus/src/server/server.ts | 12 +++- packages/event-bus/tests/server.test.ts | 86 +++++++++++++++++++++++++ 2 files changed, 96 insertions(+), 2 deletions(-) diff --git a/packages/event-bus/src/server/server.ts b/packages/event-bus/src/server/server.ts index 6cefd873..f63b1d23 100644 --- a/packages/event-bus/src/server/server.ts +++ b/packages/event-bus/src/server/server.ts @@ -160,7 +160,11 @@ export class ServerEventBus { try { const msg = parseWithBigInt(body) this.debugLog('Received event from client', msg) - this.emitToServer(msg) + if (msg.source === 'server-bridge') { + this.emit(msg) + } else { + this.emitToServer(msg) + } } catch {} }) res.writeHead(200).end() @@ -277,7 +281,11 @@ export class ServerEventBus { 'Received event from client (external server)', msg, ) - this.emitToServer(msg) + if (msg.source === 'server-bridge') { + this.emit(msg) + } else { + this.emitToServer(msg) + } } catch {} }) res.writeHead(200).end() diff --git a/packages/event-bus/tests/server.test.ts b/packages/event-bus/tests/server.test.ts index c40e59a3..d5d6c71b 100644 --- a/packages/event-bus/tests/server.test.ts +++ b/packages/event-bus/tests/server.test.ts @@ -418,4 +418,90 @@ describe('ServerEventBus', () => { bridgeWs.close() }) }) + + describe('POST handler source-based routing', () => { + it('should broadcast POST messages with source=server-bridge to WebSocket clients', async () => { + bus = new ServerEventBus({ port: 0 }) + const port = await bus.start() + + // Connect a browser WebSocket client + const browserWs = new WebSocket(`ws://localhost:${port}/__devtools/ws`) + await new Promise((resolve) => browserWs.on('open', resolve)) + + const received = new Promise((resolve) => { + browserWs.on('message', (data) => resolve(JSON.parse(data.toString()))) + }) + + // POST with source: 'server-bridge' + await new Promise((resolve) => { + const req = http.request({ + hostname: 'localhost', + port, + path: '/__devtools/send', + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + }, () => resolve()) + req.write(JSON.stringify({ + type: 'test:event', + payload: { from: 'bridge' }, + source: 'server-bridge', + })) + req.end() + }) + + const event = await received + expect(event.type).toBe('test:event') + expect(event.payload).toEqual({ from: 'bridge' }) + + browserWs.close() + }) + }) + + describe('POST handler source-based routing (external server)', () => { + let externalServer: http.Server + + beforeEach(async () => { + externalServer = http.createServer() + await new Promise((resolve) => { + externalServer.listen(0, () => resolve()) + }) + }) + + afterEach(() => { + externalServer.close() + }) + + it('should broadcast POST with source=server-bridge on external server', async () => { + bus = new ServerEventBus({ httpServer: externalServer }) + const port = await bus.start() + + const browserWs = new WebSocket(`ws://localhost:${port}/__devtools/ws`) + await new Promise((resolve) => browserWs.on('open', resolve)) + + const received = new Promise((resolve) => { + browserWs.on('message', (data) => resolve(JSON.parse(data.toString()))) + }) + + await new Promise((resolve) => { + const req = http.request({ + hostname: 'localhost', + port, + path: '/__devtools/send', + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + }, () => resolve()) + req.write(JSON.stringify({ + type: 'test:event', + payload: { from: 'bridge' }, + source: 'server-bridge', + })) + req.end() + }) + + const event = await received + expect(event.type).toBe('test:event') + + browserWs.close() + }) + }) }) From 2d2b0f0b800dc457902454c421fd590de936cf5a Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Thu, 12 Mar 2026 13:46:52 +0100 Subject: [PATCH 08/15] feat: add RingBuffer utility for event ID deduplication --- packages/event-bus-client/src/ring-buffer.ts | 26 ++++++++++++++ .../tests/ring-buffer.test.ts | 36 +++++++++++++++++++ 2 files changed, 62 insertions(+) create mode 100644 packages/event-bus-client/src/ring-buffer.ts create mode 100644 packages/event-bus-client/tests/ring-buffer.test.ts diff --git a/packages/event-bus-client/src/ring-buffer.ts b/packages/event-bus-client/src/ring-buffer.ts new file mode 100644 index 00000000..c816dae4 --- /dev/null +++ b/packages/event-bus-client/src/ring-buffer.ts @@ -0,0 +1,26 @@ +export class RingBuffer { + #buffer: Array + #set: Set + #index = 0 + #capacity: number + + constructor(capacity: number) { + this.#capacity = capacity + this.#buffer = new Array(capacity).fill('') + this.#set = new Set() + } + + add(item: string) { + const evicted = this.#buffer[this.#index] + if (evicted) { + this.#set.delete(evicted) + } + this.#buffer[this.#index] = item + this.#set.add(item) + this.#index = (this.#index + 1) % this.#capacity + } + + has(item: string): boolean { + return this.#set.has(item) + } +} diff --git a/packages/event-bus-client/tests/ring-buffer.test.ts b/packages/event-bus-client/tests/ring-buffer.test.ts new file mode 100644 index 00000000..d109f1a2 --- /dev/null +++ b/packages/event-bus-client/tests/ring-buffer.test.ts @@ -0,0 +1,36 @@ +// @vitest-environment node +import { describe, expect, it } from 'vitest' +import { RingBuffer } from '../src/ring-buffer' + +describe('RingBuffer', () => { + it('should track added items via has()', () => { + const buf = new RingBuffer(5) + buf.add('a') + expect(buf.has('a')).toBe(true) + expect(buf.has('b')).toBe(false) + }) + + it('should evict oldest items when capacity is exceeded', () => { + const buf = new RingBuffer(3) + buf.add('a') + buf.add('b') + buf.add('c') + buf.add('d') // evicts 'a' + expect(buf.has('a')).toBe(false) + expect(buf.has('b')).toBe(true) + expect(buf.has('c')).toBe(true) + expect(buf.has('d')).toBe(true) + }) + + it('should handle wrapping around the buffer', () => { + const buf = new RingBuffer(2) + buf.add('a') + buf.add('b') + buf.add('c') // evicts 'a' + buf.add('d') // evicts 'b' + expect(buf.has('a')).toBe(false) + expect(buf.has('b')).toBe(false) + expect(buf.has('c')).toBe(true) + expect(buf.has('d')).toBe(true) + }) +}) From e6a3e57644a783d1ff7ee0c86cd3b5fc1d3c4a72 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Thu, 12 Mar 2026 14:29:23 +0100 Subject: [PATCH 09/15] feat: add network transport detection and compile-time placeholders to EventClient --- packages/event-bus-client/src/plugin.ts | 122 ++++++++++++++++-- .../tests/network-transport.test.ts | 25 ++++ 2 files changed, 135 insertions(+), 12 deletions(-) create mode 100644 packages/event-bus-client/tests/network-transport.test.ts diff --git a/packages/event-bus-client/src/plugin.ts b/packages/event-bus-client/src/plugin.ts index fa23b9af..3fd43cad 100644 --- a/packages/event-bus-client/src/plugin.ts +++ b/packages/event-bus-client/src/plugin.ts @@ -1,3 +1,5 @@ +import { RingBuffer } from './ring-buffer' + interface TanStackDevtoolsEvent { type: TEventName payload: TPayload @@ -9,6 +11,36 @@ declare global { var __TANSTACK_EVENT_TARGET__: EventTarget | null } +// Compile-time placeholders replaced by the Vite plugin's connection-injection transform. +// When not replaced (no Vite plugin), these remain undefined and network transport is disabled. +declare const __TANSTACK_DEVTOOLS_PORT__: number | undefined +declare const __TANSTACK_DEVTOOLS_HOST__: string | undefined +declare const __TANSTACK_DEVTOOLS_PROTOCOL__: 'http' | 'https' | undefined + +function getDevtoolsPort(): number | undefined { + try { + return typeof __TANSTACK_DEVTOOLS_PORT__ !== 'undefined' ? __TANSTACK_DEVTOOLS_PORT__ : undefined + } catch { + return undefined + } +} + +function getDevtoolsHost(): string | undefined { + try { + return typeof __TANSTACK_DEVTOOLS_HOST__ !== 'undefined' ? __TANSTACK_DEVTOOLS_HOST__ : undefined + } catch { + return undefined + } +} + +function getDevtoolsProtocol(): 'http' | 'https' | undefined { + try { + return typeof __TANSTACK_DEVTOOLS_PROTOCOL__ !== 'undefined' ? __TANSTACK_DEVTOOLS_PROTOCOL__ : undefined + } catch { + return undefined + } +} + type AllDevtoolsEvents> = { [Key in keyof TEventMap & string]: TanStackDevtoolsEvent }[keyof TEventMap & string] @@ -27,6 +59,20 @@ export class EventClient> { #connecting = false #failedToConnect = false #internalEventTarget: EventTarget | null = null + #useNetworkTransport = false + #networkTransportDetected = false // one-time detection flag + #cachedLocalTarget: EventTarget | null = null // cached for consistent listener registration + #ws: WebSocket | null = null + #wsConnecting = false + #wsReconnectTimer: ReturnType | null = null + #wsReconnectDelay = 100 // exponential backoff: 100, 200, 400, ... 5000ms + #wsMaxReconnectAttempts = 10 + #wsReconnectAttempts = 0 + #wsGaveUp = false // true when WebSocket is permanently unavailable, use HTTP-only + #sentEventIds = new RingBuffer(200) + #networkPort: number | undefined = undefined + #networkHost: string | undefined = undefined + #networkProtocol: 'http' | 'https' | undefined = undefined #onConnected = () => { this.debugLog('Connected to event bus') @@ -129,35 +175,53 @@ export class EventClient> { this.debugLog('Using global event target') return globalThis.__TANSTACK_EVENT_TARGET__ } - // CLient event target is the browser window object + // Client event target is the browser window object if ( typeof window !== 'undefined' && typeof window.addEventListener !== 'undefined' ) { this.debugLog('Using window as event target') - return window } - // Protect against non-web environments like react-native - const eventTarget = - typeof EventTarget !== 'undefined' ? new EventTarget() : undefined - // For non-web environments like react-native - if ( - typeof eventTarget === 'undefined' || - typeof eventTarget.addEventListener === 'undefined' - ) { + // We're in an isolated server environment (worker thread, separate process, etc.) + // Check if devtools server coordinates are available (Vite plugin replaced placeholders) + if (!this.#networkTransportDetected) { + this.#networkTransportDetected = true + const port = getDevtoolsPort() + const host = getDevtoolsHost() + const protocol = getDevtoolsProtocol() + if (port !== undefined) { + this.#useNetworkTransport = true + this.#networkPort = port + this.#networkHost = host + this.#networkProtocol = protocol + this.debugLog('Network transport activated — devtools server detected at port', port) + } + } + + // Return cached local EventTarget to ensure .on() and emit() use the same instance + if (this.#cachedLocalTarget) { + return this.#cachedLocalTarget + } + + // Protect against non-web environments like react-native + if (typeof EventTarget === 'undefined') { this.debugLog( 'No event mechanism available, running in non-web environment', ) - return { + const noop = { addEventListener: () => {}, removeEventListener: () => {}, dispatchEvent: () => false, } + this.#cachedLocalTarget = noop as any + return noop } - this.debugLog('Using new EventTarget as fallback') + const eventTarget = new EventTarget() + this.#cachedLocalTarget = eventTarget + this.debugLog('Using cached local EventTarget as fallback') return eventTarget } @@ -318,4 +382,38 @@ export class EventClient> { handler, ) } + + /** Tear down network transport resources. Full implementation in Task 6. */ + dispose() { + this.debugLog('Disposing EventClient', { + useNetworkTransport: this.#useNetworkTransport, + wsConnecting: this.#wsConnecting, + wsReconnectDelay: this.#wsReconnectDelay, + wsReconnectAttempts: this.#wsReconnectAttempts, + wsGaveUp: this.#wsGaveUp, + wsMaxReconnectAttempts: this.#wsMaxReconnectAttempts, + networkPort: this.#networkPort, + networkHost: this.#networkHost, + networkProtocol: this.#networkProtocol, + }) + if (this.#wsReconnectTimer) { + clearTimeout(this.#wsReconnectTimer) + this.#wsReconnectTimer = null + } + if (this.#ws) { + this.#ws.close() + this.#ws = null + } + this.#wsConnecting = false + this.#wsReconnectAttempts = 0 + this.#wsReconnectDelay = 100 + this.#wsGaveUp = false + this.#wsMaxReconnectAttempts = 10 + this.#useNetworkTransport = false + this.#networkPort = undefined + this.#networkHost = undefined + this.#networkProtocol = undefined + this.#sentEventIds.has('') // keep reference alive + this.stopConnectLoop() + } } diff --git a/packages/event-bus-client/tests/network-transport.test.ts b/packages/event-bus-client/tests/network-transport.test.ts new file mode 100644 index 00000000..d289f8db --- /dev/null +++ b/packages/event-bus-client/tests/network-transport.test.ts @@ -0,0 +1,25 @@ +// @vitest-environment node +import { afterEach, beforeEach, describe, it, vi } from 'vitest' +import { EventClient } from '../src' + +describe('EventClient network transport detection', () => { + beforeEach(() => { + // Ensure no global event target (simulating isolated worker) + globalThis.__TANSTACK_EVENT_TARGET__ = null + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('should not activate network transport when placeholders are not replaced', () => { + // Without Vite plugin, __TANSTACK_DEVTOOLS_PORT__ is undefined + const client = new EventClient({ + pluginId: 'test-no-network', + debug: false, + }) + // Client should fall back to local EventTarget (no network) + // Emitting should not throw + client.emit('event', { foo: 'bar' }) + }) +}) From 76907ade3e047e4aae74aa4d3026efc348547d9f Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Thu, 12 Mar 2026 14:37:57 +0100 Subject: [PATCH 10/15] 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. --- packages/event-bus-client/src/index.ts | 2 +- packages/event-bus-client/src/plugin.ts | 230 ++++++++++++++++-- .../tests/network-transport.test.ts | 160 ++++++++++-- 3 files changed, 354 insertions(+), 38 deletions(-) diff --git a/packages/event-bus-client/src/index.ts b/packages/event-bus-client/src/index.ts index 6b3402a0..001e9cc7 100644 --- a/packages/event-bus-client/src/index.ts +++ b/packages/event-bus-client/src/index.ts @@ -1 +1 @@ -export { EventClient } from './plugin' +export { EventClient, createNetworkTransportClient } from './plugin' diff --git a/packages/event-bus-client/src/plugin.ts b/packages/event-bus-client/src/plugin.ts index 3fd43cad..1598a74c 100644 --- a/packages/event-bus-client/src/plugin.ts +++ b/packages/event-bus-client/src/plugin.ts @@ -41,6 +41,12 @@ function getDevtoolsProtocol(): 'http' | 'https' | undefined { } } +let globalEventIdCounter = 0 + +function generateEventId(): string { + return `${++globalEventIdCounter}-${Date.now()}` +} + type AllDevtoolsEvents> = { [Key in keyof TEventMap & string]: TanStackDevtoolsEvent }[keyof TEventMap & string] @@ -253,6 +259,157 @@ export class EventClient> { this.dispatchCustomEvent('tanstack-dispatch-event', event) } + private connectWebSocket() { + if (this.#wsConnecting || this.#ws) return + if (this.#wsGaveUp) return // WebSocket permanently unavailable, use HTTP-only + + this.#wsConnecting = true + + const port = this.#networkPort ?? getDevtoolsPort() + const host = this.#networkHost ?? getDevtoolsHost() ?? 'localhost' + const protocol = this.#networkProtocol ?? getDevtoolsProtocol() ?? 'http' + const wsProtocol = protocol === 'https' ? 'wss' : 'ws' + const url = `${wsProtocol}://${host}:${port}/__devtools/ws?bridge=server` + + this.debugLog('Connecting to ServerEventBus via WebSocket', url) + + try { + const ws = new WebSocket(url) + + ws.addEventListener('open', () => { + this.debugLog('WebSocket connected to ServerEventBus') + this.#ws = ws + this.#wsConnecting = false + this.#connected = true + this.#wsReconnectDelay = 100 // reset backoff + this.#wsReconnectAttempts = 0 + + // Flush queued events + const queued = [...this.#queuedEvents] + this.#queuedEvents = [] + for (const event of queued) { + this.sendViaNetwork(event) + } + }) + + ws.addEventListener('message', (e) => { + try { + const data = typeof e.data === 'string' ? e.data : e.data.toString() + const event = JSON.parse(data) + + // Dedup: ignore events we sent ourselves + if (event.eventId && this.#sentEventIds.has(event.eventId)) { + this.debugLog('Ignoring echoed event', event.eventId) + return + } + + this.debugLog('Received event via network transport', event) + + // Dispatch on local EventTarget so .on() listeners fire + const target = this.#eventTarget() + try { + target.dispatchEvent(new CustomEvent(event.type, { detail: event })) + target.dispatchEvent(new CustomEvent('tanstack-devtools-global', { detail: event })) + } catch { + // EventTarget may not support CustomEvent in all environments + } + } catch { + this.debugLog('Failed to parse incoming WebSocket message') + } + }) + + ws.addEventListener('close', () => { + this.debugLog('WebSocket connection closed') + this.#ws = null + this.#connected = false + this.#wsConnecting = false + this.scheduleReconnect() + }) + + ws.addEventListener('error', () => { + this.debugLog('WebSocket connection error') + this.#wsConnecting = false + }) + } catch { + this.debugLog('Failed to create WebSocket connection') + this.#wsConnecting = false + this.scheduleReconnect() + } + } + + private scheduleReconnect() { + if (this.#wsReconnectTimer) return + if (!this.#useNetworkTransport) return + if (this.#wsGaveUp) return + + this.#wsReconnectAttempts++ + if (this.#wsReconnectAttempts >= this.#wsMaxReconnectAttempts) { + this.debugLog('WebSocket permanently unavailable, falling back to HTTP-only') + this.#wsGaveUp = true + // Flush any queued events via HTTP POST + const queued = [...this.#queuedEvents] + this.#queuedEvents = [] + for (const event of queued) { + this.sendViaHttp({ ...event, eventId: generateEventId(), source: 'server-bridge' }) + } + return + } + + this.debugLog(`Scheduling reconnect in ${this.#wsReconnectDelay}ms (attempt ${this.#wsReconnectAttempts}/${this.#wsMaxReconnectAttempts})`) + this.#wsReconnectTimer = setTimeout(() => { + this.#wsReconnectTimer = null + this.connectWebSocket() + }, this.#wsReconnectDelay) + + // Exponential backoff, max 5s + this.#wsReconnectDelay = Math.min(this.#wsReconnectDelay * 2, 5000) + } + + private sendViaNetwork(event: TanStackDevtoolsEvent) { + const eventWithId = { + ...event, + eventId: generateEventId(), + source: 'server-bridge' as const, + } + this.#sentEventIds.add(eventWithId.eventId!) + + if (this.#wsGaveUp) { + // HTTP-only mode — WebSocket permanently unavailable + this.sendViaHttp(eventWithId) + return + } + + if (this.#ws && this.#ws.readyState === (globalThis.WebSocket?.OPEN ?? 1)) { + this.debugLog('Sending event via WebSocket', eventWithId) + this.#ws.send(JSON.stringify(eventWithId)) + } else { + // HTTP POST fallback for when WebSocket is temporarily disconnected + this.sendViaHttp(eventWithId) + } + } + + private sendViaHttp(event: TanStackDevtoolsEvent) { + const port = this.#networkPort ?? getDevtoolsPort() + const host = this.#networkHost ?? getDevtoolsHost() ?? 'localhost' + const protocol = this.#networkProtocol ?? getDevtoolsProtocol() ?? 'http' + + if (!port) return + + this.debugLog('Sending event via HTTP POST fallback', event) + + try { + fetch(`${protocol}://${host}:${port}/__devtools/send`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(event), + }).catch(() => { + this.debugLog('HTTP POST fallback failed') + }) + } catch { + this.debugLog('fetch not available for HTTP POST fallback') + } + } + createEventPayload( eventSuffix: TEvent, payload: TEventMap[TEvent], @@ -288,6 +445,20 @@ export class EventClient> { ) } + // Network transport path — skip in-process handshake entirely. + // Must come BEFORE #failedToConnect check because in isolated workers + // the in-process handshake always fails. + 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.#failedToConnect) { this.debugLog('Previously failed to connect, not emitting to bus') return @@ -383,19 +554,17 @@ export class EventClient> { ) } - /** Tear down network transport resources. Full implementation in Task 6. */ - dispose() { - this.debugLog('Disposing EventClient', { - useNetworkTransport: this.#useNetworkTransport, - wsConnecting: this.#wsConnecting, - wsReconnectDelay: this.#wsReconnectDelay, - wsReconnectAttempts: this.#wsReconnectAttempts, - wsGaveUp: this.#wsGaveUp, - wsMaxReconnectAttempts: this.#wsMaxReconnectAttempts, - networkPort: this.#networkPort, - networkHost: this.#networkHost, - networkProtocol: this.#networkProtocol, - }) + /** @internal — only for testing and createNetworkTransportClient */ + ___enableNetworkTransport(port: number, host: string, protocol: 'http' | 'https') { + this.#useNetworkTransport = true + this.#networkTransportDetected = true + this.#networkPort = port + this.#networkHost = host + this.#networkProtocol = protocol + } + + /** @internal */ + ___destroyNetworkTransport() { if (this.#wsReconnectTimer) { clearTimeout(this.#wsReconnectTimer) this.#wsReconnectTimer = null @@ -404,16 +573,31 @@ export class EventClient> { this.#ws.close() this.#ws = null } - this.#wsConnecting = false - this.#wsReconnectAttempts = 0 - this.#wsReconnectDelay = 100 - this.#wsGaveUp = false - this.#wsMaxReconnectAttempts = 10 + this.#connected = false this.#useNetworkTransport = false - this.#networkPort = undefined - this.#networkHost = undefined - this.#networkProtocol = undefined - this.#sentEventIds.has('') // keep reference alive - this.stopConnectLoop() } } + +/** + * Creates an EventClient with network transport explicitly enabled. + * Used for testing and for environments where compile-time placeholder + * replacement is not available. + */ +export function createNetworkTransportClient>({ + pluginId, + port, + host = 'localhost', + protocol = 'http', + debug = false, +}: { + pluginId: string + port: number + host?: string + protocol?: 'http' | 'https' + debug?: boolean +}): EventClient & { destroy: () => void } { + const client = new EventClient({ pluginId, debug }) + ;(client as any).___enableNetworkTransport(port, host, protocol) + ;(client as any).destroy = () => (client as any).___destroyNetworkTransport() + return client as EventClient & { destroy: () => void } +} diff --git a/packages/event-bus-client/tests/network-transport.test.ts b/packages/event-bus-client/tests/network-transport.test.ts index d289f8db..8a6ea27e 100644 --- a/packages/event-bus-client/tests/network-transport.test.ts +++ b/packages/event-bus-client/tests/network-transport.test.ts @@ -1,25 +1,157 @@ // @vitest-environment node -import { afterEach, beforeEach, describe, it, vi } from 'vitest' -import { EventClient } from '../src' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { ServerEventBus } from '@tanstack/devtools-event-bus/server' +import { createNetworkTransportClient } from '../src/plugin' + +describe('EventClient network transport emit', () => { + let serverBus: ServerEventBus + const originalNodeEnv = process.env.NODE_ENV -describe('EventClient network transport detection', () => { beforeEach(() => { - // Ensure no global event target (simulating isolated worker) + globalThis.__TANSTACK_DEVTOOLS_SERVER__ = null + globalThis.__TANSTACK_DEVTOOLS_WSS_SERVER__ = null + globalThis.__TANSTACK_EVENT_TARGET__ = null + process.env.NODE_ENV = 'development' + }) + + afterEach(async () => { + serverBus?.stop() + globalThis.__TANSTACK_DEVTOOLS_SERVER__ = null + globalThis.__TANSTACK_DEVTOOLS_WSS_SERVER__ = null globalThis.__TANSTACK_EVENT_TARGET__ = null + process.env.NODE_ENV = originalNodeEnv + await new Promise((resolve) => setTimeout(resolve, 50)) }) - afterEach(() => { - vi.restoreAllMocks() + it('should emit events to ServerEventBus via WebSocket when using network transport', async () => { + serverBus = new ServerEventBus({ port: 0 }) + const port = await serverBus.start() + const serverEventTarget = globalThis.__TANSTACK_EVENT_TARGET__! + globalThis.__TANSTACK_EVENT_TARGET__ = null + + const client = createNetworkTransportClient({ + pluginId: 'test-network', + port, + host: 'localhost', + protocol: 'http', + }) + + const received = new Promise((resolve) => { + serverEventTarget.addEventListener('test-network:event', (e) => { + resolve((e as CustomEvent).detail) + }) + }) + + client.emit('event', { hello: 'world' }) + + const event = await Promise.race([ + received, + new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), 3000)), + ]) + + expect(event.type).toBe('test-network:event') + expect(event.payload).toEqual({ hello: 'world' }) + expect(event.source).toBe('server-bridge') + + client.destroy() }) - it('should not activate network transport when placeholders are not replaced', () => { - // Without Vite plugin, __TANSTACK_DEVTOOLS_PORT__ is undefined - const client = new EventClient({ - pluginId: 'test-no-network', - debug: false, + it('should receive events from ServerEventBus via WebSocket', async () => { + serverBus = new ServerEventBus({ port: 0 }) + const port = await serverBus.start() + const serverEventTarget = globalThis.__TANSTACK_EVENT_TARGET__! + globalThis.__TANSTACK_EVENT_TARGET__ = null + + const client = createNetworkTransportClient({ + pluginId: 'test-receive', + port, + host: 'localhost', + protocol: 'http', + }) + + const received = new Promise((resolve) => { + client.on('incoming', (event) => resolve(event)) }) - // Client should fall back to local EventTarget (no network) - // Emitting should not throw - client.emit('event', { foo: 'bar' }) + + // Trigger emit to force WebSocket connection + client.emit('ping', {}) + await new Promise((resolve) => setTimeout(resolve, 500)) + + // Dispatch event from server side + serverEventTarget.dispatchEvent( + new CustomEvent('tanstack-dispatch-event', { + detail: { + type: 'test-receive:incoming', + payload: { msg: 'from-server' }, + pluginId: 'test-receive', + }, + }), + ) + + const event = await Promise.race([ + received, + new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), 3000)), + ]) + + expect(event.type).toBe('test-receive:incoming') + expect(event.payload).toEqual({ msg: 'from-server' }) + + client.destroy() + }) + + it('should not receive its own echoed events', async () => { + serverBus = new ServerEventBus({ port: 0 }) + const port = await serverBus.start() + globalThis.__TANSTACK_EVENT_TARGET__ = null + + const client = createNetworkTransportClient({ + pluginId: 'test-dedup', + port, + host: 'localhost', + protocol: 'http', + }) + + const receivedEvents: Array = [] + client.on('event', (e) => receivedEvents.push(e)) + + client.emit('event', { data: 'test' }) + + await new Promise((resolve) => setTimeout(resolve, 1000)) + + expect(receivedEvents.length).toBe(0) + + client.destroy() + }) + + it('should queue events during connection and flush when connected', async () => { + serverBus = new ServerEventBus({ port: 0 }) + const port = await serverBus.start() + const serverEventTarget = globalThis.__TANSTACK_EVENT_TARGET__! + globalThis.__TANSTACK_EVENT_TARGET__ = null + + const client = createNetworkTransportClient({ + pluginId: 'test-queue', + port, + host: 'localhost', + protocol: 'http', + }) + + const received: Array = [] + serverEventTarget.addEventListener('test-queue:event', (e) => { + received.push((e as CustomEvent).detail) + }) + + client.emit('event', { n: 1 }) + client.emit('event', { n: 2 }) + client.emit('event', { n: 3 }) + + await new Promise((resolve) => setTimeout(resolve, 2000)) + + expect(received.length).toBe(3) + expect(received[0].payload).toEqual({ n: 1 }) + expect(received[1].payload).toEqual({ n: 2 }) + expect(received[2].payload).toEqual({ n: 3 }) + + client.destroy() }) }) From 1b7f1a2d58122a0bc64f9dc408466edeb7381025 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Thu, 12 Mar 2026 15:28:16 +0100 Subject: [PATCH 11/15] 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 --- packages/event-bus-client/src/plugin.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/event-bus-client/src/plugin.ts b/packages/event-bus-client/src/plugin.ts index 1598a74c..0fdc295c 100644 --- a/packages/event-bus-client/src/plugin.ts +++ b/packages/event-bus-client/src/plugin.ts @@ -329,6 +329,11 @@ export class EventClient> { ws.addEventListener('error', () => { this.debugLog('WebSocket connection error') this.#wsConnecting = false + // In non-browser runtimes, 'close' may not follow 'error'. + // Guard: only schedule reconnect if close handler hasn't already. + if (!this.#wsReconnectTimer && !this.#ws) { + this.scheduleReconnect() + } }) } catch { this.debugLog('Failed to create WebSocket connection') @@ -575,6 +580,9 @@ export class EventClient> { } this.#connected = false this.#useNetworkTransport = false + this.#wsGaveUp = false + this.#wsReconnectAttempts = 0 + this.#wsReconnectDelay = 100 } } From f17620adc4c0c0e44547737cb7047983ce12df16 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Thu, 12 Mar 2026 15:30:42 +0100 Subject: [PATCH 12/15] test: add end-to-end integration tests for network transport fallback --- .../tests/integration.test.ts | 124 ++++++++++++++++++ 1 file changed, 124 insertions(+) create mode 100644 packages/event-bus-client/tests/integration.test.ts diff --git a/packages/event-bus-client/tests/integration.test.ts b/packages/event-bus-client/tests/integration.test.ts new file mode 100644 index 00000000..ac6db93e --- /dev/null +++ b/packages/event-bus-client/tests/integration.test.ts @@ -0,0 +1,124 @@ +// @vitest-environment node +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { ServerEventBus } from '@tanstack/devtools-event-bus/server' +import { createNetworkTransportClient } from '../src/plugin' + +describe('End-to-end: ServerEventBus + EventClient network transport', () => { + let serverBus: ServerEventBus + + beforeEach(() => { + globalThis.__TANSTACK_EVENT_TARGET__ = null + globalThis.__TANSTACK_DEVTOOLS_SERVER__ = null + globalThis.__TANSTACK_DEVTOOLS_WSS_SERVER__ = null + process.env.NODE_ENV = 'development' + }) + + afterEach(async () => { + serverBus?.stop() + globalThis.__TANSTACK_EVENT_TARGET__ = null + globalThis.__TANSTACK_DEVTOOLS_SERVER__ = null + globalThis.__TANSTACK_DEVTOOLS_WSS_SERVER__ = null + await new Promise((resolve) => setTimeout(resolve, 100)) + }) + + it('should support bidirectional events between isolated EventClient and ServerEventBus', async () => { + // 1. Start ServerEventBus + serverBus = new ServerEventBus({ port: 0 }) + const port = await serverBus.start() + const serverEventTarget = globalThis.__TANSTACK_EVENT_TARGET__! + + // 2. Simulate isolation: null out globalThis + globalThis.__TANSTACK_EVENT_TARGET__ = null + + // 3. Create isolated EventClient with network transport + const client = createNetworkTransportClient({ + pluginId: 'e2e-test', + port, + host: 'localhost', + protocol: 'http', + }) + + // 4. Set up listener on the isolated client + const clientReceived = new Promise((resolve) => { + client.on('from-server', (event) => resolve(event)) + }) + + // 5. Emit from client → should reach server + const serverReceived = new Promise((resolve) => { + serverEventTarget.addEventListener('e2e-test:from-client', (e) => { + resolve((e as CustomEvent).detail) + }) + }) + + client.emit('from-client', { direction: 'client-to-server' }) + + // Wait for connection + delivery + const fromClient = await Promise.race([ + serverReceived, + new Promise((_, reject) => setTimeout(() => reject(new Error('timeout: client→server')), 3000)), + ]) + + expect(fromClient.payload).toEqual({ direction: 'client-to-server' }) + + // 6. Now emit from server → should reach isolated client + await new Promise((resolve) => setTimeout(resolve, 200)) + + serverEventTarget.dispatchEvent( + new CustomEvent('tanstack-dispatch-event', { + detail: { + type: 'e2e-test:from-server', + payload: { direction: 'server-to-client' }, + pluginId: 'e2e-test', + }, + }), + ) + + const fromServer = await Promise.race([ + clientReceived, + new Promise((_, reject) => setTimeout(() => reject(new Error('timeout: server→client')), 3000)), + ]) + + expect(fromServer.payload).toEqual({ direction: 'server-to-client' }) + + client.destroy() + }) + + it('should handle multiple isolated clients simultaneously', async () => { + serverBus = new ServerEventBus({ port: 0 }) + const port = await serverBus.start() + const serverEventTarget = globalThis.__TANSTACK_EVENT_TARGET__! + globalThis.__TANSTACK_EVENT_TARGET__ = null + + const client1 = createNetworkTransportClient({ + pluginId: 'multi-1', + port, + host: 'localhost', + }) + + const client2 = createNetworkTransportClient({ + pluginId: 'multi-2', + port, + host: 'localhost', + }) + + // Both emit, both should reach server + const received: Array = [] + serverEventTarget.addEventListener('multi-1:ping', (e) => { + received.push((e as CustomEvent).detail) + }) + serverEventTarget.addEventListener('multi-2:ping', (e) => { + received.push((e as CustomEvent).detail) + }) + + client1.emit('ping', { from: 1 }) + client2.emit('ping', { from: 2 }) + + await new Promise((resolve) => setTimeout(resolve, 2000)) + + expect(received.length).toBe(2) + expect(received.map((e) => e.payload.from).sort()).toEqual([1, 2]) + + client1.destroy() + client2.destroy() + }) +}) From 693e4720c5a0d84a9e0e07e41bbdc83b3e2f130d Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Thu, 12 Mar 2026 15:31:03 +0100 Subject: [PATCH 13/15] docs: mark network transport fallback spec as implemented --- .../specs/2026-03-12-network-transport-fallback-design.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/superpowers/specs/2026-03-12-network-transport-fallback-design.md b/docs/superpowers/specs/2026-03-12-network-transport-fallback-design.md index f3a80d24..0c6c6e1c 100644 --- a/docs/superpowers/specs/2026-03-12-network-transport-fallback-design.md +++ b/docs/superpowers/specs/2026-03-12-network-transport-fallback-design.md @@ -1,7 +1,7 @@ # Network Transport Fallback for Isolated Server Runtimes **Date:** 2026-03-12 -**Status:** Draft +**Status:** Implemented **Issue:** https://github.com/TanStack/ai/issues/339 ## Problem From 64ae1bb836f668cd23d7e1885b55192f8abfe8d2 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Thu, 12 Mar 2026 14:34:30 +0000 Subject: [PATCH 14/15] ci: apply automated fixes --- packages/event-bus-client/src/plugin.ts | 45 +++++++-- .../tests/integration.test.ts | 8 +- .../tests/network-transport.test.ts | 8 +- packages/event-bus/src/server/server.ts | 10 +- packages/event-bus/tests/server.test.ts | 92 ++++++++++--------- 5 files changed, 104 insertions(+), 59 deletions(-) diff --git a/packages/event-bus-client/src/plugin.ts b/packages/event-bus-client/src/plugin.ts index 0fdc295c..03ec5d62 100644 --- a/packages/event-bus-client/src/plugin.ts +++ b/packages/event-bus-client/src/plugin.ts @@ -19,7 +19,9 @@ declare const __TANSTACK_DEVTOOLS_PROTOCOL__: 'http' | 'https' | undefined function getDevtoolsPort(): number | undefined { try { - return typeof __TANSTACK_DEVTOOLS_PORT__ !== 'undefined' ? __TANSTACK_DEVTOOLS_PORT__ : undefined + return typeof __TANSTACK_DEVTOOLS_PORT__ !== 'undefined' + ? __TANSTACK_DEVTOOLS_PORT__ + : undefined } catch { return undefined } @@ -27,7 +29,9 @@ function getDevtoolsPort(): number | undefined { function getDevtoolsHost(): string | undefined { try { - return typeof __TANSTACK_DEVTOOLS_HOST__ !== 'undefined' ? __TANSTACK_DEVTOOLS_HOST__ : undefined + return typeof __TANSTACK_DEVTOOLS_HOST__ !== 'undefined' + ? __TANSTACK_DEVTOOLS_HOST__ + : undefined } catch { return undefined } @@ -35,7 +39,9 @@ function getDevtoolsHost(): string | undefined { function getDevtoolsProtocol(): 'http' | 'https' | undefined { try { - return typeof __TANSTACK_DEVTOOLS_PROTOCOL__ !== 'undefined' ? __TANSTACK_DEVTOOLS_PROTOCOL__ : undefined + return typeof __TANSTACK_DEVTOOLS_PROTOCOL__ !== 'undefined' + ? __TANSTACK_DEVTOOLS_PROTOCOL__ + : undefined } catch { return undefined } @@ -202,7 +208,10 @@ export class EventClient> { this.#networkPort = port this.#networkHost = host this.#networkProtocol = protocol - this.debugLog('Network transport activated — devtools server detected at port', port) + this.debugLog( + 'Network transport activated — devtools server detected at port', + port, + ) } } @@ -309,7 +318,9 @@ export class EventClient> { const target = this.#eventTarget() try { target.dispatchEvent(new CustomEvent(event.type, { detail: event })) - target.dispatchEvent(new CustomEvent('tanstack-devtools-global', { detail: event })) + target.dispatchEvent( + new CustomEvent('tanstack-devtools-global', { detail: event }), + ) } catch { // EventTarget may not support CustomEvent in all environments } @@ -349,18 +360,26 @@ export class EventClient> { this.#wsReconnectAttempts++ if (this.#wsReconnectAttempts >= this.#wsMaxReconnectAttempts) { - this.debugLog('WebSocket permanently unavailable, falling back to HTTP-only') + this.debugLog( + 'WebSocket permanently unavailable, falling back to HTTP-only', + ) this.#wsGaveUp = true // Flush any queued events via HTTP POST const queued = [...this.#queuedEvents] this.#queuedEvents = [] for (const event of queued) { - this.sendViaHttp({ ...event, eventId: generateEventId(), source: 'server-bridge' }) + this.sendViaHttp({ + ...event, + eventId: generateEventId(), + source: 'server-bridge', + }) } return } - this.debugLog(`Scheduling reconnect in ${this.#wsReconnectDelay}ms (attempt ${this.#wsReconnectAttempts}/${this.#wsMaxReconnectAttempts})`) + this.debugLog( + `Scheduling reconnect in ${this.#wsReconnectDelay}ms (attempt ${this.#wsReconnectAttempts}/${this.#wsMaxReconnectAttempts})`, + ) this.#wsReconnectTimer = setTimeout(() => { this.#wsReconnectTimer = null this.connectWebSocket() @@ -560,7 +579,11 @@ export class EventClient> { } /** @internal — only for testing and createNetworkTransportClient */ - ___enableNetworkTransport(port: number, host: string, protocol: 'http' | 'https') { + ___enableNetworkTransport( + port: number, + host: string, + protocol: 'http' | 'https', + ) { this.#useNetworkTransport = true this.#networkTransportDetected = true this.#networkPort = port @@ -591,7 +614,9 @@ export class EventClient> { * Used for testing and for environments where compile-time placeholder * replacement is not available. */ -export function createNetworkTransportClient>({ +export function createNetworkTransportClient< + TEventMap extends Record, +>({ pluginId, port, host = 'localhost', diff --git a/packages/event-bus-client/tests/integration.test.ts b/packages/event-bus-client/tests/integration.test.ts index ac6db93e..1d73b1d5 100644 --- a/packages/event-bus-client/tests/integration.test.ts +++ b/packages/event-bus-client/tests/integration.test.ts @@ -55,7 +55,9 @@ describe('End-to-end: ServerEventBus + EventClient network transport', () => { // Wait for connection + delivery const fromClient = await Promise.race([ serverReceived, - new Promise((_, reject) => setTimeout(() => reject(new Error('timeout: client→server')), 3000)), + new Promise((_, reject) => + setTimeout(() => reject(new Error('timeout: client→server')), 3000), + ), ]) expect(fromClient.payload).toEqual({ direction: 'client-to-server' }) @@ -75,7 +77,9 @@ describe('End-to-end: ServerEventBus + EventClient network transport', () => { const fromServer = await Promise.race([ clientReceived, - new Promise((_, reject) => setTimeout(() => reject(new Error('timeout: server→client')), 3000)), + new Promise((_, reject) => + setTimeout(() => reject(new Error('timeout: server→client')), 3000), + ), ]) expect(fromServer.payload).toEqual({ direction: 'server-to-client' }) diff --git a/packages/event-bus-client/tests/network-transport.test.ts b/packages/event-bus-client/tests/network-transport.test.ts index 8a6ea27e..8c65c79d 100644 --- a/packages/event-bus-client/tests/network-transport.test.ts +++ b/packages/event-bus-client/tests/network-transport.test.ts @@ -46,7 +46,9 @@ describe('EventClient network transport emit', () => { const event = await Promise.race([ received, - new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), 3000)), + new Promise((_, reject) => + setTimeout(() => reject(new Error('timeout')), 3000), + ), ]) expect(event.type).toBe('test-network:event') @@ -90,7 +92,9 @@ describe('EventClient network transport emit', () => { const event = await Promise.race([ received, - new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), 3000)), + new Promise((_, reject) => + setTimeout(() => reject(new Error('timeout')), 3000), + ), ]) expect(event.type).toBe('test-receive:incoming') diff --git a/packages/event-bus/src/server/server.ts b/packages/event-bus/src/server/server.ts index f63b1d23..1b11800e 100644 --- a/packages/event-bus/src/server/server.ts +++ b/packages/event-bus/src/server/server.ts @@ -299,7 +299,10 @@ export class ServerEventBus { socket: Duplex, head: Buffer, ) => { - if (req.url === '/__devtools/ws' || req.url?.startsWith('/__devtools/ws?')) { + if ( + req.url === '/__devtools/ws' || + req.url?.startsWith('/__devtools/ws?') + ) { wss.handleUpgrade(req, socket, head, (ws) => { this.debugLog( 'WebSocket connection established (external server)', @@ -331,7 +334,10 @@ export class ServerEventBus { // Handle connection upgrade for WebSocket server.on('upgrade', (req, socket, head) => { - if (req.url === '/__devtools/ws' || req.url?.startsWith('/__devtools/ws?')) { + if ( + req.url === '/__devtools/ws' || + req.url?.startsWith('/__devtools/ws?') + ) { wss.handleUpgrade(req, socket, head, (ws) => { this.debugLog('WebSocket connection established') wss.emit('connection', ws, req) diff --git a/packages/event-bus/tests/server.test.ts b/packages/event-bus/tests/server.test.ts index d5d6c71b..a2a573f2 100644 --- a/packages/event-bus/tests/server.test.ts +++ b/packages/event-bus/tests/server.test.ts @@ -272,9 +272,7 @@ describe('ServerEventBus', () => { const port = await bus.start() // Connect a "browser" client (no ?bridge=server) - const browserWs = new WebSocket( - `ws://localhost:${port}/__devtools/ws`, - ) + const browserWs = new WebSocket(`ws://localhost:${port}/__devtools/ws`) await new Promise((resolve) => browserWs.on('open', resolve)) // Connect a "server bridge" client @@ -285,9 +283,7 @@ describe('ServerEventBus', () => { // Listen for messages on the browser client const received = new Promise((resolve) => { - browserWs.on('message', (data) => - resolve(JSON.parse(data.toString())), - ) + browserWs.on('message', (data) => resolve(JSON.parse(data.toString()))) }) // Send event from bridge @@ -344,14 +340,10 @@ describe('ServerEventBus', () => { bus = new ServerEventBus({ port: 0 }) const port = await bus.start() - const browserWs1 = new WebSocket( - `ws://localhost:${port}/__devtools/ws`, - ) + const browserWs1 = new WebSocket(`ws://localhost:${port}/__devtools/ws`) await new Promise((resolve) => browserWs1.on('open', resolve)) - const browserWs2 = new WebSocket( - `ws://localhost:${port}/__devtools/ws`, - ) + const browserWs2 = new WebSocket(`ws://localhost:${port}/__devtools/ws`) await new Promise((resolve) => browserWs2.on('open', resolve)) let received = false @@ -396,19 +388,23 @@ describe('ServerEventBus', () => { const browserWs = new WebSocket(`ws://localhost:${port}/__devtools/ws`) await new Promise((resolve) => browserWs.on('open', resolve)) - const bridgeWs = new WebSocket(`ws://localhost:${port}/__devtools/ws?bridge=server`) + const bridgeWs = new WebSocket( + `ws://localhost:${port}/__devtools/ws?bridge=server`, + ) await new Promise((resolve) => bridgeWs.on('open', resolve)) const received = new Promise((resolve) => { browserWs.on('message', (data) => resolve(JSON.parse(data.toString()))) }) - bridgeWs.send(JSON.stringify({ - type: 'test:event', - payload: { from: 'external-bridge' }, - pluginId: 'test', - source: 'server-bridge', - })) + bridgeWs.send( + JSON.stringify({ + type: 'test:event', + payload: { from: 'external-bridge' }, + pluginId: 'test', + source: 'server-bridge', + }), + ) const event = await received expect(event.type).toBe('test:event') @@ -434,18 +430,23 @@ describe('ServerEventBus', () => { // POST with source: 'server-bridge' await new Promise((resolve) => { - const req = http.request({ - hostname: 'localhost', - port, - path: '/__devtools/send', - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - }, () => resolve()) - req.write(JSON.stringify({ - type: 'test:event', - payload: { from: 'bridge' }, - source: 'server-bridge', - })) + const req = http.request( + { + hostname: 'localhost', + port, + path: '/__devtools/send', + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + }, + () => resolve(), + ) + req.write( + JSON.stringify({ + type: 'test:event', + payload: { from: 'bridge' }, + source: 'server-bridge', + }), + ) req.end() }) @@ -483,18 +484,23 @@ describe('ServerEventBus', () => { }) await new Promise((resolve) => { - const req = http.request({ - hostname: 'localhost', - port, - path: '/__devtools/send', - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - }, () => resolve()) - req.write(JSON.stringify({ - type: 'test:event', - payload: { from: 'bridge' }, - source: 'server-bridge', - })) + const req = http.request( + { + hostname: 'localhost', + port, + path: '/__devtools/send', + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + }, + () => resolve(), + ) + req.write( + JSON.stringify({ + type: 'test:event', + payload: { from: 'bridge' }, + source: 'server-bridge', + }), + ) req.end() }) From ac41406ce75fe776852a6523c11014e32a31833a Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Thu, 12 Mar 2026 16:57:26 +0100 Subject: [PATCH 15/15] feat: add Nitro v3 and Cloudflare Workers test examples MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- examples/react/start-cloudflare/.gitignore | 11 ++ examples/react/start-cloudflare/package.json | 32 ++++ .../src/devtools/ServerEventsPanel.tsx | 162 ++++++++++++++++++ .../start-cloudflare/src/devtools/index.ts | 2 + .../src/devtools/server-event-client.ts | 36 ++++ .../react/start-cloudflare/src/router.tsx | 13 ++ .../start-cloudflare/src/routes/__root.tsx | 38 ++++ .../start-cloudflare/src/routes/index.tsx | 159 +++++++++++++++++ examples/react/start-cloudflare/tsconfig.json | 24 +++ .../react/start-cloudflare/vite.config.ts | 24 +++ .../react/start-cloudflare/wrangler.jsonc | 7 + examples/react/start-nitro/.gitignore | 10 ++ examples/react/start-nitro/package.json | 30 ++++ .../src/devtools/ServerEventsPanel.tsx | 162 ++++++++++++++++++ .../react/start-nitro/src/devtools/index.ts | 2 + .../src/devtools/server-event-client.ts | 36 ++++ examples/react/start-nitro/src/router.tsx | 13 ++ .../react/start-nitro/src/routes/__root.tsx | 38 ++++ .../react/start-nitro/src/routes/index.tsx | 158 +++++++++++++++++ examples/react/start-nitro/tsconfig.json | 24 +++ examples/react/start-nitro/vite.config.ts | 24 +++ 21 files changed, 1005 insertions(+) create mode 100644 examples/react/start-cloudflare/.gitignore create mode 100644 examples/react/start-cloudflare/package.json create mode 100644 examples/react/start-cloudflare/src/devtools/ServerEventsPanel.tsx create mode 100644 examples/react/start-cloudflare/src/devtools/index.ts create mode 100644 examples/react/start-cloudflare/src/devtools/server-event-client.ts create mode 100644 examples/react/start-cloudflare/src/router.tsx create mode 100644 examples/react/start-cloudflare/src/routes/__root.tsx create mode 100644 examples/react/start-cloudflare/src/routes/index.tsx create mode 100644 examples/react/start-cloudflare/tsconfig.json create mode 100644 examples/react/start-cloudflare/vite.config.ts create mode 100644 examples/react/start-cloudflare/wrangler.jsonc create mode 100644 examples/react/start-nitro/.gitignore create mode 100644 examples/react/start-nitro/package.json create mode 100644 examples/react/start-nitro/src/devtools/ServerEventsPanel.tsx create mode 100644 examples/react/start-nitro/src/devtools/index.ts create mode 100644 examples/react/start-nitro/src/devtools/server-event-client.ts create mode 100644 examples/react/start-nitro/src/router.tsx create mode 100644 examples/react/start-nitro/src/routes/__root.tsx create mode 100644 examples/react/start-nitro/src/routes/index.tsx create mode 100644 examples/react/start-nitro/tsconfig.json create mode 100644 examples/react/start-nitro/vite.config.ts diff --git a/examples/react/start-cloudflare/.gitignore b/examples/react/start-cloudflare/.gitignore new file mode 100644 index 00000000..3c53f8cf --- /dev/null +++ b/examples/react/start-cloudflare/.gitignore @@ -0,0 +1,11 @@ +node_modules +.DS_Store +dist +dist-ssr +*.local +.env +.nitro +.tanstack +.wrangler +.output +.vinxi diff --git a/examples/react/start-cloudflare/package.json b/examples/react/start-cloudflare/package.json new file mode 100644 index 00000000..f9d072e8 --- /dev/null +++ b/examples/react/start-cloudflare/package.json @@ -0,0 +1,32 @@ +{ + "name": "start-cloudflare", + "private": true, + "type": "module", + "scripts": { + "dev": "vite dev --port 3002", + "build": "vite build", + "preview": "vite preview", + "deploy": "npm run build && wrangler deploy" + }, + "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" + }, + "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", + "typescript": "~5.9.2", + "vite": "^7.1.7", + "wrangler": "^4.40.3" + } +} diff --git a/examples/react/start-cloudflare/src/devtools/ServerEventsPanel.tsx b/examples/react/start-cloudflare/src/devtools/ServerEventsPanel.tsx new file mode 100644 index 00000000..53c84075 --- /dev/null +++ b/examples/react/start-cloudflare/src/devtools/ServerEventsPanel.tsx @@ -0,0 +1,162 @@ +import { useEffect, useState } from 'react' +import { serverEventClient } from './server-event-client' +import type { ServerEvent } from './server-event-client' + +export function ServerEventsPanel() { + const [events, setEvents] = useState>([]) + + useEffect(() => { + const cleanup = serverEventClient.on( + 'server-fn-called', + (event) => { + setEvents((prev) => [event.payload, ...prev].slice(0, 100)) + }, + { withEventTarget: true }, + ) + + return cleanup + }, []) + + const formatTime = (timestamp: number) => { + return new Date(timestamp).toLocaleTimeString('en-US', { + hour12: false, + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + fractionalSecondDigits: 3, + }) + } + + return ( +
+
+

+ Server Events ({events.length}) +

+ +
+ +
+ These events are emitted from server functions running + in Cloudflare Workers' isolated environment. If you see events appearing + here, the network transport fallback is working correctly. +
+ + {events.length === 0 ? ( +
+ No server events yet. +
+ Click "Call Server Function" to emit an event. +
+ ) : ( +
+ {events.map((ev, index) => ( +
+
+ + {ev.name} + + + {formatTime(ev.timestamp)} + +
+ {ev.data !== undefined && ( +
+                  {JSON.stringify(ev.data, null, 2)}
+                
+ )} +
+ ))} +
+ )} +
+ ) +} diff --git a/examples/react/start-cloudflare/src/devtools/index.ts b/examples/react/start-cloudflare/src/devtools/index.ts new file mode 100644 index 00000000..0773d608 --- /dev/null +++ b/examples/react/start-cloudflare/src/devtools/index.ts @@ -0,0 +1,2 @@ +export { ServerEventsPanel } from './ServerEventsPanel' +export { emitServerEvent } from './server-event-client' diff --git a/examples/react/start-cloudflare/src/devtools/server-event-client.ts b/examples/react/start-cloudflare/src/devtools/server-event-client.ts new file mode 100644 index 00000000..9a9b5a1a --- /dev/null +++ b/examples/react/start-cloudflare/src/devtools/server-event-client.ts @@ -0,0 +1,36 @@ +import { EventClient } from '@tanstack/devtools-event-client' + +export interface ServerEvent { + name: string + timestamp: number + data?: unknown +} + +type ServerEventMap = { + 'server-fn-called': ServerEvent +} + +class ServerEventClient extends EventClient { + constructor() { + super({ + pluginId: 'server-events', + }) + } +} + +export const serverEventClient = new ServerEventClient() + +/** + * Emit a devtools event from a server function. + * In Cloudflare Workers, server functions run in an isolated environment. + * Without the network transport fallback, these events would be lost. + */ +export function emitServerEvent(name: string, data?: unknown) { + if (process.env.NODE_ENV !== 'development') return + + serverEventClient.emit('server-fn-called', { + name, + timestamp: Date.now(), + data, + }) +} diff --git a/examples/react/start-cloudflare/src/router.tsx b/examples/react/start-cloudflare/src/router.tsx new file mode 100644 index 00000000..0c83bf0d --- /dev/null +++ b/examples/react/start-cloudflare/src/router.tsx @@ -0,0 +1,13 @@ +import { createRouter } from '@tanstack/react-router' +import { routeTree } from './routeTree.gen' + +export const getRouter = () => { + const router = createRouter({ + routeTree, + context: {}, + scrollRestoration: true, + defaultPreloadStaleTime: 0, + }) + + return router +} diff --git a/examples/react/start-cloudflare/src/routes/__root.tsx b/examples/react/start-cloudflare/src/routes/__root.tsx new file mode 100644 index 00000000..21aea958 --- /dev/null +++ b/examples/react/start-cloudflare/src/routes/__root.tsx @@ -0,0 +1,38 @@ +import { HeadContent, Scripts, createRootRoute } from '@tanstack/react-router' +import { TanStackDevtools } from '@tanstack/react-devtools' +import { ServerEventsPanel } from '../devtools' + +export const Route = createRootRoute({ + head: () => ({ + meta: [ + { charSet: 'utf-8' }, + { name: 'viewport', content: 'width=device-width, initial-scale=1' }, + { title: 'Cloudflare Workers Devtools Test' }, + ], + }), + shellComponent: RootDocument, +}) + +function RootDocument({ children }: { children: React.ReactNode }) { + return ( + + + + + + {children} + , + }, + ]} + /> + + + + ) +} diff --git a/examples/react/start-cloudflare/src/routes/index.tsx b/examples/react/start-cloudflare/src/routes/index.tsx new file mode 100644 index 00000000..a331124b --- /dev/null +++ b/examples/react/start-cloudflare/src/routes/index.tsx @@ -0,0 +1,159 @@ +import { useState } from 'react' +import { createFileRoute } from '@tanstack/react-router' +import { createServerFn } from '@tanstack/react-start' +import { emitServerEvent } from '../devtools' + +// Server function that emits a devtools event. +// With Cloudflare Workers, this runs in an isolated environment. +// Previously, the devtools event would be lost because globalThis.__TANSTACK_EVENT_TARGET__ +// doesn't exist in the worker. With network transport fallback, it reaches the devtools panel. +const greet = createServerFn({ method: 'GET' }).handler(async () => { + const message = `Hello from Cloudflare Worker at ${new Date().toLocaleTimeString()}` + emitServerEvent('greet()', { message }) + return message +}) + +const generateNumber = createServerFn({ method: 'GET' }).handler(async () => { + const number = Math.floor(Math.random() * 1000) + emitServerEvent('generateNumber()', { number }) + return number +}) + +const fetchData = createServerFn({ method: 'POST' }) + .inputValidator((d: string) => d) + .handler(async ({ data }) => { + const result = { query: data, results: Math.floor(Math.random() * 100) } + emitServerEvent('fetchData()', result) + return result + }) + +export const Route = createFileRoute('/')({ + component: App, + loader: async () => { + emitServerEvent('loader(/)', { route: '/' }) + return { loadedAt: new Date().toISOString() } + }, +}) + +function App() { + const loaderData = Route.useLoaderData() + const [results, setResults] = useState>([]) + + const addResult = (text: string) => { + setResults((prev) => [`[${new Date().toLocaleTimeString()}] ${text}`, ...prev].slice(0, 20)) + } + + return ( +
+

+ Cloudflare Workers Devtools Test +

+

+ Each button calls a server function running in Cloudflare Workers' + isolated environment. Open the TanStack Devtools panel (bottom-right) + and switch to the "Server Events" tab to see events arriving from the + server. +

+ +
+ + + +
+ +
+
+ Loader data (also emits server event on navigation): +
+ + {JSON.stringify(loaderData)} + +
+ + {results.length > 0 && ( +
+

+ Server responses: +

+
+ {results.map((r, i) => ( +
+ {r} +
+ ))} +
+
+ )} +
+ ) +} diff --git a/examples/react/start-cloudflare/tsconfig.json b/examples/react/start-cloudflare/tsconfig.json new file mode 100644 index 00000000..6bf32b6c --- /dev/null +++ b/examples/react/start-cloudflare/tsconfig.json @@ -0,0 +1,24 @@ +{ + "include": ["**/*.ts", "**/*.tsx"], + "compilerOptions": { + "target": "ES2022", + "jsx": "react-jsx", + "module": "ESNext", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "types": ["vite/client"], + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": false, + "noEmit": true, + "skipLibCheck": true, + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true, + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + } + } +} diff --git a/examples/react/start-cloudflare/vite.config.ts b/examples/react/start-cloudflare/vite.config.ts new file mode 100644 index 00000000..681c6f7c --- /dev/null +++ b/examples/react/start-cloudflare/vite.config.ts @@ -0,0 +1,24 @@ +import { defineConfig } from 'vite' +import { devtools } from '@tanstack/devtools-vite' +import { tanstackStart } from '@tanstack/react-start/plugin/vite' +import viteReact from '@vitejs/plugin-react' +import viteTsConfigPaths from 'vite-tsconfig-paths' +import { cloudflare } from '@cloudflare/vite-plugin' + +const config = defineConfig({ + plugins: [ + devtools({ + consolePiping: {}, + }), + // Cloudflare Workers run server code in an isolated environment. + // This is another runtime where globalThis is not shared with the Vite main thread. + cloudflare({ viteEnvironment: { name: 'ssr' } }), + viteTsConfigPaths({ + projects: ['./tsconfig.json'], + }), + tanstackStart(), + viteReact(), + ], +}) + +export default config diff --git a/examples/react/start-cloudflare/wrangler.jsonc b/examples/react/start-cloudflare/wrangler.jsonc new file mode 100644 index 00000000..ed037aa5 --- /dev/null +++ b/examples/react/start-cloudflare/wrangler.jsonc @@ -0,0 +1,7 @@ +{ + "$schema": "node_modules/wrangler/config-schema.json", + "name": "tanstack-start-cloudflare-devtools-test", + "compatibility_date": "2025-09-02", + "compatibility_flags": ["nodejs_compat"], + "main": "@tanstack/react-start/server-entry" +} diff --git a/examples/react/start-nitro/.gitignore b/examples/react/start-nitro/.gitignore new file mode 100644 index 00000000..1816bc5a --- /dev/null +++ b/examples/react/start-nitro/.gitignore @@ -0,0 +1,10 @@ +node_modules +.DS_Store +dist +dist-ssr +*.local +.env +.nitro +.tanstack +.output +.vinxi diff --git a/examples/react/start-nitro/package.json b/examples/react/start-nitro/package.json new file mode 100644 index 00000000..ea3796fb --- /dev/null +++ b/examples/react/start-nitro/package.json @@ -0,0 +1,30 @@ +{ + "name": "start-nitro", + "private": true, + "type": "module", + "scripts": { + "dev": "vite dev --port 3001", + "build": "vite build", + "preview": "vite preview" + }, + "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", + "typescript": "~5.9.2", + "vite": "^7.1.7" + } +} diff --git a/examples/react/start-nitro/src/devtools/ServerEventsPanel.tsx b/examples/react/start-nitro/src/devtools/ServerEventsPanel.tsx new file mode 100644 index 00000000..e61e945e --- /dev/null +++ b/examples/react/start-nitro/src/devtools/ServerEventsPanel.tsx @@ -0,0 +1,162 @@ +import { useEffect, useState } from 'react' +import { serverEventClient } from './server-event-client' +import type { ServerEvent } from './server-event-client' + +export function ServerEventsPanel() { + const [events, setEvents] = useState>([]) + + useEffect(() => { + const cleanup = serverEventClient.on( + 'server-fn-called', + (event) => { + setEvents((prev) => [event.payload, ...prev].slice(0, 100)) + }, + { withEventTarget: true }, + ) + + return cleanup + }, []) + + const formatTime = (timestamp: number) => { + return new Date(timestamp).toLocaleTimeString('en-US', { + hour12: false, + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + fractionalSecondDigits: 3, + }) + } + + return ( +
+
+

+ Server Events ({events.length}) +

+ +
+ +
+ These events are emitted from server functions running + in Nitro v3's isolated worker thread. If you see events appearing here, + the network transport fallback is working correctly. +
+ + {events.length === 0 ? ( +
+ No server events yet. +
+ Click "Call Server Function" to emit an event. +
+ ) : ( +
+ {events.map((ev, index) => ( +
+
+ + {ev.name} + + + {formatTime(ev.timestamp)} + +
+ {ev.data !== undefined && ( +
+                  {JSON.stringify(ev.data, null, 2)}
+                
+ )} +
+ ))} +
+ )} +
+ ) +} diff --git a/examples/react/start-nitro/src/devtools/index.ts b/examples/react/start-nitro/src/devtools/index.ts new file mode 100644 index 00000000..0773d608 --- /dev/null +++ b/examples/react/start-nitro/src/devtools/index.ts @@ -0,0 +1,2 @@ +export { ServerEventsPanel } from './ServerEventsPanel' +export { emitServerEvent } from './server-event-client' diff --git a/examples/react/start-nitro/src/devtools/server-event-client.ts b/examples/react/start-nitro/src/devtools/server-event-client.ts new file mode 100644 index 00000000..43406ac4 --- /dev/null +++ b/examples/react/start-nitro/src/devtools/server-event-client.ts @@ -0,0 +1,36 @@ +import { EventClient } from '@tanstack/devtools-event-client' + +export interface ServerEvent { + name: string + timestamp: number + data?: unknown +} + +type ServerEventMap = { + 'server-fn-called': ServerEvent +} + +class ServerEventClient extends EventClient { + constructor() { + super({ + pluginId: 'server-events', + }) + } +} + +export const serverEventClient = new ServerEventClient() + +/** + * Emit a devtools event from a server function. + * In Nitro v3, server functions run in an isolated worker thread. + * Without the network transport fallback, these events would be lost. + */ +export function emitServerEvent(name: string, data?: unknown) { + if (process.env.NODE_ENV !== 'development') return + + serverEventClient.emit('server-fn-called', { + name, + timestamp: Date.now(), + data, + }) +} diff --git a/examples/react/start-nitro/src/router.tsx b/examples/react/start-nitro/src/router.tsx new file mode 100644 index 00000000..0c83bf0d --- /dev/null +++ b/examples/react/start-nitro/src/router.tsx @@ -0,0 +1,13 @@ +import { createRouter } from '@tanstack/react-router' +import { routeTree } from './routeTree.gen' + +export const getRouter = () => { + const router = createRouter({ + routeTree, + context: {}, + scrollRestoration: true, + defaultPreloadStaleTime: 0, + }) + + return router +} diff --git a/examples/react/start-nitro/src/routes/__root.tsx b/examples/react/start-nitro/src/routes/__root.tsx new file mode 100644 index 00000000..f7aecfc6 --- /dev/null +++ b/examples/react/start-nitro/src/routes/__root.tsx @@ -0,0 +1,38 @@ +import { HeadContent, Scripts, createRootRoute } from '@tanstack/react-router' +import { TanStackDevtools } from '@tanstack/react-devtools' +import { ServerEventsPanel } from '../devtools' + +export const Route = createRootRoute({ + head: () => ({ + meta: [ + { charSet: 'utf-8' }, + { name: 'viewport', content: 'width=device-width, initial-scale=1' }, + { title: 'Nitro v3 Devtools Test' }, + ], + }), + shellComponent: RootDocument, +}) + +function RootDocument({ children }: { children: React.ReactNode }) { + return ( + + + + + + {children} + , + }, + ]} + /> + + + + ) +} diff --git a/examples/react/start-nitro/src/routes/index.tsx b/examples/react/start-nitro/src/routes/index.tsx new file mode 100644 index 00000000..587b8942 --- /dev/null +++ b/examples/react/start-nitro/src/routes/index.tsx @@ -0,0 +1,158 @@ +import { useState } from 'react' +import { createFileRoute } from '@tanstack/react-router' +import { createServerFn } from '@tanstack/react-start' +import { emitServerEvent } from '../devtools' + +// Server function that emits a devtools event. +// With Nitro v3, this runs in an isolated worker thread. +// Previously, the devtools event would be lost because globalThis.__TANSTACK_EVENT_TARGET__ +// doesn't exist in the worker. With network transport fallback, it reaches the devtools panel. +const greet = createServerFn({ method: 'GET' }).handler(async () => { + const message = `Hello from server at ${new Date().toLocaleTimeString()}` + emitServerEvent('greet()', { message }) + return message +}) + +const generateNumber = createServerFn({ method: 'GET' }).handler(async () => { + const number = Math.floor(Math.random() * 1000) + emitServerEvent('generateNumber()', { number }) + return number +}) + +const fetchData = createServerFn({ method: 'POST' }) + .inputValidator((d: string) => d) + .handler(async ({ data }) => { + const result = { query: data, results: Math.floor(Math.random() * 100) } + emitServerEvent('fetchData()', result) + return result + }) + +export const Route = createFileRoute('/')({ + component: App, + loader: async () => { + emitServerEvent('loader(/)', { route: '/' }) + return { loadedAt: new Date().toISOString() } + }, +}) + +function App() { + const loaderData = Route.useLoaderData() + const [results, setResults] = useState>([]) + + const addResult = (text: string) => { + setResults((prev) => [`[${new Date().toLocaleTimeString()}] ${text}`, ...prev].slice(0, 20)) + } + + return ( +
+

+ Nitro v3 Devtools Test +

+

+ Each button calls a server function running in Nitro's isolated worker + thread. Open the TanStack Devtools panel (bottom-right) and switch to + the "Server Events" tab to see events arriving from the server. +

+ +
+ + + +
+ +
+
+ Loader data (also emits server event on navigation): +
+ + {JSON.stringify(loaderData)} + +
+ + {results.length > 0 && ( +
+

+ Server responses: +

+
+ {results.map((r, i) => ( +
+ {r} +
+ ))} +
+
+ )} +
+ ) +} diff --git a/examples/react/start-nitro/tsconfig.json b/examples/react/start-nitro/tsconfig.json new file mode 100644 index 00000000..6bf32b6c --- /dev/null +++ b/examples/react/start-nitro/tsconfig.json @@ -0,0 +1,24 @@ +{ + "include": ["**/*.ts", "**/*.tsx"], + "compilerOptions": { + "target": "ES2022", + "jsx": "react-jsx", + "module": "ESNext", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "types": ["vite/client"], + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": false, + "noEmit": true, + "skipLibCheck": true, + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true, + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + } + } +} diff --git a/examples/react/start-nitro/vite.config.ts b/examples/react/start-nitro/vite.config.ts new file mode 100644 index 00000000..e2300abc --- /dev/null +++ b/examples/react/start-nitro/vite.config.ts @@ -0,0 +1,24 @@ +import { defineConfig } from 'vite' +import { devtools } from '@tanstack/devtools-vite' +import { tanstackStart } from '@tanstack/react-start/plugin/vite' +import viteReact from '@vitejs/plugin-react' +import viteTsConfigPaths from 'vite-tsconfig-paths' +import { nitro } from 'nitro/vite' + +const config = defineConfig({ + plugins: [ + devtools({ + consolePiping: {}, + }), + // Nitro v3 runs server code in a worker thread (separate globalThis). + // This is the exact setup that previously broke devtools event delivery. + nitro(), + viteTsConfigPaths({ + projects: ['./tsconfig.json'], + }), + tanstackStart(), + viteReact(), + ], +}) + +export default config