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" +``` 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..0c6c6e1c --- /dev/null +++ b/docs/superpowers/specs/2026-03-12-network-transport-fallback-design.md @@ -0,0 +1,160 @@ +# Network Transport Fallback for Isolated Server Runtimes + +**Date:** 2026-03-12 +**Status:** Implemented +**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 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. + +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. Follow the existing codebase convention (used in `packages/event-bus/src/client/client.ts`): + +```typescript +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*`. 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 + +`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`. This requires two changes to the existing upgrade handlers: + +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. + +**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:** +- `#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 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. The POST handler must check the `source` field to route server-bridge messages through `emit()` (broadcast) rather than just `emitToServer()`. + +**Dev-only guard:** Network transport only activates when placeholders are replaced. In production, `removeDevtoolsOnBuild` strips devtools code. Even without that, unreplaced placeholders prevent activation (`typeof DEVTOOLS_PORT === 'number'` check). + +**HMR / server restart:** WebSocket breaks on server restart. `EventClient` reconnects with exponential backoff. Events queue during reconnection. + +**Multiple EventClients in same worker:** Each instance independently connects via WebSocket. Fine for v1 — shared connection optimization possible later. + +**Queue preservation on network fallback:** The current `stopConnectLoop()` clears `#queuedEvents`. When transitioning from failed in-process handshake to network transport, the queue must be preserved. The network transport path should not call `stopConnectLoop()` or should preserve the queue before it's cleared. + +**Ordering:** WebSocket is ordered (TCP). No reordering concerns. + +## Files Changed + +### `packages/event-bus/src/server/server.ts` (ServerEventBus) +- Add optional `eventId` and `source` fields to `TanStackDevtoolsEvent` interface +- 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()` — 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`) +- Modify `getGlobalTarget()` to detect isolated server environment and set `#useNetworkTransport` (one-time, cached) +- Add WebSocket connection logic (lazy, on first emit) +- 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` (matches via `@tanstack/devtools` in package name) +- Browser-side `ClientEventBus` — unaffected beyond the interface update +- Any consuming libraries (`@tanstack/ai`, etc.) — transparent 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 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 9fd2d07b..03ec5d62 100644 --- a/packages/event-bus-client/src/plugin.ts +++ b/packages/event-bus-client/src/plugin.ts @@ -1,12 +1,58 @@ +import { RingBuffer } from './ring-buffer' + 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 } +// 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 + } +} + +let globalEventIdCounter = 0 + +function generateEventId(): string { + return `${++globalEventIdCounter}-${Date.now()}` +} + type AllDevtoolsEvents> = { [Key in keyof TEventMap & string]: TanStackDevtoolsEvent }[keyof TEventMap & string] @@ -25,6 +71,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') @@ -127,35 +187,56 @@ 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 } @@ -187,6 +268,172 @@ 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 + // 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') + 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], @@ -222,6 +469,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 @@ -316,4 +577,60 @@ export class EventClient> { handler, ) } + + /** @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 + this.#wsGaveUp = false + this.#wsReconnectAttempts = 0 + this.#wsReconnectDelay = 100 + } +} + +/** + * 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< + TEventMap extends Record, +>({ + 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/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/integration.test.ts b/packages/event-bus-client/tests/integration.test.ts new file mode 100644 index 00000000..1d73b1d5 --- /dev/null +++ b/packages/event-bus-client/tests/integration.test.ts @@ -0,0 +1,128 @@ +// @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() + }) +}) 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..8c65c79d --- /dev/null +++ b/packages/event-bus-client/tests/network-transport.test.ts @@ -0,0 +1,161 @@ +// @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('EventClient network transport emit', () => { + let serverBus: ServerEventBus + const originalNodeEnv = process.env.NODE_ENV + + beforeEach(() => { + 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)) + }) + + 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 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)) + }) + + // 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() + }) +}) 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) + }) +}) 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..1b11800e 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 { @@ -50,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 @@ -157,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() @@ -184,17 +191,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) + } }) }) } @@ -256,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() @@ -270,7 +299,10 @@ 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)', @@ -302,7 +334,10 @@ 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) @@ -373,6 +408,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..a2a573f2 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,265 @@ 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() + }) + }) + + 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() + }) + }) })