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

+ Server Events ({events.length}) +

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

+ Cloudflare Workers Devtools Test +

+

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

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

+ Server responses: +

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

+ Server Events ({events.length}) +

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

+ Nitro v3 Devtools Test +

+

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

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

+ Server responses: +

+
+ {results.map((r, i) => ( +
+ {r} +
+ ))} +
+
+ )} +
+ ) +} diff --git a/examples/react/start-nitro/tsconfig.json b/examples/react/start-nitro/tsconfig.json new file mode 100644 index 00000000..6bf32b6c --- /dev/null +++ b/examples/react/start-nitro/tsconfig.json @@ -0,0 +1,24 @@ +{ + "include": ["**/*.ts", "**/*.tsx"], + "compilerOptions": { + "target": "ES2022", + "jsx": "react-jsx", + "module": "ESNext", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "types": ["vite/client"], + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": false, + "noEmit": true, + "skipLibCheck": true, + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true, + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + } + } +} diff --git a/examples/react/start-nitro/vite.config.ts b/examples/react/start-nitro/vite.config.ts new file mode 100644 index 00000000..e2300abc --- /dev/null +++ b/examples/react/start-nitro/vite.config.ts @@ -0,0 +1,24 @@ +import { defineConfig } from 'vite' +import { devtools } from '@tanstack/devtools-vite' +import { tanstackStart } from '@tanstack/react-start/plugin/vite' +import viteReact from '@vitejs/plugin-react' +import viteTsConfigPaths from 'vite-tsconfig-paths' +import { nitro } from 'nitro/vite' + +const config = defineConfig({ + plugins: [ + devtools({ + consolePiping: {}, + }), + // Nitro v3 runs server code in a worker thread (separate globalThis). + // This is the exact setup that previously broke devtools event delivery. + nitro(), + viteTsConfigPaths({ + projects: ['./tsconfig.json'], + }), + tanstackStart(), + viteReact(), + ], +}) + +export default config From c61ea0f5a0e3b22ce8e76906f8388134e9905bce Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Fri, 27 Mar 2026 10:54:40 +0100 Subject: [PATCH 16/27] Refactor code structure for improved readability and maintainability --- .gitignore | 2 +- .../start-cloudflare/src/routes/__root.tsx | 3 + .../react/start-nitro/src/routes/__root.tsx | 3 + package.json | 4 +- pnpm-lock.yaml | 498 ++++++++++++++++-- 5 files changed, 472 insertions(+), 38 deletions(-) diff --git a/.gitignore b/.gitignore index b0fad0c0..aac1fb0a 100644 --- a/.gitignore +++ b/.gitignore @@ -54,7 +54,7 @@ yarn.lock .nx/workspace-data vite.config.js.timestamp-* vite.config.ts.timestamp-* - +.claude/worktrees .angular .nitro .sonda diff --git a/examples/react/start-cloudflare/src/routes/__root.tsx b/examples/react/start-cloudflare/src/routes/__root.tsx index 21aea958..7d85f2ee 100644 --- a/examples/react/start-cloudflare/src/routes/__root.tsx +++ b/examples/react/start-cloudflare/src/routes/__root.tsx @@ -22,6 +22,9 @@ function RootDocument({ children }: { children: React.ReactNode }) { {children} {children} =18'} + '@rolldown/binding-android-arm64@1.0.0-rc.9': + resolution: {integrity: sha512-lcJL0bN5hpgJfSIz/8PIf02irmyL43P+j1pTCfbD1DbLkmGRuFIA4DD3B3ZOvGqG0XiVvRznbKtN0COQVaKUTg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.0.0-rc.9': + resolution: {integrity: sha512-J7Zk3kLYFsLtuH6U+F4pS2sYVzac0qkjcO5QxHS7OS7yZu2LRs+IXo+uvJ/mvpyUljDJ3LROZPoQfgBIpCMhdQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.0.0-rc.9': + resolution: {integrity: sha512-iwtmmghy8nhfRGeNAIltcNXzD0QMNaaA5U/NyZc1Ia4bxrzFByNMDoppoC+hl7cDiUq5/1CnFthpT9n+UtfFyg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.0.0-rc.9': + resolution: {integrity: sha512-DLFYI78SCiZr5VvdEplsVC2Vx53lnA4/Ga5C65iyldMVaErr86aiqCoNBLl92PXPfDtUYjUh+xFFor40ueNs4Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.9': + resolution: {integrity: sha512-CsjTmTwd0Hri6iTw/DRMK7kOZ7FwAkrO4h8YWKoX/kcj833e4coqo2wzIFywtch/8Eb5enQ/lwLM7w6JX1W5RQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.9': + resolution: {integrity: sha512-2x9O2JbSPxpxMDhP9Z74mahAStibTlrBMW0520+epJH5sac7/LwZW5Bmg/E6CXuEF53JJFW509uP+lSedaUNxg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@rolldown/binding-linux-arm64-musl@1.0.0-rc.9': + resolution: {integrity: sha512-JA1QRW31ogheAIRhIg9tjMfsYbglXXYGNPLdPEYrwFxdbkQCAzvpSCSHCDWNl4hTtrol8WeboCSEpjdZK8qrCg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.9': + resolution: {integrity: sha512-aOKU9dJheda8Kj8Y3w9gnt9QFOO+qKPAl8SWd7JPHP+Cu0EuDAE5wokQubLzIDQWg2myXq2XhTpOVS07qqvT+w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + + '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.9': + resolution: {integrity: sha512-OalO94fqj7IWRn3VdXWty75jC5dk4C197AWEuMhIpvVv2lw9fiPhud0+bW2ctCxb3YoBZor71QHbY+9/WToadA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + + '@rolldown/binding-linux-x64-gnu@1.0.0-rc.9': + resolution: {integrity: sha512-cVEl1vZtBsBZna3YMjGXNvnYYrOJ7RzuWvZU0ffvJUexWkukMaDuGhUXn0rjnV0ptzGVkvc+vW9Yqy6h8YX4pg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@rolldown/binding-linux-x64-musl@1.0.0-rc.9': + resolution: {integrity: sha512-UzYnKCIIc4heAKgI4PZ3dfBGUZefGCJ1TPDuLHoCzgrMYPb5Rv6TLFuYtyM4rWyHM7hymNdsg5ik2C+UD9VDbA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@rolldown/binding-openharmony-arm64@1.0.0-rc.9': + resolution: {integrity: sha512-+6zoiF+RRyf5cdlFQP7nm58mq7+/2PFaY2DNQeD4B87N36JzfF/l9mdBkkmTvSYcYPE8tMh/o3cRlsx1ldLfog==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-wasm32-wasi@1.0.0-rc.9': + resolution: {integrity: sha512-rgFN6sA/dyebil3YTlL2evvi/M+ivhfnyxec7AccTpRPccno/rPoNlqybEZQBkcbZu8Hy+eqNJCqfBR8P7Pg8g==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + + '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.9': + resolution: {integrity: sha512-lHVNUG/8nlF1IQk1C0Ci574qKYyty2goMiPlRqkC5R+3LkXDkL5Dhx8ytbxq35m+pkHVIvIxviD+TWLdfeuadA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.0.0-rc.9': + resolution: {integrity: sha512-G0oA4+w1iY5AGi5HcDTxWsoxF509hrFIPB2rduV5aDqS9FtDg1CAfa7V34qImbjfhIcA8C+RekocJZA96EarwQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + '@rolldown/pluginutils@1.0.0-beta.40': resolution: {integrity: sha512-s3GeJKSQOwBlzdUrj4ISjJj5SfSh+aqn0wjOar4Bx95iV1ETI7F6S/5hLcfAxZ9kXDcyrAkxPlqmd1ZITttf+w==} @@ -2801,6 +3000,9 @@ packages: '@rolldown/pluginutils@1.0.0-rc.3': resolution: {integrity: sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==} + '@rolldown/pluginutils@1.0.0-rc.9': + resolution: {integrity: sha512-w6oiRWgEBl04QkFZgmW+jnU1EC9b57Oihi2ot3HNWIQRqgHp5PnYDia5iZ5FF7rpa4EQdiqMDXjlqKGXBhsoXw==} + '@rollup/plugin-alias@6.0.0': resolution: {integrity: sha512-tPCzJOtS7uuVZd+xPhoy5W4vThe6KWXNmsFCNktaAh5RTqcLiSfT4huPQIXkgJ6YCOjJHvecOAzQxLFhPxKr+g==} engines: {node: '>=20.19.0'} @@ -5340,6 +5542,15 @@ packages: resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} engines: {node: '>=0.12'} + env-runner@0.1.6: + resolution: {integrity: sha512-fSb7X1zdda8k6611a6/SdSQpDe7a/bqMz2UWdbHjk9YWzpUR4/fn9YtE/hqgGQ2nhvVN0zUtcL1SRMKwIsDbAA==} + hasBin: true + peerDependencies: + miniflare: ^4.0.0 + peerDependenciesMeta: + miniflare: + optional: true + error-stack-parser-es@1.0.5: resolution: {integrity: sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==} @@ -5907,6 +6118,16 @@ packages: crossws: optional: true + h3@2.0.1-rc.16: + resolution: {integrity: sha512-h+pjvyujdo9way8qj6FUbhaQcHlR8FEq65EhTX9ViT5pK8aLj68uFl4hBkF+hsTJAH+H1END2Yv6hTIsabGfag==} + engines: {node: '>=20.11.1'} + hasBin: true + peerDependencies: + crossws: ^0.4.1 + peerDependenciesMeta: + crossws: + optional: true + hachure-fill@0.5.2: resolution: {integrity: sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==} @@ -5993,6 +6214,9 @@ packages: hookable@5.5.3: resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==} + hookable@6.0.1: + resolution: {integrity: sha512-uKGyY8BuzN/a5gvzvA+3FVWo0+wUjgtfSdnmjtrOVwQCZPHpHDH2WRO3VZSOeluYrHoDCiXFffZXs8Dj1ULWtw==} + html-encoding-sniffer@6.0.0: resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} @@ -6038,6 +6262,9 @@ packages: httpxy@0.1.7: resolution: {integrity: sha512-pXNx8gnANKAndgga5ahefxc++tJvNL87CXoRwxn1cJE2ZkWEojF3tNfQIEhZX/vfpt+wzeAzpUI4qkediX1MLQ==} + httpxy@0.3.1: + resolution: {integrity: sha512-XjG/CEoofEisMrnFr0D6U6xOZ4mRfnwcYQ9qvvnT4lvnX8BoeA3x3WofB75D+vZwpaobFVkBIHrZzoK40w8XSw==} + human-id@4.1.3: resolution: {integrity: sha512-tsYlhAYpjCKa//8rXZ9DqKEawhPoSytweBC2eNvcaDK+57RZLHGqNs3PZTQO6yekLFSuvA6AlnAfrw1uBvtb+Q==} hasBin: true @@ -6893,6 +7120,9 @@ packages: nf3@0.3.10: resolution: {integrity: sha512-UlqmHkZiHGgSkRj17yrOXEsSu5ECvtlJ3Xm1W5WsWrTKgu9m7OjrMZh9H/ME2LcWrTlMD0/vmmNVpyBG4yRdGg==} + nf3@0.3.11: + resolution: {integrity: sha512-ObKp/SA3f1g1f/OMeDlRWaZmqGgk7A0NnDIbeO7c/MV4r/quMlpP/BsqMGuTi3lUlXbC1On8YH7ICM2u2bIAOw==} + nitro@3.0.1-alpha.2: resolution: {integrity: sha512-YviDY5J/trS821qQ1fpJtpXWIdPYiOizC/meHavlm1Hfuhx//H+Egd1+4C5SegJRgtWMnRPW9n//6Woaw81cTQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -6912,6 +7142,34 @@ packages: xml2js: optional: true + nitro@3.0.260311-beta: + resolution: {integrity: sha512-0o0fJ9LUh4WKUqJNX012jyieUOtMCnadkNDWr0mHzdraoHpJP/1CGNefjRyZyMXSpoJfwoWdNEZu2iGf35TUvQ==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + dotenv: '*' + giget: '*' + jiti: ^2.6.1 + rollup: ^4.59.0 + vite: ^7 || ^8 || >=8.0.0-0 + xml2js: ^0.6.2 + zephyr-agent: ^0.1.15 + peerDependenciesMeta: + dotenv: + optional: true + giget: + optional: true + jiti: + optional: true + rollup: + optional: true + vite: + optional: true + xml2js: + optional: true + zephyr-agent: + optional: true + nitropack@2.13.1: resolution: {integrity: sha512-2dDj89C4wC2uzG7guF3CnyG+zwkZosPEp7FFBGHB3AJo11AywOolWhyQJFHDzve8COvGxJaqscye9wW2IrUsNw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -7007,6 +7265,9 @@ packages: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} + ocache@0.1.2: + resolution: {integrity: sha512-lI34wjM7cahEdrq2I5obbF7MEdE97vULf6vNj6ZCzwEadzyXO1w7QOl2qzzG4IL8cyO7wDtXPj9CqW/aG3mn7g==} + ofetch@1.5.1: resolution: {integrity: sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA==} @@ -7580,6 +7841,11 @@ packages: robust-predicates@3.0.2: resolution: {integrity: sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg==} + rolldown@1.0.0-rc.9: + resolution: {integrity: sha512-9EbgWge7ZH+yqb4d2EnELAntgPTWbfL8ajiTW+SyhJEC4qhBbkCKbqFV4Ge4zmu5ziQuVbWxb/XwLZ+RIO7E8Q==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + rollup-plugin-preserve-directives@0.4.0: resolution: {integrity: sha512-gx4nBxYm5BysmEQS+e2tAMrtFxrGvk+Pe5ppafRibQi0zlW7VYAbEGk6IKDw9sJGPdFWgVTE0o4BU4cdG0Fylg==} peerDependencies: @@ -7606,6 +7872,9 @@ packages: rou3@0.7.12: resolution: {integrity: sha512-iFE4hLDuloSWcD7mjdCDhx2bKcIsYbtOTpfH5MHHLSKMOUyjqQXTeZVa289uuwEGEKFoE/BAPbhaU4B774nceg==} + rou3@0.8.1: + resolution: {integrity: sha512-ePa+XGk00/3HuCqrEnK3LxJW7I0SdNg6EFzKUJG73hMAdDcOUC/i/aSz7LSDwLrGr33kal/rqOGydzwl6U7zBA==} + roughjs@4.6.6: resolution: {integrity: sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ==} @@ -7852,6 +8121,11 @@ packages: engines: {node: '>=20.16.0'} hasBin: true + srvx@0.11.9: + resolution: {integrity: sha512-97wWJS6F0KTKAhDlHVmBzMvlBOp5FiNp3XrLoodIgYJpXxgG5tE9rX4Pg7s46n2shI4wtEsMATTS1+rI3/ubzA==} + engines: {node: '>=20.16.0'} + hasBin: true + stable-hash-x@0.2.0: resolution: {integrity: sha512-o3yWv49B/o4QZk5ZcsALc6t0+eCelPc44zZsLtCQnZPDwFpDYSWcDnrv2TtMmMbQ7uKo3J0HTURCqckw23czNQ==} engines: {node: '>=12.0.0'} @@ -10322,6 +10596,8 @@ snapshots: '@oxc-minify/binding-win32-x64-msvc@0.110.0': optional: true + '@oxc-project/types@0.115.0': {} + '@oxc-resolver/binding-android-arm-eabi@11.19.1': optional: true @@ -10601,12 +10877,61 @@ snapshots: '@publint/pack@0.1.4': {} + '@rolldown/binding-android-arm64@1.0.0-rc.9': + optional: true + + '@rolldown/binding-darwin-arm64@1.0.0-rc.9': + optional: true + + '@rolldown/binding-darwin-x64@1.0.0-rc.9': + optional: true + + '@rolldown/binding-freebsd-x64@1.0.0-rc.9': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.9': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.9': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.0.0-rc.9': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.9': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.9': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.0.0-rc.9': + optional: true + + '@rolldown/binding-linux-x64-musl@1.0.0-rc.9': + optional: true + + '@rolldown/binding-openharmony-arm64@1.0.0-rc.9': + optional: true + + '@rolldown/binding-wasm32-wasi@1.0.0-rc.9': + dependencies: + '@napi-rs/wasm-runtime': 1.1.1 + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.9': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.0.0-rc.9': + optional: true + '@rolldown/pluginutils@1.0.0-beta.40': {} '@rolldown/pluginutils@1.0.0-rc.2': {} '@rolldown/pluginutils@1.0.0-rc.3': {} + '@rolldown/pluginutils@1.0.0-rc.9': {} + '@rollup/plugin-alias@6.0.0(rollup@4.59.0)': optionalDependencies: rollup: 4.59.0 @@ -10975,11 +11300,11 @@ snapshots: dependencies: solid-js: 1.9.11 - '@solidjs/start@1.3.2(@testing-library/jest-dom@6.9.1)(solid-js@1.9.11)(vinxi@0.5.11(@types/node@22.19.13)(db0@0.3.4(drizzle-orm@0.45.1(pg@8.19.0)))(drizzle-orm@0.45.1(pg@8.19.0))(ioredis@5.10.0)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(vite@7.3.1(@types/node@22.19.13)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))': + '@solidjs/start@1.3.2(@testing-library/jest-dom@6.9.1)(solid-js@1.9.11)(vinxi@0.5.11(@types/node@22.19.13)(db0@0.3.4(drizzle-orm@0.45.1(pg@8.19.0)))(drizzle-orm@0.45.1(pg@8.19.0))(ioredis@5.10.0)(jiti@2.6.1)(lightningcss@1.31.1)(rolldown@1.0.0-rc.9)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(vite@7.3.1(@types/node@22.19.13)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@tanstack/server-functions-plugin': 1.121.21(vite@7.3.1(@types/node@22.19.13)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) - '@vinxi/plugin-directives': 0.5.1(vinxi@0.5.11(@types/node@22.19.13)(db0@0.3.4(drizzle-orm@0.45.1(pg@8.19.0)))(drizzle-orm@0.45.1(pg@8.19.0))(ioredis@5.10.0)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) - '@vinxi/server-components': 0.5.1(vinxi@0.5.11(@types/node@22.19.13)(db0@0.3.4(drizzle-orm@0.45.1(pg@8.19.0)))(drizzle-orm@0.45.1(pg@8.19.0))(ioredis@5.10.0)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) + '@vinxi/plugin-directives': 0.5.1(vinxi@0.5.11(@types/node@22.19.13)(db0@0.3.4(drizzle-orm@0.45.1(pg@8.19.0)))(drizzle-orm@0.45.1(pg@8.19.0))(ioredis@5.10.0)(jiti@2.6.1)(lightningcss@1.31.1)(rolldown@1.0.0-rc.9)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) + '@vinxi/server-components': 0.5.1(vinxi@0.5.11(@types/node@22.19.13)(db0@0.3.4(drizzle-orm@0.45.1(pg@8.19.0)))(drizzle-orm@0.45.1(pg@8.19.0))(ioredis@5.10.0)(jiti@2.6.1)(lightningcss@1.31.1)(rolldown@1.0.0-rc.9)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) cookie-es: 2.0.0 defu: 6.1.4 error-stack-parser: 2.1.4 @@ -10991,7 +11316,7 @@ snapshots: source-map-js: 1.2.1 terracotta: 1.1.0(solid-js@1.9.11) tinyglobby: 0.2.15 - vinxi: 0.5.11(@types/node@22.19.13)(db0@0.3.4(drizzle-orm@0.45.1(pg@8.19.0)))(drizzle-orm@0.45.1(pg@8.19.0))(ioredis@5.10.0)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + vinxi: 0.5.11(@types/node@22.19.13)(db0@0.3.4(drizzle-orm@0.45.1(pg@8.19.0)))(drizzle-orm@0.45.1(pg@8.19.0))(ioredis@5.10.0)(jiti@2.6.1)(lightningcss@1.31.1)(rolldown@1.0.0-rc.9)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) vite-plugin-solid: 2.11.10(@testing-library/jest-dom@6.9.1)(solid-js@1.9.11)(vite@7.3.1(@types/node@22.19.13)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) transitivePeerDependencies: - '@testing-library/jest-dom' @@ -11349,13 +11674,13 @@ snapshots: transitivePeerDependencies: - crossws - '@tanstack/react-start-server@1.166.0(crossws@0.4.4(srvx@0.11.8))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@tanstack/react-start-server@1.166.0(crossws@0.4.4(srvx@0.11.9))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: '@tanstack/history': 1.161.4 '@tanstack/react-router': 1.163.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@tanstack/router-core': 1.163.3 '@tanstack/start-client-core': 1.164.1 - '@tanstack/start-server-core': 1.166.0(crossws@0.4.4(srvx@0.11.8)) + '@tanstack/start-server-core': 1.166.0(crossws@0.4.4(srvx@0.11.9)) react: 19.2.4 react-dom: 19.2.4(react@19.2.4) transitivePeerDependencies: @@ -11381,15 +11706,15 @@ snapshots: - vite-plugin-solid - webpack - '@tanstack/react-start@1.166.1(crossws@0.4.4(srvx@0.11.8))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vite-plugin-solid@2.11.10(@testing-library/jest-dom@6.9.1)(solid-js@1.9.11)(vite@7.3.1(@types/node@22.19.13)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))(vite@7.3.1(@types/node@22.19.13)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))': + '@tanstack/react-start@1.166.1(crossws@0.4.4(srvx@0.11.9))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vite-plugin-solid@2.11.10(@testing-library/jest-dom@6.9.1)(solid-js@1.9.11)(vite@7.3.1(@types/node@22.19.13)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))(vite@7.3.1(@types/node@22.19.13)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@tanstack/react-router': 1.163.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@tanstack/react-start-client': 1.164.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@tanstack/react-start-server': 1.166.0(crossws@0.4.4(srvx@0.11.8))(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@tanstack/react-start-server': 1.166.0(crossws@0.4.4(srvx@0.11.9))(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@tanstack/router-utils': 1.161.4 '@tanstack/start-client-core': 1.164.1 - '@tanstack/start-plugin-core': 1.166.1(@tanstack/react-router@1.163.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(crossws@0.4.4(srvx@0.11.8))(vite-plugin-solid@2.11.10(@testing-library/jest-dom@6.9.1)(solid-js@1.9.11)(vite@7.3.1(@types/node@22.19.13)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))(vite@7.3.1(@types/node@22.19.13)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) - '@tanstack/start-server-core': 1.166.0(crossws@0.4.4(srvx@0.11.8)) + '@tanstack/start-plugin-core': 1.166.1(@tanstack/react-router@1.163.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(crossws@0.4.4(srvx@0.11.9))(vite-plugin-solid@2.11.10(@testing-library/jest-dom@6.9.1)(solid-js@1.9.11)(vite@7.3.1(@types/node@22.19.13)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))(vite@7.3.1(@types/node@22.19.13)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) + '@tanstack/start-server-core': 1.166.0(crossws@0.4.4(srvx@0.11.9)) pathe: 2.0.3 react: 19.2.4 react-dom: 19.2.4(react@19.2.4) @@ -11579,7 +11904,7 @@ snapshots: - vite-plugin-solid - webpack - '@tanstack/start-plugin-core@1.166.1(@tanstack/react-router@1.163.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(crossws@0.4.4(srvx@0.11.8))(vite-plugin-solid@2.11.10(@testing-library/jest-dom@6.9.1)(solid-js@1.9.11)(vite@7.3.1(@types/node@22.19.13)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))(vite@7.3.1(@types/node@22.19.13)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))': + '@tanstack/start-plugin-core@1.166.1(@tanstack/react-router@1.163.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(crossws@0.4.4(srvx@0.11.9))(vite-plugin-solid@2.11.10(@testing-library/jest-dom@6.9.1)(solid-js@1.9.11)(vite@7.3.1(@types/node@22.19.13)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))(vite@7.3.1(@types/node@22.19.13)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@babel/code-frame': 7.27.1 '@babel/core': 7.29.0 @@ -11590,7 +11915,7 @@ snapshots: '@tanstack/router-plugin': 1.164.0(@tanstack/react-router@1.163.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite-plugin-solid@2.11.10(@testing-library/jest-dom@6.9.1)(solid-js@1.9.11)(vite@7.3.1(@types/node@22.19.13)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))(vite@7.3.1(@types/node@22.19.13)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) '@tanstack/router-utils': 1.161.4 '@tanstack/start-client-core': 1.164.1 - '@tanstack/start-server-core': 1.166.0(crossws@0.4.4(srvx@0.11.8)) + '@tanstack/start-server-core': 1.166.0(crossws@0.4.4(srvx@0.11.9)) cheerio: 1.2.0 exsolve: 1.0.8 pathe: 2.0.3 @@ -11623,13 +11948,13 @@ snapshots: transitivePeerDependencies: - crossws - '@tanstack/start-server-core@1.166.0(crossws@0.4.4(srvx@0.11.8))': + '@tanstack/start-server-core@1.166.0(crossws@0.4.4(srvx@0.11.9))': dependencies: '@tanstack/history': 1.161.4 '@tanstack/router-core': 1.163.3 '@tanstack/start-client-core': 1.164.1 '@tanstack/start-storage-context': 1.163.3 - h3-v2: h3@2.0.1-rc.14(crossws@0.4.4(srvx@0.11.8)) + h3-v2: h3@2.0.1-rc.14(crossws@0.4.4(srvx@0.11.9)) seroval: 1.5.0 tiny-invariant: 1.3.3 transitivePeerDependencies: @@ -12129,7 +12454,7 @@ snapshots: untun: 0.1.3 uqr: 0.1.2 - '@vinxi/plugin-directives@0.5.1(vinxi@0.5.11(@types/node@22.19.13)(db0@0.3.4(drizzle-orm@0.45.1(pg@8.19.0)))(drizzle-orm@0.45.1(pg@8.19.0))(ioredis@5.10.0)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))': + '@vinxi/plugin-directives@0.5.1(vinxi@0.5.11(@types/node@22.19.13)(db0@0.3.4(drizzle-orm@0.45.1(pg@8.19.0)))(drizzle-orm@0.45.1(pg@8.19.0))(ioredis@5.10.0)(jiti@2.6.1)(lightningcss@1.31.1)(rolldown@1.0.0-rc.9)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@babel/parser': 7.29.0 acorn: 8.16.0 @@ -12140,18 +12465,18 @@ snapshots: magicast: 0.2.11 recast: 0.23.11 tslib: 2.8.1 - vinxi: 0.5.11(@types/node@22.19.13)(db0@0.3.4(drizzle-orm@0.45.1(pg@8.19.0)))(drizzle-orm@0.45.1(pg@8.19.0))(ioredis@5.10.0)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + vinxi: 0.5.11(@types/node@22.19.13)(db0@0.3.4(drizzle-orm@0.45.1(pg@8.19.0)))(drizzle-orm@0.45.1(pg@8.19.0))(ioredis@5.10.0)(jiti@2.6.1)(lightningcss@1.31.1)(rolldown@1.0.0-rc.9)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) - '@vinxi/server-components@0.5.1(vinxi@0.5.11(@types/node@22.19.13)(db0@0.3.4(drizzle-orm@0.45.1(pg@8.19.0)))(drizzle-orm@0.45.1(pg@8.19.0))(ioredis@5.10.0)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))': + '@vinxi/server-components@0.5.1(vinxi@0.5.11(@types/node@22.19.13)(db0@0.3.4(drizzle-orm@0.45.1(pg@8.19.0)))(drizzle-orm@0.45.1(pg@8.19.0))(ioredis@5.10.0)(jiti@2.6.1)(lightningcss@1.31.1)(rolldown@1.0.0-rc.9)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))': dependencies: - '@vinxi/plugin-directives': 0.5.1(vinxi@0.5.11(@types/node@22.19.13)(db0@0.3.4(drizzle-orm@0.45.1(pg@8.19.0)))(drizzle-orm@0.45.1(pg@8.19.0))(ioredis@5.10.0)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) + '@vinxi/plugin-directives': 0.5.1(vinxi@0.5.11(@types/node@22.19.13)(db0@0.3.4(drizzle-orm@0.45.1(pg@8.19.0)))(drizzle-orm@0.45.1(pg@8.19.0))(ioredis@5.10.0)(jiti@2.6.1)(lightningcss@1.31.1)(rolldown@1.0.0-rc.9)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) acorn: 8.16.0 acorn-loose: 8.5.2 acorn-typescript: 1.4.13(acorn@8.16.0) astring: 1.9.0 magicast: 0.2.11 recast: 0.23.11 - vinxi: 0.5.11(@types/node@22.19.13)(db0@0.3.4(drizzle-orm@0.45.1(pg@8.19.0)))(drizzle-orm@0.45.1(pg@8.19.0))(ioredis@5.10.0)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + vinxi: 0.5.11(@types/node@22.19.13)(db0@0.3.4(drizzle-orm@0.45.1(pg@8.19.0)))(drizzle-orm@0.45.1(pg@8.19.0))(ioredis@5.10.0)(jiti@2.6.1)(lightningcss@1.31.1)(rolldown@1.0.0-rc.9)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) '@vitejs/plugin-react@5.1.4(vite@7.3.1(@types/node@22.19.13)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))': dependencies: @@ -12868,10 +13193,9 @@ snapshots: optionalDependencies: srvx: 0.10.1 - crossws@0.4.4(srvx@0.11.8): + crossws@0.4.4(srvx@0.11.9): optionalDependencies: - srvx: 0.11.8 - optional: true + srvx: 0.11.9 css-select@5.2.2: dependencies: @@ -13291,6 +13615,14 @@ snapshots: entities@7.0.1: {} + env-runner@0.1.6(miniflare@4.20260301.1): + dependencies: + crossws: 0.4.4(srvx@0.11.9) + httpxy: 0.3.1 + srvx: 0.11.9 + optionalDependencies: + miniflare: 4.20260301.1 + error-stack-parser-es@1.0.5: {} error-stack-parser@2.1.4: @@ -14060,12 +14392,19 @@ snapshots: optionalDependencies: crossws: 0.4.4(srvx@0.10.1) - h3@2.0.1-rc.14(crossws@0.4.4(srvx@0.11.8)): + h3@2.0.1-rc.14(crossws@0.4.4(srvx@0.11.9)): dependencies: rou3: 0.7.12 srvx: 0.11.8 optionalDependencies: - crossws: 0.4.4(srvx@0.11.8) + crossws: 0.4.4(srvx@0.11.9) + + h3@2.0.1-rc.16(crossws@0.4.4(srvx@0.11.9)): + dependencies: + rou3: 0.8.1 + srvx: 0.11.9 + optionalDependencies: + crossws: 0.4.4(srvx@0.11.9) hachure-fill@0.5.2: {} @@ -14227,6 +14566,8 @@ snapshots: hookable@5.5.3: {} + hookable@6.0.1: {} + html-encoding-sniffer@6.0.0: dependencies: '@exodus/bytes': 1.15.0 @@ -14286,6 +14627,8 @@ snapshots: httpxy@0.1.7: {} + httpxy@0.3.1: {} + human-id@4.1.3: {} human-signals@5.0.0: {} @@ -15330,7 +15673,9 @@ snapshots: nf3@0.3.10: {} - nitro@3.0.1-alpha.2(chokidar@5.0.0)(drizzle-orm@0.45.1(pg@8.19.0))(ioredis@5.10.0)(lru-cache@11.2.6)(rollup@4.59.0)(vite@7.3.1(@types/node@22.19.13)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)): + nf3@0.3.11: {} + + nitro@3.0.1-alpha.2(chokidar@5.0.0)(drizzle-orm@0.45.1(pg@8.19.0))(ioredis@5.10.0)(lru-cache@11.2.6)(rolldown@1.0.0-rc.9)(rollup@4.59.0)(vite@7.3.1(@types/node@22.19.13)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)): dependencies: consola: 3.4.2 crossws: 0.4.4(srvx@0.10.1) @@ -15347,6 +15692,7 @@ snapshots: unenv: 2.0.0-rc.24 unstorage: 2.0.0-alpha.6(chokidar@5.0.0)(db0@0.3.4(drizzle-orm@0.45.1(pg@8.19.0)))(ioredis@5.10.0)(lru-cache@11.2.6)(ofetch@2.0.0-alpha.3) optionalDependencies: + rolldown: 1.0.0-rc.9 rollup: 4.59.0 vite: 7.3.1(@types/node@22.19.13)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) transitivePeerDependencies: @@ -15378,7 +15724,59 @@ snapshots: - sqlite3 - uploadthing - nitropack@2.13.1(drizzle-orm@0.45.1(pg@8.19.0)): + nitro@3.0.260311-beta(chokidar@5.0.0)(dotenv@17.3.1)(drizzle-orm@0.45.1(pg@8.19.0))(giget@2.0.0)(ioredis@5.10.0)(jiti@2.6.1)(lru-cache@11.2.6)(miniflare@4.20260301.1)(rollup@4.59.0)(vite@7.3.1(@types/node@22.19.13)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)): + dependencies: + consola: 3.4.2 + crossws: 0.4.4(srvx@0.11.9) + db0: 0.3.4(drizzle-orm@0.45.1(pg@8.19.0)) + env-runner: 0.1.6(miniflare@4.20260301.1) + h3: 2.0.1-rc.16(crossws@0.4.4(srvx@0.11.9)) + hookable: 6.0.1 + nf3: 0.3.11 + ocache: 0.1.2 + ofetch: 2.0.0-alpha.3 + ohash: 2.0.11 + rolldown: 1.0.0-rc.9 + srvx: 0.11.9 + unenv: 2.0.0-rc.24 + unstorage: 2.0.0-alpha.6(chokidar@5.0.0)(db0@0.3.4(drizzle-orm@0.45.1(pg@8.19.0)))(ioredis@5.10.0)(lru-cache@11.2.6)(ofetch@2.0.0-alpha.3) + optionalDependencies: + dotenv: 17.3.1 + giget: 2.0.0 + jiti: 2.6.1 + rollup: 4.59.0 + vite: 7.3.1(@types/node@22.19.13)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + transitivePeerDependencies: + - '@azure/app-configuration' + - '@azure/cosmos' + - '@azure/data-tables' + - '@azure/identity' + - '@azure/keyvault-secrets' + - '@azure/storage-blob' + - '@capacitor/preferences' + - '@deno/kv' + - '@electric-sql/pglite' + - '@libsql/client' + - '@netlify/blobs' + - '@planetscale/database' + - '@upstash/redis' + - '@vercel/blob' + - '@vercel/functions' + - '@vercel/kv' + - aws4fetch + - better-sqlite3 + - chokidar + - drizzle-orm + - idb-keyval + - ioredis + - lru-cache + - miniflare + - mongodb + - mysql2 + - sqlite3 + - uploadthing + + nitropack@2.13.1(drizzle-orm@0.45.1(pg@8.19.0))(rolldown@1.0.0-rc.9): dependencies: '@cloudflare/kv-asset-handler': 0.4.2 '@rollup/plugin-alias': 6.0.0(rollup@4.59.0) @@ -15431,7 +15829,7 @@ snapshots: pretty-bytes: 7.1.0 radix3: 1.1.2 rollup: 4.59.0 - rollup-plugin-visualizer: 6.0.11(rollup@4.59.0) + rollup-plugin-visualizer: 6.0.11(rolldown@1.0.0-rc.9)(rollup@4.59.0) scule: 1.3.0 semver: 7.7.4 serve-placeholder: 2.0.2 @@ -15589,6 +15987,10 @@ snapshots: object-assign@4.1.1: {} + ocache@0.1.2: + dependencies: + ohash: 2.0.11 + ofetch@1.5.1: dependencies: destr: 2.0.5 @@ -16243,19 +16645,41 @@ snapshots: robust-predicates@3.0.2: {} + rolldown@1.0.0-rc.9: + dependencies: + '@oxc-project/types': 0.115.0 + '@rolldown/pluginutils': 1.0.0-rc.9 + optionalDependencies: + '@rolldown/binding-android-arm64': 1.0.0-rc.9 + '@rolldown/binding-darwin-arm64': 1.0.0-rc.9 + '@rolldown/binding-darwin-x64': 1.0.0-rc.9 + '@rolldown/binding-freebsd-x64': 1.0.0-rc.9 + '@rolldown/binding-linux-arm-gnueabihf': 1.0.0-rc.9 + '@rolldown/binding-linux-arm64-gnu': 1.0.0-rc.9 + '@rolldown/binding-linux-arm64-musl': 1.0.0-rc.9 + '@rolldown/binding-linux-ppc64-gnu': 1.0.0-rc.9 + '@rolldown/binding-linux-s390x-gnu': 1.0.0-rc.9 + '@rolldown/binding-linux-x64-gnu': 1.0.0-rc.9 + '@rolldown/binding-linux-x64-musl': 1.0.0-rc.9 + '@rolldown/binding-openharmony-arm64': 1.0.0-rc.9 + '@rolldown/binding-wasm32-wasi': 1.0.0-rc.9 + '@rolldown/binding-win32-arm64-msvc': 1.0.0-rc.9 + '@rolldown/binding-win32-x64-msvc': 1.0.0-rc.9 + rollup-plugin-preserve-directives@0.4.0(rollup@4.59.0): dependencies: '@rollup/pluginutils': 5.3.0(rollup@4.59.0) magic-string: 0.30.21 rollup: 4.59.0 - rollup-plugin-visualizer@6.0.11(rollup@4.59.0): + rollup-plugin-visualizer@6.0.11(rolldown@1.0.0-rc.9)(rollup@4.59.0): dependencies: open: 8.4.2 picomatch: 4.0.3 source-map: 0.7.6 yargs: 17.7.2 optionalDependencies: + rolldown: 1.0.0-rc.9 rollup: 4.59.0 rollup@4.59.0: @@ -16291,6 +16715,8 @@ snapshots: rou3@0.7.12: {} + rou3@0.8.1: {} + roughjs@4.6.6: dependencies: hachure-fill: 0.5.2 @@ -16580,6 +17006,8 @@ snapshots: srvx@0.11.8: {} + srvx@0.11.9: {} + stable-hash-x@0.2.0: {} stack-trace@1.0.0-pre2: {} @@ -17206,7 +17634,7 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 - vinxi@0.5.11(@types/node@22.19.13)(db0@0.3.4(drizzle-orm@0.45.1(pg@8.19.0)))(drizzle-orm@0.45.1(pg@8.19.0))(ioredis@5.10.0)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2): + vinxi@0.5.11(@types/node@22.19.13)(db0@0.3.4(drizzle-orm@0.45.1(pg@8.19.0)))(drizzle-orm@0.45.1(pg@8.19.0))(ioredis@5.10.0)(jiti@2.6.1)(lightningcss@1.31.1)(rolldown@1.0.0-rc.9)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2): dependencies: '@babel/core': 7.29.0 '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.29.0) @@ -17227,7 +17655,7 @@ snapshots: hookable: 5.5.3 http-proxy: 1.18.1 micromatch: 4.0.8 - nitropack: 2.13.1(drizzle-orm@0.45.1(pg@8.19.0)) + nitropack: 2.13.1(drizzle-orm@0.45.1(pg@8.19.0))(rolldown@1.0.0-rc.9) node-fetch-native: 1.6.7 path-to-regexp: 6.3.0 pathe: 1.1.2 From 69a91cdfdb08f95b71d9c688684e194600ca824b Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Fri, 19 Jun 2026 15:48:18 +0200 Subject: [PATCH 17/27] docs: design spec for native Vite HotChannel runtime bridge --- ...-vite-hot-channel-runtime-bridge-design.md | 168 ++++++++++++++++++ 1 file changed, 168 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-19-vite-hot-channel-runtime-bridge-design.md diff --git a/docs/superpowers/specs/2026-06-19-vite-hot-channel-runtime-bridge-design.md b/docs/superpowers/specs/2026-06-19-vite-hot-channel-runtime-bridge-design.md new file mode 100644 index 00000000..8cb36da9 --- /dev/null +++ b/docs/superpowers/specs/2026-06-19-vite-hot-channel-runtime-bridge-design.md @@ -0,0 +1,168 @@ +# Native Vite HotChannel Runtime Bridge for Isolated Server Runtimes + +**Date:** 2026-06-19 +**Status:** Approved (design) +**Issue:** https://github.com/TanStack/ai/issues/339 +**Supersedes approach in:** PR #384 (`feat: network transport fallback for isolated server runtimes`) + +## Problem + +When TanStack Start runs server code in an **isolated runtime** — Nitro v3's `nitro()` Vite plugin (worker thread), Cloudflare's `workerd`, or any separate thread/process — the devtools event system breaks. + +`ServerEventBus` (in the Vite main process) creates and listens on `globalThis.__TANSTACK_EVENT_TARGET__`. In the isolated worker, `globalThis` is a *different* object, so `globalThis.__TANSTACK_EVENT_TARGET__` is `null`. When the server-side `EventClient` calls `getGlobalTarget()` it falls through to creating a throwaway `EventTarget` that nobody listens on. Events emitted on the server (e.g. `chat()` / `TextEngine` events, Query/Router server events) go nowhere and never reach the devtools panel. + +The default TanStack Start dev setup does not hit this because it uses Vite's `RunnableDevEnvironment`, which runs in-process and shares the same `globalThis`. + +## Why not PR #384's approach + +PR #384 solved this by building a full **network transport into `EventClient`** (the tiny, widely-imported `@tanstack/devtools-event-client` package): a WebSocket client, exponential-backoff reconnection, an HTTP POST fallback, an `eventId` + 200-entry ring-buffer dedup layer, and `?bridge=server` routing in `ServerEventBus`. This added ~380 lines to the client package and roughly doubled its bundle size — a cost paid by every consumer of every TanStack devtools plugin, for a dev-only concern. + +Issue #339 itself identifies the better path (its "Option 3", marked "most impactful"): Vite's Environment API already maintains a `HotChannel` between each isolated runtime and the dev process for HMR. The framework plugins (`@cloudflare/vite-plugin`, Nitro) establish it; `@cloudflare/vite-plugin` already uses `import.meta.hot.send(...)` from `workerd` to talk to the Vite process. We reuse that same channel. + +## Insight + +Cross-runtime communication is already solved by Vite — we just have to use it: + +- **From the isolated runtime:** `import.meta.hot.send('event', data)` dispatches to the Vite dev server over the channel the framework plugin already opened. +- **On the dev server:** `server.environments[name].hot.on('event', handler)` receives it; `server.environments[name].hot.send('event', data)` sends back. + +This is confirmed by the Vite docs (Environment API for Runtimes / Plugins): the Environment API supports isolated SSR workers and worker threads with per-environment hot channels. + +**No new WebSocket, no fetch, no reconnection logic, no ring buffer.** Vite owns the connection lifecycle (including HMR restarts), and we faithfully replicate the existing single-process `EventTarget` dispatch semantics across the wire — which removes the need for dedup. + +## Architecture + +Three parts: two reverts and one new dev-only bridge. + +### Part 1 — Revert `event-bus-client` and `event-bus` to minimal + +Restore the published packages to their pre-#384 byte size. + +**`packages/event-bus-client/`** +- `src/plugin.ts` — restore `main`'s `EventClient`: remove the WebSocket transport, reconnection, HTTP POST fallback, `#useNetworkTransport`, `eventId`/`source` fields, and the `__TANSTACK_DEVTOOLS_*` placeholder declarations. (The HotChannel needs no port/host coordinates, so no placeholders are required here.) +- `src/ring-buffer.ts` — delete. +- `src/index.ts` — remove the `createNetworkTransportClient` export. +- `tests/network-transport.test.ts`, `tests/ring-buffer.test.ts`, `tests/integration.test.ts` — delete. + +**`packages/event-bus/`** +- `src/server/server.ts` — take `main`'s version (drop `?bridge=server` URL matching, bridge-vs-browser connection tagging, and POST `source`-based routing). Keep the `main` hardening from #466. +- `src/client/client.ts` — take `main`'s version (drop `eventId`/`source` interface fields). +- `tests/server.test.ts` — drop the new bridge/POST routing tests; keep `main`'s suite. + +**Net result:** the widely-imported `@tanstack/devtools-event-client` package returns to its pre-#384 size; all new code lives in the dev-only Vite plugin. + +### Part 2 — Worker-side bridge (injected by `devtools-vite`, dev-only) + +A small, runtime-guarded bridge injected into the `@tanstack/devtools-event-client` module **only when it is transformed in a non-client (server) environment during `serve` in development**. It runs at module evaluation — before any `EventClient` instance method is called — so `EventClient.getGlobalTarget()` finds a real global target instead of a throwaway. + +```js +if (import.meta.hot && !globalThis.__TANSTACK_EVENT_TARGET__) { + const target = new EventTarget() + globalThis.__TANSTACK_EVENT_TARGET__ = target + + // Complete EventClient's connect handshake locally so it flushes queued events. + target.addEventListener('tanstack-connect', () => + target.dispatchEvent(new CustomEvent('tanstack-connect-success')), + ) + + // Worker -> Vite dev server. + target.addEventListener('tanstack-dispatch-event', (e) => + import.meta.hot.send('tsd:to-server', e.detail), + ) + + // Vite dev server -> worker listeners. + import.meta.hot.on('tsd:to-client', (event) => { + target.dispatchEvent(new CustomEvent(event.type, { detail: event })) + target.dispatchEvent( + new CustomEvent('tanstack-devtools-global', { detail: event }), + ) + }) +} +``` + +The `!globalThis.__TANSTACK_EVENT_TARGET__` guard makes in-process runtimes (where `ServerEventBus` already set the global) skip the bridge entirely — zero behavior change for the common path. The `import.meta.hot` guard makes it a no-op (and tree-shaken) in production. + +#### Replicating the in-process protocol + +The bridge replicates the responsibilities `ServerEventBus` performs in-process, so the unchanged `EventClient` behaves identically: + +1. `EventClient.emit()` → (after handshake) dispatches `tanstack-dispatch-event` on the global target. In-process, `ServerEventBus#dispatcher` handles it. In the worker, the bridge forwards it over `tsd:to-server`. +2. `EventClient` first emit dispatches `tanstack-connect` and waits for `tanstack-connect-success`. In-process, `ServerEventBus` replies. In the worker, the bridge replies locally so the queue flushes. +3. Devtools/browser-originated events that `ServerEventBus` would dispatch on the in-process target (`event.type` + `tanstack-devtools-global`, for server-side `.on()` listeners) arrive over `tsd:to-client` and the bridge dispatches them on the worker's target. + +### Part 3 — Dev-server wiring (`devtools-vite` `configureServer`) + +For every server environment that exposes a hot channel (i.e. every environment except `client`): + +```js +const globalTarget = globalThis.__TANSTACK_EVENT_TARGET__ // set by ServerEventBus + +// Worker -> ServerEventBus (broadcasts to browser + in-process listeners). +env.hot.on('tsd:to-server', (event) => { + globalTarget?.dispatchEvent( + new CustomEvent('tanstack-dispatch-event', { detail: event }), + ) +}) + +// ServerEventBus output -> worker listeners. +const forward = (e) => env.hot.send('tsd:to-client', e.detail) +globalTarget?.addEventListener('tanstack-devtools-global', forward) +// (removed on server close / environment teardown) +``` + +Feeding the worker's event back in as `tanstack-dispatch-event` routes it through the *existing* `ServerEventBus` path — it reaches the browser exactly as an in-process server event would, with no `ServerEventBus` changes. + +## Why there is no echo / dedup problem + +We are not inventing a new protocol; we are extending the existing `EventTarget` dispatch across a wire. The **emit** path dispatches `tanstack-dispatch-event`; the **receive** path dispatches `event.type` / `tanstack-devtools-global`. These are disjoint event names, so a received event never re-triggers the send path — no loop. An event the worker emitted does come back to the worker's own `.on()` listeners, but that is exactly what already happens in a single process today (the emitter shares the target it listens on), so the behavior is consistent and no `eventId`/ring-buffer dedup is required. + +## Edge cases + +- **HMR / worker restart:** Vite tears down and re-establishes the HotChannel and re-evaluates modules; the bridge re-registers automatically. No custom reconnect/backoff. +- **Production builds:** `import.meta.hot` is `undefined`, so the bridge guard fails and the injected block is tree-shaken. `removeDevtoolsOnBuild` continues to strip devtools code as before. +- **Multiple isolated environments:** each environment wires its own `hot.on`/`hot.send` independently; the worker-side global guard prevents an in-process environment from double-handling. +- **In-process (`RunnableDevEnvironment`):** `ServerEventBus` already set the global target, so the worker bridge no-ops and the dev-server-side `tsd:to-client` forwarding to that environment is inert (nothing is listening on its `tsd:to-client`). Existing behavior is unchanged. + +## Implementation risk to validate + +The exact injection point depends on whether Vite wires `import.meta.hot` into the `@tanstack/devtools-event-client` dep module within `workerd` / Nitro's bundled worker graph: + +- **Primary:** inject the bridge into the `event-client` module via a `devtools-vite` transform keyed on module id + non-client environment. This is deterministic and runs before `EventClient` is used. +- **Fallback (if `import.meta.hot` is not wired into the dep there):** inject the bridge into the isolated environment's server entry instead (similar to how the existing `console-pipe-transform` injects into entry files). + +The design is identical either way; only the host module differs. This is validated empirically against the example apps (below). + +## Testing + +- **Reverts** restore the original, passing minimal test suites for `event-bus` and `event-bus-client`. +- **New unit tests** for the dev-server wiring: `tsd:to-server` dispatches `tanstack-dispatch-event` on the global target; a global event triggers `env.hot.send('tsd:to-client', ...)`. +- **Manual validation** against the `examples/react/start-cloudflare` and `examples/react/start-nitro` apps added in #384 (kept specifically for this): emit a server event in each isolated runtime and confirm it appears in the devtools panel, and that devtools/browser events reach server-side `.on()` listeners. This is also where the injection-point risk above is confirmed. + +## Files changed + +### Reverted to `main` +- `packages/event-bus-client/src/plugin.ts` +- `packages/event-bus-client/src/index.ts` +- `packages/event-bus/src/server/server.ts` +- `packages/event-bus/src/client/client.ts` + +### Deleted +- `packages/event-bus-client/src/ring-buffer.ts` +- `packages/event-bus-client/tests/network-transport.test.ts` +- `packages/event-bus-client/tests/ring-buffer.test.ts` +- `packages/event-bus-client/tests/integration.test.ts` +- `packages/event-bus/tests/server.test.ts` bridge/POST routing cases (file kept, cases removed) + +### New / modified (dev-only) +- `packages/devtools-vite/src/plugin.ts` — bridge-injection transform (non-client server environment, `serve` + development) and `configureServer` hot-channel wiring + teardown. +- `packages/devtools-vite/` tests for the new wiring. + +### Kept +- `examples/react/start-cloudflare/*`, `examples/react/start-nitro/*` — for final validation. + +### Removed from PR +- `docs/superpowers/plans/2026-03-12-network-transport-fallback.md` and `docs/superpowers/specs/2026-03-12-network-transport-fallback-design.md` — superseded by this document (generated artifacts, not committed source). + +## Branch / integration + +Work continues on PR #384's branch (`worktree-polished-cuddling-lark`). Latest `main` is merged in first (resolving event-bus conflicts in favor of the reverts described here), then the bridge is implemented, then the example apps are run for confirmation. From 830767c9f26b8ac6b055eaa1a0fa1e6bffbd37b0 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Fri, 19 Jun 2026 16:04:03 +0200 Subject: [PATCH 18/27] docs: implementation plan for native Vite HotChannel runtime bridge --- ...6-06-19-vite-hot-channel-runtime-bridge.md | 708 ++++++++++++++++++ 1 file changed, 708 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-19-vite-hot-channel-runtime-bridge.md diff --git a/docs/superpowers/plans/2026-06-19-vite-hot-channel-runtime-bridge.md b/docs/superpowers/plans/2026-06-19-vite-hot-channel-runtime-bridge.md new file mode 100644 index 00000000..f14fa261 --- /dev/null +++ b/docs/superpowers/plans/2026-06-19-vite-hot-channel-runtime-bridge.md @@ -0,0 +1,708 @@ +# Native Vite HotChannel Runtime Bridge — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace PR #384's heavy in-client network transport with a minimal, dev-only bridge that uses Vite's native `import.meta.hot` HotChannel to carry devtools events between an isolated server runtime (Nitro v3 / Cloudflare workerd) and the Vite dev process. + +**Architecture:** Revert `@tanstack/devtools-event-client` and `@tanstack/devtools-event-bus` to their minimal pre-#384 state. Add a dev-only bridge in `@tanstack/devtools-vite`: a small generated IIFE injected into the `event-client` module in non-client (server) environments, plus `configureServer` wiring that connects each server environment's hot channel to the in-process `ServerEventBus`. We faithfully replicate the existing single-process `EventTarget` dispatch semantics across the wire, so no WebSocket/fetch/reconnect/ring-buffer/dedup is needed. + +**Tech Stack:** TypeScript, Vite 6/7/8 Environment API (`server.environments[name].hot`, `import.meta.hot`), Vitest, pnpm workspaces, Nx. + +## Global Constraints + +- Vite peer range stays `^6.0.0 || ^7.0.0 || ^8.0.0` (already in `packages/devtools-vite/package.json`). Do not add new runtime dependencies. +- All bridge code is **dev + `serve` only** and must be a no-op / tree-shaken in production (`import.meta.hot` undefined, and `removeDevtoolsOnBuild` still applies). +- `@tanstack/devtools-event-client` must end byte-for-byte equal to `origin/main` — zero bundle growth. +- Custom hot-channel event names: `tsd:to-server` (worker → dev server) and `tsd:to-client` (dev server → worker). Use these exact strings on both sides. +- Internal devtools `EventTarget` event names are unchanged: `tanstack-dispatch-event`, `tanstack-connect`, `tanstack-connect-success`, `tanstack-devtools-global`, and per-event `event.type`. +- Follow the existing `generate*Code(...)` + `toContain(...)` test pattern from `packages/devtools-vite/src/virtual-console.ts`. +- Package manager is **pnpm**. Run package tests with `pnpm --filter test:lib run` (vitest). Never use npx. +- Keep `examples/react/start-cloudflare` and `examples/react/start-nitro` — they are the final validation harness. + +--- + +## File Structure + +- `packages/devtools-vite/src/runtime-bridge.ts` — **new.** Pure functions: the generated worker-side bridge IIFE, the injection predicate/transform, and the dev-server hot-channel wiring helper. One responsibility: everything about the runtime bridge. +- `packages/devtools-vite/src/runtime-bridge.test.ts` — **new.** Unit tests for all four exported functions. +- `packages/devtools-vite/src/plugin.ts` — **modified.** Add a new plugin object that runs the injection transform; call the wiring helper inside the existing `custom-server` plugin's `configureServer` after `bus.start()`. +- `packages/event-bus-client/*`, `packages/event-bus/*` — **reverted to `origin/main`.** +- Old #384 docs — **deleted.** + +--- + +## Task 1: Merge `main` and revert the event packages to minimal + +**Files:** +- Merge: all (from `origin/main`) +- Revert to main: `packages/event-bus-client/src/plugin.ts`, `packages/event-bus-client/src/index.ts`, `packages/event-bus/src/server/server.ts`, `packages/event-bus/src/client/client.ts`, `packages/event-bus/tests/server.test.ts`, `packages/event-bus/tests/client.test.ts` +- Delete (PR-only, absent from main): `packages/event-bus-client/src/ring-buffer.ts`, `packages/event-bus-client/tests/ring-buffer.test.ts`, `packages/event-bus-client/tests/network-transport.test.ts`, `packages/event-bus-client/tests/integration.test.ts` + +**Interfaces:** +- Produces: a `main`-equivalent `EventClient` (no network transport, no `eventId`/`source`, no placeholders) and a `main`-equivalent `ServerEventBus` (no `?bridge=server` routing). Later tasks rely on `EventClient.getGlobalTarget()` reading `globalThis.__TANSTACK_EVENT_TARGET__` first, and on `ServerEventBus` listening for `tanstack-dispatch-event` on that global target. + +- [ ] **Step 1: Start the merge (run from the worktree `F:/projects/tanstack/devtools-pr384`)** + +```bash +git fetch origin main +git merge origin/main --no-commit --no-ff +``` +Expected: conflicts reported in `package.json`, `packages/event-bus/src/client/client.ts`, `pnpm-lock.yaml` (the only files changed on both sides). + +- [ ] **Step 2: Force the event packages to `origin/main`'s exact state** + +```bash +git checkout origin/main -- packages/event-bus packages/event-bus-client +``` +This resolves the `client.ts` conflict (takes main) and reverts every PR-modified file in both packages that exists in main. + +- [ ] **Step 3: Delete the PR-only files that `checkout` left behind** + +```bash +git rm -f packages/event-bus-client/src/ring-buffer.ts \ + packages/event-bus-client/tests/ring-buffer.test.ts \ + packages/event-bus-client/tests/network-transport.test.ts \ + packages/event-bus-client/tests/integration.test.ts +``` +Expected: each removed. If a file is already gone, confirm with `git status` that no `network-transport`/`ring-buffer`/`integration` files remain under `packages/event-bus-client`. + +- [ ] **Step 4: Resolve the remaining conflicts (`package.json`, `pnpm-lock.yaml`)** + +For root `package.json`, take main's version (it has the newer version bumps), then re-apply any PR-only example deps only if main dropped them — main did not touch examples, so main's `package.json` is correct: +```bash +git checkout origin/main -- package.json +``` +Regenerate the lockfile from the merged manifests: +```bash +pnpm install +git add pnpm-lock.yaml +``` +Expected: `pnpm install` completes; lockfile no longer conflicted. + +- [ ] **Step 5: Verify no event-client/event-bus diff against main remains** + +```bash +git diff origin/main -- packages/event-bus-client packages/event-bus | head +``` +Expected: **empty output** (both packages identical to main). + +- [ ] **Step 6: Run the reverted packages' test suites** + +```bash +pnpm --filter @tanstack/devtools-event-bus test:lib run +pnpm --filter @tanstack/devtools-event-client test:lib run +``` +Expected: both green (these are main's passing suites). + +- [ ] **Step 7: Commit the merge** + +```bash +git add -A +git commit --no-edit +``` +Expected: merge commit created. `git log --oneline -1` shows the merge. + +--- + +## Task 2: Generate the worker-side bridge code + +**Files:** +- Create: `packages/devtools-vite/src/runtime-bridge.ts` +- Test: `packages/devtools-vite/src/runtime-bridge.test.ts` + +**Interfaces:** +- Produces: `export function generateRuntimeBridgeCode(): string` — returns a self-contained IIFE string (no imports) that, when evaluated in a module where Vite has wired `import.meta.hot`, sets `globalThis.__TANSTACK_EVENT_TARGET__` and bridges it to the hot channel. Consumed by Task 3. + +- [ ] **Step 1: Write the failing test** + +```ts +// packages/devtools-vite/src/runtime-bridge.test.ts +import { describe, expect, test } from 'vitest' +import { generateRuntimeBridgeCode } from './runtime-bridge' + +describe('generateRuntimeBridgeCode', () => { + test('guards on import.meta.hot and an unset global target', () => { + const code = generateRuntimeBridgeCode() + expect(code).toContain('import.meta.hot') + expect(code).toContain('globalThis.__TANSTACK_EVENT_TARGET__') + expect(code).toContain('!globalThis.__TANSTACK_EVENT_TARGET__') + }) + + test('completes the connect handshake locally', () => { + const code = generateRuntimeBridgeCode() + expect(code).toContain("'tanstack-connect'") + expect(code).toContain("'tanstack-connect-success'") + }) + + test('forwards dispatched events to the dev server', () => { + const code = generateRuntimeBridgeCode() + expect(code).toContain("'tanstack-dispatch-event'") + expect(code).toContain("import.meta.hot.send('tsd:to-server'") + }) + + test('receives dev-server events and redispatches them locally', () => { + const code = generateRuntimeBridgeCode() + expect(code).toContain("import.meta.hot.on('tsd:to-client'") + expect(code).toContain("'tanstack-devtools-global'") + }) + + test('has no external imports', () => { + expect(generateRuntimeBridgeCode()).not.toContain('import ') + }) +}) +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `pnpm --filter @tanstack/devtools-vite test:lib run src/runtime-bridge.test.ts` +Expected: FAIL — `generateRuntimeBridgeCode` is not exported / module not found. + +- [ ] **Step 3: Implement `generateRuntimeBridgeCode`** + +```ts +// packages/devtools-vite/src/runtime-bridge.ts + +/** + * Worker-side bridge for isolated server runtimes (Nitro v3 worker, Cloudflare workerd). + * + * Injected into the `@tanstack/devtools-event-client` module ONLY in non-client + * (server) environments during dev. At module-eval time it gives the isolated + * runtime a real `globalThis.__TANSTACK_EVENT_TARGET__` (so the unchanged + * `EventClient` uses it instead of a throwaway target) and bridges that target to + * the Vite dev process over the framework plugin's existing HMR HotChannel. + * + * Guards: + * - `import.meta.hot` falsy (production / no HMR) -> tree-shaken / no-op. + * - global target already set (in-process RunnableDevEnvironment, where + * ServerEventBus lives) -> no-op, so existing behavior is unchanged. + * + * The bridge replicates ServerEventBus's in-process responsibilities so the + * EventClient protocol is identical across the wire (see design doc). + */ +export function generateRuntimeBridgeCode(): string { + return ` +;(function __tsdRuntimeBridge() { + if (typeof import.meta === 'undefined' || !import.meta.hot) return; + if (globalThis.__TANSTACK_EVENT_TARGET__) return; + var target = new EventTarget(); + globalThis.__TANSTACK_EVENT_TARGET__ = target; + + // Complete EventClient's connect handshake locally so queued events flush. + target.addEventListener('tanstack-connect', function () { + target.dispatchEvent(new CustomEvent('tanstack-connect-success')); + }); + + // Worker -> Vite dev server. + target.addEventListener('tanstack-dispatch-event', function (e) { + import.meta.hot.send('tsd:to-server', e.detail); + }); + + // Vite dev server -> worker listeners. + import.meta.hot.on('tsd:to-client', function (event) { + target.dispatchEvent(new CustomEvent(event.type, { detail: event })); + target.dispatchEvent(new CustomEvent('tanstack-devtools-global', { detail: event })); + }); +})(); +` +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `pnpm --filter @tanstack/devtools-vite test:lib run src/runtime-bridge.test.ts` +Expected: PASS (5 tests). + +- [ ] **Step 5: Commit** + +```bash +git add packages/devtools-vite/src/runtime-bridge.ts packages/devtools-vite/src/runtime-bridge.test.ts +git commit -m "feat(devtools-vite): generate worker-side runtime bridge code" +``` + +--- + +## Task 3: Inject the bridge into the event-client module in server environments + +**Files:** +- Modify: `packages/devtools-vite/src/runtime-bridge.ts` +- Test: `packages/devtools-vite/src/runtime-bridge.test.ts` + +**Interfaces:** +- Consumes: `generateRuntimeBridgeCode()` from Task 2. +- Produces: `export function injectRuntimeBridge(code: string, id: string, environmentName: string | undefined): string | undefined` — returns `code` with the bridge appended when the module is the event-client entry in a non-client environment; otherwise `undefined` (Vite convention for "no transform"). Consumed by Task 5's plugin wiring. + +- [ ] **Step 1: Write the failing test (append to `runtime-bridge.test.ts`)** + +```ts +import { injectRuntimeBridge } from './runtime-bridge' + +describe('injectRuntimeBridge', () => { + const EVENT_CLIENT_ID = + '/repo/node_modules/@tanstack/devtools-event-client/dist/esm/index.js' + const EVENT_CLIENT_CODE = 'class EventClient { emit() {} }' + + test('injects into the event-client module in a server environment', () => { + const out = injectRuntimeBridge(EVENT_CLIENT_CODE, EVENT_CLIENT_ID, 'ssr') + expect(out).toBeDefined() + expect(out).toContain(EVENT_CLIENT_CODE) + expect(out).toContain('__tsdRuntimeBridge') + }) + + test('matches the workspace source path too', () => { + const id = '/repo/packages/event-bus-client/src/plugin.ts' + expect(injectRuntimeBridge(EVENT_CLIENT_CODE, id, 'ssr')).toBeDefined() + }) + + test('skips the client environment', () => { + expect( + injectRuntimeBridge(EVENT_CLIENT_CODE, EVENT_CLIENT_ID, 'client'), + ).toBeUndefined() + }) + + test('skips when environment name is unknown (pre-Environment-API)', () => { + expect( + injectRuntimeBridge(EVENT_CLIENT_CODE, EVENT_CLIENT_ID, undefined), + ).toBeUndefined() + }) + + test('skips modules that are not the event-client', () => { + expect( + injectRuntimeBridge('export const x = 1', '/repo/src/app.ts', 'ssr'), + ).toBeUndefined() + }) + + test('skips event-client-pathed modules that lack the EventClient class', () => { + const id = '/repo/node_modules/@tanstack/devtools-event-client/dist/esm/foo.js' + expect(injectRuntimeBridge('export const y = 2', id, 'ssr')).toBeUndefined() + }) +}) +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `pnpm --filter @tanstack/devtools-vite test:lib run src/runtime-bridge.test.ts` +Expected: FAIL — `injectRuntimeBridge` is not exported. + +- [ ] **Step 3: Implement `injectRuntimeBridge` (add to `runtime-bridge.ts`)** + +```ts +function isEventClientModule(id: string, code: string): boolean { + const isEventClientPath = + id.includes('devtools-event-client') || id.includes('event-bus-client') + // Only the module that actually defines the class — avoids re-export shims + // and unrelated files inside the package. + return isEventClientPath && code.includes('EventClient') +} + +export function injectRuntimeBridge( + code: string, + id: string, + environmentName: string | undefined, +): string | undefined { + // Only isolated server environments need the bridge. The client environment + // has `window`; the in-process RunnableDevEnvironment is handled by the + // runtime global guard inside the injected code. + if (!environmentName || environmentName === 'client') return undefined + if (!isEventClientModule(id, code)) return undefined + return `${code}\n${generateRuntimeBridgeCode()}` +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `pnpm --filter @tanstack/devtools-vite test:lib run src/runtime-bridge.test.ts` +Expected: PASS (all `generateRuntimeBridgeCode` + `injectRuntimeBridge` tests). + +- [ ] **Step 5: Commit** + +```bash +git add packages/devtools-vite/src/runtime-bridge.ts packages/devtools-vite/src/runtime-bridge.test.ts +git commit -m "feat(devtools-vite): inject runtime bridge into event-client for server envs" +``` + +--- + +## Task 4: Wire the dev-server hot channels + +**Files:** +- Modify: `packages/devtools-vite/src/runtime-bridge.ts` +- Test: `packages/devtools-vite/src/runtime-bridge.test.ts` + +**Interfaces:** +- Produces: `export function wireRuntimeBridgeChannels(server: { environments: Record, }, getTarget: () => EventTarget | null | undefined): () => void` — for every non-`client` environment with a hot channel, registers `hot.on('tsd:to-server', ...)` (dispatching `tanstack-dispatch-event` on the target) and forwards the target's `tanstack-devtools-global` events to `hot.send('tsd:to-client', ...)`. Returns a teardown function that removes the forward listeners. Consumed by Task 5. + +- [ ] **Step 1: Write the failing test (append to `runtime-bridge.test.ts`)** + +```ts +import { wireRuntimeBridgeChannels } from './runtime-bridge' + +describe('wireRuntimeBridgeChannels', () => { + function makeEnv() { + const handlers: Record = {} + const sent: Array<{ event: string; data: any }> = [] + return { + hot: { + on: (event: string, cb: Function) => (handlers[event] = cb), + send: (event: string, data: any) => sent.push({ event, data }), + }, + __handlers: handlers, + __sent: sent, + } + } + + test('worker event -> dispatches tanstack-dispatch-event on the target', () => { + const target = new EventTarget() + const ssr = makeEnv() + const server = { environments: { client: { hot: null }, ssr } } + const received: Array = [] + target.addEventListener('tanstack-dispatch-event', (e) => + received.push((e as CustomEvent).detail), + ) + + wireRuntimeBridgeChannels(server as any, () => target) + const evt = { type: 'q:foo', payload: 1 } + ssr.__handlers['tsd:to-server'](evt) + + expect(received).toEqual([evt]) + }) + + test('target global event -> forwarded to the env via tsd:to-client', () => { + const target = new EventTarget() + const ssr = makeEnv() + const server = { environments: { ssr } } + + wireRuntimeBridgeChannels(server as any, () => target) + const evt = { type: 'q:bar', payload: 2 } + target.dispatchEvent(new CustomEvent('tanstack-devtools-global', { detail: evt })) + + expect(ssr.__sent).toEqual([{ event: 'tsd:to-client', data: evt }]) + }) + + test('skips the client environment', () => { + const client = makeEnv() + const server = { environments: { client } } + wireRuntimeBridgeChannels(server as any, () => new EventTarget()) + expect(client.__handlers['tsd:to-server']).toBeUndefined() + }) + + test('teardown stops forwarding', () => { + const target = new EventTarget() + const ssr = makeEnv() + const server = { environments: { ssr } } + const teardown = wireRuntimeBridgeChannels(server as any, () => target) + teardown() + target.dispatchEvent( + new CustomEvent('tanstack-devtools-global', { detail: { type: 'x' } }), + ) + expect(ssr.__sent).toEqual([]) + }) +}) +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `pnpm --filter @tanstack/devtools-vite test:lib run src/runtime-bridge.test.ts` +Expected: FAIL — `wireRuntimeBridgeChannels` is not exported. + +- [ ] **Step 3: Implement `wireRuntimeBridgeChannels` (add to `runtime-bridge.ts`)** + +```ts +interface BridgeHotChannel { + on?: (event: string, cb: (data: any) => void) => void + send?: (event: string, data: any) => void +} +interface BridgeServerLike { + environments: Record +} + +export function wireRuntimeBridgeChannels( + server: BridgeServerLike, + getTarget: () => EventTarget | null | undefined, +): () => void { + const forwarders: Array<() => void> = [] + + for (const [name, env] of Object.entries(server.environments)) { + if (name === 'client') continue + const hot = env?.hot + if (!hot || typeof hot.on !== 'function' || typeof hot.send !== 'function') { + continue + } + + // Worker -> ServerEventBus (broadcasts to browser + in-process listeners). + hot.on('tsd:to-server', (event: any) => { + getTarget()?.dispatchEvent( + new CustomEvent('tanstack-dispatch-event', { detail: event }), + ) + }) + + // ServerEventBus output -> worker listeners. + const forward = (e: Event) => + hot.send!('tsd:to-client', (e as CustomEvent).detail) + const target = getTarget() + target?.addEventListener('tanstack-devtools-global', forward) + forwarders.push(() => + getTarget()?.removeEventListener('tanstack-devtools-global', forward), + ) + } + + return () => forwarders.forEach((off) => off()) +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `pnpm --filter @tanstack/devtools-vite test:lib run src/runtime-bridge.test.ts` +Expected: PASS (all runtime-bridge tests). + +- [ ] **Step 5: Commit** + +```bash +git add packages/devtools-vite/src/runtime-bridge.ts packages/devtools-vite/src/runtime-bridge.test.ts +git commit -m "feat(devtools-vite): wire dev-server hot channels to ServerEventBus" +``` + +--- + +## Task 5: Hook the bridge into the `devtools-vite` plugin + +**Files:** +- Modify: `packages/devtools-vite/src/plugin.ts` (add import; add an injection plugin object; call wiring in the `custom-server` `configureServer`) +- Test: `packages/devtools-vite/tests/index.test.ts` + +**Interfaces:** +- Consumes: `injectRuntimeBridge`, `wireRuntimeBridgeChannels` from Tasks 3–4. +- Produces: a new plugin named `@tanstack/devtools:runtime-bridge` in the array returned by `devtools(...)`, and a teardown wired to server close. + +- [ ] **Step 1: Write the failing test (append to `packages/devtools-vite/tests/index.test.ts`)** + +First open `packages/devtools-vite/tests/index.test.ts` and match its existing import/style. Add: + +```ts +import { devtools } from '../src/plugin' + +describe('runtime-bridge plugin', () => { + test('devtools() includes the runtime-bridge plugin', () => { + const plugins = devtools() + const names = plugins.map((p) => p.name) + expect(names).toContain('@tanstack/devtools:runtime-bridge') + }) + + test('runtime-bridge transform injects in a server environment', () => { + const plugin = devtools().find( + (p) => p.name === '@tanstack/devtools:runtime-bridge', + )! + // emulate Vite's per-environment plugin context + const ctx = { environment: { name: 'ssr' } } + const handler = + typeof plugin.transform === 'function' + ? plugin.transform + : (plugin.transform as any).handler + const out = handler.call( + ctx, + 'class EventClient {}', + '/x/node_modules/@tanstack/devtools-event-client/dist/esm/index.js', + ) + expect(out).toBeDefined() + expect(String(out)).toContain('__tsdRuntimeBridge') + }) + + test('runtime-bridge transform skips the client environment', () => { + const plugin = devtools().find( + (p) => p.name === '@tanstack/devtools:runtime-bridge', + )! + const ctx = { environment: { name: 'client' } } + const handler = + typeof plugin.transform === 'function' + ? plugin.transform + : (plugin.transform as any).handler + const out = handler.call( + ctx, + 'class EventClient {}', + '/x/node_modules/@tanstack/devtools-event-client/dist/esm/index.js', + ) + expect(out).toBeUndefined() + }) +}) +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `pnpm --filter @tanstack/devtools-vite test:lib run tests/index.test.ts` +Expected: FAIL — no plugin named `@tanstack/devtools:runtime-bridge`. + +- [ ] **Step 3: Add the import at the top of `packages/devtools-vite/src/plugin.ts`** + +```ts +import { + injectRuntimeBridge, + wireRuntimeBridgeChannels, +} from './runtime-bridge' +``` + +- [ ] **Step 4: Add the injection plugin object to the array returned by `devtools(...)`** + +Insert as a new object in the returned array (e.g. directly after the `@tanstack/devtools:connection-injection` plugin). `this.environment` is provided by Vite 6+ in the transform hook: + +```ts +{ + name: '@tanstack/devtools:runtime-bridge', + apply(config, { command }) { + return config.mode === 'development' && command === 'serve' + }, + transform(code, id) { + if (id.includes('?')) return + return injectRuntimeBridge( + code, + id, + (this as any).environment?.name as string | undefined, + ) + }, +}, +``` + +- [ ] **Step 5: Wire the hot channels in the existing `custom-server` `configureServer`** + +In the `@tanstack/devtools:custom-server` plugin's `configureServer(server)`, immediately after `devtoolsPort = await bus.start()` (inside the `if (serverBusEnabled)` block), add: + +```ts +const teardownBridge = wireRuntimeBridgeChannels( + server as unknown as Parameters[0], + () => globalThis.__TANSTACK_EVENT_TARGET__, +) +server.httpServer?.on('close', teardownBridge) +``` + +If `globalThis.__TANSTACK_EVENT_TARGET__` is not already declared in this file's scope, rely on the existing global declaration in `@tanstack/devtools-event-bus/server` (already imported as `ServerEventBus`); if TypeScript complains, add at the top of the file: + +```ts +declare global { + var __TANSTACK_EVENT_TARGET__: EventTarget | null | undefined +} +``` + +- [ ] **Step 6: Run the test to verify it passes** + +Run: `pnpm --filter @tanstack/devtools-vite test:lib run tests/index.test.ts` +Expected: PASS. + +- [ ] **Step 7: Typecheck and full package test** + +```bash +pnpm --filter @tanstack/devtools-vite test:types +pnpm --filter @tanstack/devtools-vite test:lib run +``` +Expected: both green. + +- [ ] **Step 8: Commit** + +```bash +git add packages/devtools-vite/src/plugin.ts packages/devtools-vite/tests/index.test.ts +git commit -m "feat(devtools-vite): hook runtime bridge into plugin and dev server" +``` + +--- + +## Task 6: Validate against the Cloudflare and Nitro example apps + +**Files:** +- Use: `examples/react/start-cloudflare`, `examples/react/start-nitro` (no code changes expected) + +**Interfaces:** +- Consumes: the full bridge from Tasks 1–5. This is the empirical check of the spec's one implementation risk (whether `import.meta.hot` is wired into the injected `event-client` module in workerd / Nitro). + +- [ ] **Step 1: Build the workspace packages the examples consume** + +```bash +pnpm --filter @tanstack/devtools-event-client --filter @tanstack/devtools-event-bus --filter @tanstack/devtools-vite build +``` +Expected: all build. (Examples consume built `dist`, so the injected-module match in Task 3 targets `dist/esm/*` — confirm the built file contains `EventClient`.) + +- [ ] **Step 2: Run the Nitro example and emit a server event** + +```bash +pnpm --filter ./examples/react/start-nitro dev +``` +In the browser, open devtools, trigger a server-emitted event (the example's Server Events panel / a server action). Confirm the event appears in the devtools panel. Enable `eventBusConfig.debug` if needed to trace `tsd:to-server` arriving on the dev server. +Expected: server events appear in devtools. + +- [ ] **Step 3: Run the Cloudflare example and emit a server event** + +```bash +pnpm --filter ./examples/react/start-cloudflare dev +``` +Repeat the verification. +Expected: server events appear in devtools. + +- [ ] **Step 4: If events do NOT arrive — apply the documented fallback injection point** + +If debug shows the worker never sends `tsd:to-server` (i.e. `import.meta.hot` was `undefined` in the injected `event-client` dep module), switch the injection target from the dep module to the isolated environment's **server entry**, reusing the entry-detection approach already in `console-pipe-transform`: +- In `injectRuntimeBridge`, replace `isEventClientModule(id, code)` with an entry check: inject when `environmentName !== 'client'` and the module is the environment's server entry (heuristic: `code` contains the framework server handler markers used by `console-pipe-transform`'s `isRootEntry`, or matches the configured SSR entry id). Keep all generated code and the dev-server wiring identical. +- Re-run Steps 2–3. +Expected after fallback: server events appear in devtools in both runtimes. + +- [ ] **Step 5: Confirm the reverse direction and production no-op** + +- Trigger a devtools/browser-originated event that a server-side `.on()` listener handles; confirm the worker listener fires. +- Run a production build of one example (`pnpm --filter ./examples/react/start-nitro build`) and confirm no `__tsdRuntimeBridge` / `tsd:to-server` string remains in the server bundle. +Expected: reverse direction works; bridge stripped from production. + +- [ ] **Step 6: Commit any fallback changes (if Step 4 was needed)** + +```bash +git add packages/devtools-vite/src/runtime-bridge.ts packages/devtools-vite/src/runtime-bridge.test.ts +git commit -m "fix(devtools-vite): inject runtime bridge at server entry for isolated runtimes" +``` + +--- + +## Task 7: Remove superseded #384 docs and finalize the PR + +**Files:** +- Delete: `docs/superpowers/plans/2026-03-12-network-transport-fallback.md`, `docs/superpowers/specs/2026-03-12-network-transport-fallback-design.md` + +- [ ] **Step 1: Remove the old #384 design/plan docs** + +```bash +git rm docs/superpowers/plans/2026-03-12-network-transport-fallback.md \ + docs/superpowers/specs/2026-03-12-network-transport-fallback-design.md +``` + +- [ ] **Step 2: Run the full affected test + typecheck sweep** + +```bash +pnpm --filter @tanstack/devtools-event-bus test:lib run +pnpm --filter @tanstack/devtools-event-client test:lib run +pnpm --filter @tanstack/devtools-vite test:lib run +pnpm --filter @tanstack/devtools-vite test:types +``` +Expected: all green. + +- [ ] **Step 3: Commit and push to update PR #384** + +```bash +git add -A +git commit -m "docs: remove superseded network-transport docs" +git push +``` +Expected: PR #384 updated. Report the PR URL. + +- [ ] **Step 4: (Decision point) Confirm with the user whether the new `docs/superpowers/` design+plan should stay in the PR or be stripped for a code-only PR** before the final push, per their standing "no generated artifacts in PRs" preference. + +--- + +## Self-Review + +**Spec coverage:** +- Revert `event-bus-client` + `event-bus` → Task 1. ✓ +- Worker-side bridge (generated, guarded) → Tasks 2. ✓ +- Injection into event-client in server envs → Task 3. ✓ +- Dev-server `hot.on`/`hot.send` wiring + teardown → Task 4. ✓ +- Plugin integration → Task 5. ✓ +- Edge cases (in-process no-op via global guard; production tree-shake) → covered by generated guards (Task 2) + validated Task 6 Step 5. ✓ +- Implementation risk (injection point) → Task 6 Steps 4. ✓ +- Keep example apps → Task 6 (used, not deleted). ✓ +- Remove old #384 docs → Task 7. ✓ + +**Placeholder scan:** No TBD/TODO; every code step shows complete code; the fallback (Task 6 Step 4) describes the concrete swap rather than deferring it. + +**Type/name consistency:** `generateRuntimeBridgeCode` / `injectRuntimeBridge` / `wireRuntimeBridgeChannels` used identically across Tasks 2–5. Hot-channel event names `tsd:to-server` / `tsd:to-client` and target event names (`tanstack-dispatch-event`, `tanstack-connect`, `tanstack-connect-success`, `tanstack-devtools-global`) are consistent on both worker and dev-server sides. From e21593521f7d0f5de66ea10b47368471edc24894 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Fri, 19 Jun 2026 17:02:36 +0200 Subject: [PATCH 19/27] feat(devtools-vite): generate worker-side runtime bridge code --- .../devtools-vite/src/runtime-bridge.test.ts | 33 ++++++++++++++ packages/devtools-vite/src/runtime-bridge.ts | 44 +++++++++++++++++++ 2 files changed, 77 insertions(+) create mode 100644 packages/devtools-vite/src/runtime-bridge.test.ts create mode 100644 packages/devtools-vite/src/runtime-bridge.ts diff --git a/packages/devtools-vite/src/runtime-bridge.test.ts b/packages/devtools-vite/src/runtime-bridge.test.ts new file mode 100644 index 00000000..4b9344a1 --- /dev/null +++ b/packages/devtools-vite/src/runtime-bridge.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, test } from 'vitest' +import { generateRuntimeBridgeCode } from './runtime-bridge' + +describe('generateRuntimeBridgeCode', () => { + test('guards on import.meta.hot and an unset global target', () => { + const code = generateRuntimeBridgeCode() + expect(code).toContain('import.meta.hot') + expect(code).toContain('globalThis.__TANSTACK_EVENT_TARGET__') + expect(code).toContain('!globalThis.__TANSTACK_EVENT_TARGET__') + }) + + test('completes the connect handshake locally', () => { + const code = generateRuntimeBridgeCode() + expect(code).toContain("'tanstack-connect'") + expect(code).toContain("'tanstack-connect-success'") + }) + + test('forwards dispatched events to the dev server', () => { + const code = generateRuntimeBridgeCode() + expect(code).toContain("'tanstack-dispatch-event'") + expect(code).toContain("import.meta.hot.send('tsd:to-server'") + }) + + test('receives dev-server events and redispatches them locally', () => { + const code = generateRuntimeBridgeCode() + expect(code).toContain("import.meta.hot.on('tsd:to-client'") + expect(code).toContain("'tanstack-devtools-global'") + }) + + test('has no external imports', () => { + expect(generateRuntimeBridgeCode()).not.toContain('import ') + }) +}) diff --git a/packages/devtools-vite/src/runtime-bridge.ts b/packages/devtools-vite/src/runtime-bridge.ts new file mode 100644 index 00000000..0be50925 --- /dev/null +++ b/packages/devtools-vite/src/runtime-bridge.ts @@ -0,0 +1,44 @@ +/** + * Worker-side bridge for isolated server runtimes (Nitro v3 worker, Cloudflare workerd). + * + * Injected into the `@tanstack/devtools-event-client` module ONLY in non-client + * (server) environments during dev. At module-eval time it gives the isolated + * runtime a real `globalThis.__TANSTACK_EVENT_TARGET__` (so the unchanged + * `EventClient` uses it instead of a throwaway target) and bridges that target to + * the Vite dev process over the framework plugin's existing HMR HotChannel. + * + * Guards: + * - `import.meta.hot` falsy (production / no HMR) -> tree-shaken / no-op. + * - global target already set (in-process RunnableDevEnvironment, where + * ServerEventBus lives) -> no-op, so existing behavior is unchanged. + * + * The bridge replicates ServerEventBus's in-process responsibilities so the + * EventClient protocol is identical across the wire (see design doc). + */ +export function generateRuntimeBridgeCode(): string { + return ` +;(function __tsdRuntimeBridge() { + if (typeof import.meta === 'undefined' || !import.meta.hot) return; + if (!globalThis.__TANSTACK_EVENT_TARGET__) { + var target = new EventTarget(); + globalThis.__TANSTACK_EVENT_TARGET__ = target; + + // Complete EventClient's connect handshake locally so queued events flush. + target.addEventListener('tanstack-connect', function () { + target.dispatchEvent(new CustomEvent('tanstack-connect-success')); + }); + + // Worker -> Vite dev server. + target.addEventListener('tanstack-dispatch-event', function (e) { + import.meta.hot.send('tsd:to-server', e.detail); + }); + + // Vite dev server -> worker listeners. + import.meta.hot.on('tsd:to-client', function (event) { + target.dispatchEvent(new CustomEvent(event.type, { detail: event })); + target.dispatchEvent(new CustomEvent('tanstack-devtools-global', { detail: event })); + }); + } +})(); +` +} From 9d9e9fee3cd63324e070e8d6be54ad6afd03ce70 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Fri, 19 Jun 2026 17:18:17 +0200 Subject: [PATCH 20/27] feat(devtools-vite): inject runtime bridge into event-client for server envs --- .../devtools-vite/src/runtime-bridge.test.ts | 43 ++++++++++++++++++- packages/devtools-vite/src/runtime-bridge.ts | 21 +++++++++ 2 files changed, 63 insertions(+), 1 deletion(-) diff --git a/packages/devtools-vite/src/runtime-bridge.test.ts b/packages/devtools-vite/src/runtime-bridge.test.ts index 4b9344a1..5c1caf52 100644 --- a/packages/devtools-vite/src/runtime-bridge.test.ts +++ b/packages/devtools-vite/src/runtime-bridge.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from 'vitest' -import { generateRuntimeBridgeCode } from './runtime-bridge' +import { generateRuntimeBridgeCode, injectRuntimeBridge } from './runtime-bridge' describe('generateRuntimeBridgeCode', () => { test('guards on import.meta.hot and an unset global target', () => { @@ -31,3 +31,44 @@ describe('generateRuntimeBridgeCode', () => { expect(generateRuntimeBridgeCode()).not.toContain('import ') }) }) + +describe('injectRuntimeBridge', () => { + const EVENT_CLIENT_ID = + '/repo/node_modules/@tanstack/devtools-event-client/dist/esm/index.js' + const EVENT_CLIENT_CODE = 'class EventClient { emit() {} }' + + test('injects into the event-client module in a server environment', () => { + const out = injectRuntimeBridge(EVENT_CLIENT_CODE, EVENT_CLIENT_ID, 'ssr') + expect(out).toBeDefined() + expect(out).toContain(EVENT_CLIENT_CODE) + expect(out).toContain('__tsdRuntimeBridge') + }) + + test('matches the workspace source path too', () => { + const id = '/repo/packages/event-bus-client/src/plugin.ts' + expect(injectRuntimeBridge(EVENT_CLIENT_CODE, id, 'ssr')).toBeDefined() + }) + + test('skips the client environment', () => { + expect( + injectRuntimeBridge(EVENT_CLIENT_CODE, EVENT_CLIENT_ID, 'client'), + ).toBeUndefined() + }) + + test('skips when environment name is unknown (pre-Environment-API)', () => { + expect( + injectRuntimeBridge(EVENT_CLIENT_CODE, EVENT_CLIENT_ID, undefined), + ).toBeUndefined() + }) + + test('skips modules that are not the event-client', () => { + expect( + injectRuntimeBridge('export const x = 1', '/repo/src/app.ts', 'ssr'), + ).toBeUndefined() + }) + + test('skips event-client-pathed modules that lack the EventClient class', () => { + const id = '/repo/node_modules/@tanstack/devtools-event-client/dist/esm/foo.js' + expect(injectRuntimeBridge('export const y = 2', id, 'ssr')).toBeUndefined() + }) +}) diff --git a/packages/devtools-vite/src/runtime-bridge.ts b/packages/devtools-vite/src/runtime-bridge.ts index 0be50925..18e4effc 100644 --- a/packages/devtools-vite/src/runtime-bridge.ts +++ b/packages/devtools-vite/src/runtime-bridge.ts @@ -42,3 +42,24 @@ export function generateRuntimeBridgeCode(): string { })(); ` } + +function isEventClientModule(id: string, code: string): boolean { + const isEventClientPath = + id.includes('devtools-event-client') || id.includes('event-bus-client') + // Only the module that actually defines the class — avoids re-export shims + // and unrelated files inside the package. + return isEventClientPath && code.includes('EventClient') +} + +export function injectRuntimeBridge( + code: string, + id: string, + environmentName: string | undefined, +): string | undefined { + // Only isolated server environments need the bridge. The client environment + // has `window`; the in-process RunnableDevEnvironment is handled by the + // runtime global guard inside the injected code. + if (!environmentName || environmentName === 'client') return undefined + if (!isEventClientModule(id, code)) return undefined + return `${code}\n${generateRuntimeBridgeCode()}` +} From 9571300eaf4358611344d831a3877c18a52d8322 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Fri, 19 Jun 2026 17:20:16 +0200 Subject: [PATCH 21/27] feat(devtools-vite): wire dev-server hot channels to ServerEventBus --- .../devtools-vite/src/runtime-bridge.test.ts | 68 ++++++++++++++++++- packages/devtools-vite/src/runtime-bridge.ts | 41 +++++++++++ 2 files changed, 108 insertions(+), 1 deletion(-) diff --git a/packages/devtools-vite/src/runtime-bridge.test.ts b/packages/devtools-vite/src/runtime-bridge.test.ts index 5c1caf52..16003ec9 100644 --- a/packages/devtools-vite/src/runtime-bridge.test.ts +++ b/packages/devtools-vite/src/runtime-bridge.test.ts @@ -1,5 +1,9 @@ import { describe, expect, test } from 'vitest' -import { generateRuntimeBridgeCode, injectRuntimeBridge } from './runtime-bridge' +import { + generateRuntimeBridgeCode, + injectRuntimeBridge, + wireRuntimeBridgeChannels, +} from './runtime-bridge' describe('generateRuntimeBridgeCode', () => { test('guards on import.meta.hot and an unset global target', () => { @@ -72,3 +76,65 @@ describe('injectRuntimeBridge', () => { expect(injectRuntimeBridge('export const y = 2', id, 'ssr')).toBeUndefined() }) }) + +describe('wireRuntimeBridgeChannels', () => { + function makeEnv() { + const handlers: Record = {} + const sent: Array<{ event: string; data: any }> = [] + return { + hot: { + on: (event: string, cb: Function) => (handlers[event] = cb), + send: (event: string, data: any) => sent.push({ event, data }), + }, + __handlers: handlers, + __sent: sent, + } + } + + test('worker event -> dispatches tanstack-dispatch-event on the target', () => { + const target = new EventTarget() + const ssr = makeEnv() + const server = { environments: { client: { hot: null }, ssr } } + const received: Array = [] + target.addEventListener('tanstack-dispatch-event', (e) => + received.push((e as CustomEvent).detail), + ) + + wireRuntimeBridgeChannels(server as any, () => target) + const evt = { type: 'q:foo', payload: 1 } + ssr.__handlers['tsd:to-server'](evt) + + expect(received).toEqual([evt]) + }) + + test('target global event -> forwarded to the env via tsd:to-client', () => { + const target = new EventTarget() + const ssr = makeEnv() + const server = { environments: { ssr } } + + wireRuntimeBridgeChannels(server as any, () => target) + const evt = { type: 'q:bar', payload: 2 } + target.dispatchEvent(new CustomEvent('tanstack-devtools-global', { detail: evt })) + + expect(ssr.__sent).toEqual([{ event: 'tsd:to-client', data: evt }]) + }) + + test('skips the client environment', () => { + const client = makeEnv() + const server = { environments: { client } } + wireRuntimeBridgeChannels(server as any, () => new EventTarget()) + expect(client.__handlers['tsd:to-server']).toBeUndefined() + }) + + test('teardown stops forwarding', () => { + const target = new EventTarget() + const ssr = makeEnv() + const server = { environments: { ssr } } + const teardown = wireRuntimeBridgeChannels(server as any, () => target) + teardown() + target.dispatchEvent( + new CustomEvent('tanstack-devtools-global', { detail: { type: 'x' } }), + ) + expect(ssr.__sent).toEqual([]) + }) +}) diff --git a/packages/devtools-vite/src/runtime-bridge.ts b/packages/devtools-vite/src/runtime-bridge.ts index 18e4effc..ae02ac36 100644 --- a/packages/devtools-vite/src/runtime-bridge.ts +++ b/packages/devtools-vite/src/runtime-bridge.ts @@ -63,3 +63,44 @@ export function injectRuntimeBridge( if (!isEventClientModule(id, code)) return undefined return `${code}\n${generateRuntimeBridgeCode()}` } + +interface BridgeHotChannel { + on?: (event: string, cb: (data: any) => void) => void + send?: (event: string, data: any) => void +} +interface BridgeServerLike { + environments: Record +} + +export function wireRuntimeBridgeChannels( + server: BridgeServerLike, + getTarget: () => EventTarget | null | undefined, +): () => void { + const forwarders: Array<() => void> = [] + + for (const [name, env] of Object.entries(server.environments)) { + if (name === 'client') continue + const hot = env?.hot + if (!hot || typeof hot.on !== 'function' || typeof hot.send !== 'function') { + continue + } + + // Worker -> ServerEventBus (broadcasts to browser + in-process listeners). + hot.on('tsd:to-server', (event: any) => { + getTarget()?.dispatchEvent( + new CustomEvent('tanstack-dispatch-event', { detail: event }), + ) + }) + + // ServerEventBus output -> worker listeners. + const forward = (e: Event) => + hot.send!('tsd:to-client', (e as CustomEvent).detail) + const target = getTarget() + target?.addEventListener('tanstack-devtools-global', forward) + forwarders.push(() => + getTarget()?.removeEventListener('tanstack-devtools-global', forward), + ) + } + + return () => forwarders.forEach((off) => off()) +} From a85ff0b512148971e6b77971719edcd4e7d1c3cc Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Fri, 19 Jun 2026 17:25:33 +0200 Subject: [PATCH 22/27] feat(devtools-vite): hook runtime bridge into plugin and dev server --- packages/devtools-vite/src/plugin.ts | 25 +++++++++++ .../devtools-vite/src/runtime-bridge.test.ts | 2 +- packages/devtools-vite/tests/index.test.ts | 44 +++++++++++++++++++ 3 files changed, 70 insertions(+), 1 deletion(-) diff --git a/packages/devtools-vite/src/plugin.ts b/packages/devtools-vite/src/plugin.ts index 99d85fc5..b06884a6 100644 --- a/packages/devtools-vite/src/plugin.ts +++ b/packages/devtools-vite/src/plugin.ts @@ -19,6 +19,10 @@ import { installPackage, } from './package-manager' import { generateConsolePipeCode } from './virtual-console' +import { + injectRuntimeBridge, + wireRuntimeBridgeChannels, +} from './runtime-bridge' import type { ServerResponse } from 'node:http' import type { Plugin } from 'vite' import type { EditorConfig } from './editor' @@ -196,6 +200,13 @@ export const devtools = (args?: TanStackDevtoolsViteConfig): Array => { }) // start() now handles EADDRINUSE and returns the actual port devtoolsPort = await bus.start() + if ((server as any).environments) { + const teardownBridge = wireRuntimeBridgeChannels( + server as unknown as Parameters[0], + () => globalThis.__TANSTACK_EVENT_TARGET__, + ) + server.httpServer?.on('close', teardownBridge) + } } server.middlewares.use((req, _res, next) => { @@ -667,5 +678,19 @@ export const devtools = (args?: TanStackDevtoolsViteConfig): Array => { return result }, }, + { + name: '@tanstack/devtools:runtime-bridge', + apply(config, { command }) { + return config.mode === 'development' && command === 'serve' + }, + transform(code, id) { + if (id.includes('?')) return + return injectRuntimeBridge( + code, + id, + (this as any).environment?.name as string | undefined, + ) + }, + }, ] } diff --git a/packages/devtools-vite/src/runtime-bridge.test.ts b/packages/devtools-vite/src/runtime-bridge.test.ts index 16003ec9..1d0b0253 100644 --- a/packages/devtools-vite/src/runtime-bridge.test.ts +++ b/packages/devtools-vite/src/runtime-bridge.test.ts @@ -102,7 +102,7 @@ describe('wireRuntimeBridgeChannels', () => { wireRuntimeBridgeChannels(server as any, () => target) const evt = { type: 'q:foo', payload: 1 } - ssr.__handlers['tsd:to-server'](evt) + ssr.__handlers['tsd:to-server']!(evt) expect(received).toEqual([evt]) }) diff --git a/packages/devtools-vite/tests/index.test.ts b/packages/devtools-vite/tests/index.test.ts index 8c99a43f..b33b6f86 100644 --- a/packages/devtools-vite/tests/index.test.ts +++ b/packages/devtools-vite/tests/index.test.ts @@ -437,4 +437,48 @@ describe('devtools plugin', () => { expect(betterLogsIdx).toBeLessThan(pipeIdx) }) }) + + describe('runtime-bridge plugin', () => { + it('devtools() includes the runtime-bridge plugin', () => { + const plugins = devtools() + const names = plugins.map((p) => p.name) + expect(names).toContain('@tanstack/devtools:runtime-bridge') + }) + + it('runtime-bridge transform injects in a server environment', () => { + const plugin = devtools().find( + (p) => p.name === '@tanstack/devtools:runtime-bridge', + )! + // emulate Vite's per-environment plugin context + const ctx = { environment: { name: 'ssr' } } + const handler = + typeof plugin.transform === 'function' + ? plugin.transform + : (plugin.transform as any).handler + const out = handler.call( + ctx, + 'class EventClient {}', + '/x/node_modules/@tanstack/devtools-event-client/dist/esm/index.js', + ) + expect(out).toBeDefined() + expect(String(out)).toContain('__tsdRuntimeBridge') + }) + + it('runtime-bridge transform skips the client environment', () => { + const plugin = devtools().find( + (p) => p.name === '@tanstack/devtools:runtime-bridge', + )! + const ctx = { environment: { name: 'client' } } + const handler = + typeof plugin.transform === 'function' + ? plugin.transform + : (plugin.transform as any).handler + const out = handler.call( + ctx, + 'class EventClient {}', + '/x/node_modules/@tanstack/devtools-event-client/dist/esm/index.js', + ) + expect(out).toBeUndefined() + }) + }) }) From d4b03e0cb5ea65a0019314aefc085c95ee668bba Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Fri, 19 Jun 2026 19:23:47 +0200 Subject: [PATCH 23/27] chore: add devtools-vite changeset, remove planning docs from PR --- .changeset/devtools-vite-runtime-bridge.md | 9 + .superpowers/sdd/progress.md | 16 - .../2026-03-12-network-transport-fallback.md | 1507 ----------------- ...6-06-19-vite-hot-channel-runtime-bridge.md | 708 -------- ...03-12-network-transport-fallback-design.md | 160 -- ...-vite-hot-channel-runtime-bridge-design.md | 168 -- 6 files changed, 9 insertions(+), 2559 deletions(-) create mode 100644 .changeset/devtools-vite-runtime-bridge.md delete mode 100644 .superpowers/sdd/progress.md delete mode 100644 docs/superpowers/plans/2026-03-12-network-transport-fallback.md delete mode 100644 docs/superpowers/plans/2026-06-19-vite-hot-channel-runtime-bridge.md delete mode 100644 docs/superpowers/specs/2026-03-12-network-transport-fallback-design.md delete mode 100644 docs/superpowers/specs/2026-06-19-vite-hot-channel-runtime-bridge-design.md diff --git a/.changeset/devtools-vite-runtime-bridge.md b/.changeset/devtools-vite-runtime-bridge.md new file mode 100644 index 00000000..b38f9341 --- /dev/null +++ b/.changeset/devtools-vite-runtime-bridge.md @@ -0,0 +1,9 @@ +--- +'@tanstack/devtools-vite': minor +--- + +feat: deliver devtools events from isolated server runtimes over Vite's native HotChannel + +Server code running in an isolated runtime (Nitro v3 worker thread, Cloudflare `workerd`, or any separate thread/process) does not share `globalThis.__TANSTACK_EVENT_TARGET__` with the Vite dev process, so devtools events emitted on the server never reached the panel. + +The Vite plugin now bridges those events over the framework's existing `import.meta.hot` HotChannel — the same connection the runtime already uses for HMR. It injects a tiny, dev-only bridge into the event client when it runs in a non-client environment and wires each server environment's hot channel to the in-process `ServerEventBus`. No new WebSocket, no fetch, no reconnect logic, and no new runtime dependencies; the bridge is fully tree-shaken in production. diff --git a/.superpowers/sdd/progress.md b/.superpowers/sdd/progress.md deleted file mode 100644 index c0795184..00000000 --- a/.superpowers/sdd/progress.md +++ /dev/null @@ -1,16 +0,0 @@ -# Runtime Bridge — Progress Ledger - -Plan: docs/superpowers/plans/2026-06-19-vite-hot-channel-runtime-bridge.md -Branch: worktree-polished-cuddling-lark -Branch base (HEAD before Task 1): 830767c9f26b8ac6b055eaa1a0fa1e6bffbd37b0 - -## Tasks -- Task 1: merge main + revert event packages — pending -- Task 2: generate bridge code — pending -- Task 3: inject bridge into event-client — pending -- Task 4: wire dev-server hot channels — pending -- Task 5: hook into plugin — pending -- Task 6: validate against examples (USER-IN-LOOP) — pending -- Task 7: remove old docs + push — pending - -## Minor findings (for final review triage) diff --git a/docs/superpowers/plans/2026-03-12-network-transport-fallback.md b/docs/superpowers/plans/2026-03-12-network-transport-fallback.md deleted file mode 100644 index fb90de43..00000000 --- a/docs/superpowers/plans/2026-03-12-network-transport-fallback.md +++ /dev/null @@ -1,1507 +0,0 @@ -# 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/plans/2026-06-19-vite-hot-channel-runtime-bridge.md b/docs/superpowers/plans/2026-06-19-vite-hot-channel-runtime-bridge.md deleted file mode 100644 index f14fa261..00000000 --- a/docs/superpowers/plans/2026-06-19-vite-hot-channel-runtime-bridge.md +++ /dev/null @@ -1,708 +0,0 @@ -# Native Vite HotChannel Runtime Bridge — Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Replace PR #384's heavy in-client network transport with a minimal, dev-only bridge that uses Vite's native `import.meta.hot` HotChannel to carry devtools events between an isolated server runtime (Nitro v3 / Cloudflare workerd) and the Vite dev process. - -**Architecture:** Revert `@tanstack/devtools-event-client` and `@tanstack/devtools-event-bus` to their minimal pre-#384 state. Add a dev-only bridge in `@tanstack/devtools-vite`: a small generated IIFE injected into the `event-client` module in non-client (server) environments, plus `configureServer` wiring that connects each server environment's hot channel to the in-process `ServerEventBus`. We faithfully replicate the existing single-process `EventTarget` dispatch semantics across the wire, so no WebSocket/fetch/reconnect/ring-buffer/dedup is needed. - -**Tech Stack:** TypeScript, Vite 6/7/8 Environment API (`server.environments[name].hot`, `import.meta.hot`), Vitest, pnpm workspaces, Nx. - -## Global Constraints - -- Vite peer range stays `^6.0.0 || ^7.0.0 || ^8.0.0` (already in `packages/devtools-vite/package.json`). Do not add new runtime dependencies. -- All bridge code is **dev + `serve` only** and must be a no-op / tree-shaken in production (`import.meta.hot` undefined, and `removeDevtoolsOnBuild` still applies). -- `@tanstack/devtools-event-client` must end byte-for-byte equal to `origin/main` — zero bundle growth. -- Custom hot-channel event names: `tsd:to-server` (worker → dev server) and `tsd:to-client` (dev server → worker). Use these exact strings on both sides. -- Internal devtools `EventTarget` event names are unchanged: `tanstack-dispatch-event`, `tanstack-connect`, `tanstack-connect-success`, `tanstack-devtools-global`, and per-event `event.type`. -- Follow the existing `generate*Code(...)` + `toContain(...)` test pattern from `packages/devtools-vite/src/virtual-console.ts`. -- Package manager is **pnpm**. Run package tests with `pnpm --filter test:lib run` (vitest). Never use npx. -- Keep `examples/react/start-cloudflare` and `examples/react/start-nitro` — they are the final validation harness. - ---- - -## File Structure - -- `packages/devtools-vite/src/runtime-bridge.ts` — **new.** Pure functions: the generated worker-side bridge IIFE, the injection predicate/transform, and the dev-server hot-channel wiring helper. One responsibility: everything about the runtime bridge. -- `packages/devtools-vite/src/runtime-bridge.test.ts` — **new.** Unit tests for all four exported functions. -- `packages/devtools-vite/src/plugin.ts` — **modified.** Add a new plugin object that runs the injection transform; call the wiring helper inside the existing `custom-server` plugin's `configureServer` after `bus.start()`. -- `packages/event-bus-client/*`, `packages/event-bus/*` — **reverted to `origin/main`.** -- Old #384 docs — **deleted.** - ---- - -## Task 1: Merge `main` and revert the event packages to minimal - -**Files:** -- Merge: all (from `origin/main`) -- Revert to main: `packages/event-bus-client/src/plugin.ts`, `packages/event-bus-client/src/index.ts`, `packages/event-bus/src/server/server.ts`, `packages/event-bus/src/client/client.ts`, `packages/event-bus/tests/server.test.ts`, `packages/event-bus/tests/client.test.ts` -- Delete (PR-only, absent from main): `packages/event-bus-client/src/ring-buffer.ts`, `packages/event-bus-client/tests/ring-buffer.test.ts`, `packages/event-bus-client/tests/network-transport.test.ts`, `packages/event-bus-client/tests/integration.test.ts` - -**Interfaces:** -- Produces: a `main`-equivalent `EventClient` (no network transport, no `eventId`/`source`, no placeholders) and a `main`-equivalent `ServerEventBus` (no `?bridge=server` routing). Later tasks rely on `EventClient.getGlobalTarget()` reading `globalThis.__TANSTACK_EVENT_TARGET__` first, and on `ServerEventBus` listening for `tanstack-dispatch-event` on that global target. - -- [ ] **Step 1: Start the merge (run from the worktree `F:/projects/tanstack/devtools-pr384`)** - -```bash -git fetch origin main -git merge origin/main --no-commit --no-ff -``` -Expected: conflicts reported in `package.json`, `packages/event-bus/src/client/client.ts`, `pnpm-lock.yaml` (the only files changed on both sides). - -- [ ] **Step 2: Force the event packages to `origin/main`'s exact state** - -```bash -git checkout origin/main -- packages/event-bus packages/event-bus-client -``` -This resolves the `client.ts` conflict (takes main) and reverts every PR-modified file in both packages that exists in main. - -- [ ] **Step 3: Delete the PR-only files that `checkout` left behind** - -```bash -git rm -f packages/event-bus-client/src/ring-buffer.ts \ - packages/event-bus-client/tests/ring-buffer.test.ts \ - packages/event-bus-client/tests/network-transport.test.ts \ - packages/event-bus-client/tests/integration.test.ts -``` -Expected: each removed. If a file is already gone, confirm with `git status` that no `network-transport`/`ring-buffer`/`integration` files remain under `packages/event-bus-client`. - -- [ ] **Step 4: Resolve the remaining conflicts (`package.json`, `pnpm-lock.yaml`)** - -For root `package.json`, take main's version (it has the newer version bumps), then re-apply any PR-only example deps only if main dropped them — main did not touch examples, so main's `package.json` is correct: -```bash -git checkout origin/main -- package.json -``` -Regenerate the lockfile from the merged manifests: -```bash -pnpm install -git add pnpm-lock.yaml -``` -Expected: `pnpm install` completes; lockfile no longer conflicted. - -- [ ] **Step 5: Verify no event-client/event-bus diff against main remains** - -```bash -git diff origin/main -- packages/event-bus-client packages/event-bus | head -``` -Expected: **empty output** (both packages identical to main). - -- [ ] **Step 6: Run the reverted packages' test suites** - -```bash -pnpm --filter @tanstack/devtools-event-bus test:lib run -pnpm --filter @tanstack/devtools-event-client test:lib run -``` -Expected: both green (these are main's passing suites). - -- [ ] **Step 7: Commit the merge** - -```bash -git add -A -git commit --no-edit -``` -Expected: merge commit created. `git log --oneline -1` shows the merge. - ---- - -## Task 2: Generate the worker-side bridge code - -**Files:** -- Create: `packages/devtools-vite/src/runtime-bridge.ts` -- Test: `packages/devtools-vite/src/runtime-bridge.test.ts` - -**Interfaces:** -- Produces: `export function generateRuntimeBridgeCode(): string` — returns a self-contained IIFE string (no imports) that, when evaluated in a module where Vite has wired `import.meta.hot`, sets `globalThis.__TANSTACK_EVENT_TARGET__` and bridges it to the hot channel. Consumed by Task 3. - -- [ ] **Step 1: Write the failing test** - -```ts -// packages/devtools-vite/src/runtime-bridge.test.ts -import { describe, expect, test } from 'vitest' -import { generateRuntimeBridgeCode } from './runtime-bridge' - -describe('generateRuntimeBridgeCode', () => { - test('guards on import.meta.hot and an unset global target', () => { - const code = generateRuntimeBridgeCode() - expect(code).toContain('import.meta.hot') - expect(code).toContain('globalThis.__TANSTACK_EVENT_TARGET__') - expect(code).toContain('!globalThis.__TANSTACK_EVENT_TARGET__') - }) - - test('completes the connect handshake locally', () => { - const code = generateRuntimeBridgeCode() - expect(code).toContain("'tanstack-connect'") - expect(code).toContain("'tanstack-connect-success'") - }) - - test('forwards dispatched events to the dev server', () => { - const code = generateRuntimeBridgeCode() - expect(code).toContain("'tanstack-dispatch-event'") - expect(code).toContain("import.meta.hot.send('tsd:to-server'") - }) - - test('receives dev-server events and redispatches them locally', () => { - const code = generateRuntimeBridgeCode() - expect(code).toContain("import.meta.hot.on('tsd:to-client'") - expect(code).toContain("'tanstack-devtools-global'") - }) - - test('has no external imports', () => { - expect(generateRuntimeBridgeCode()).not.toContain('import ') - }) -}) -``` - -- [ ] **Step 2: Run the test to verify it fails** - -Run: `pnpm --filter @tanstack/devtools-vite test:lib run src/runtime-bridge.test.ts` -Expected: FAIL — `generateRuntimeBridgeCode` is not exported / module not found. - -- [ ] **Step 3: Implement `generateRuntimeBridgeCode`** - -```ts -// packages/devtools-vite/src/runtime-bridge.ts - -/** - * Worker-side bridge for isolated server runtimes (Nitro v3 worker, Cloudflare workerd). - * - * Injected into the `@tanstack/devtools-event-client` module ONLY in non-client - * (server) environments during dev. At module-eval time it gives the isolated - * runtime a real `globalThis.__TANSTACK_EVENT_TARGET__` (so the unchanged - * `EventClient` uses it instead of a throwaway target) and bridges that target to - * the Vite dev process over the framework plugin's existing HMR HotChannel. - * - * Guards: - * - `import.meta.hot` falsy (production / no HMR) -> tree-shaken / no-op. - * - global target already set (in-process RunnableDevEnvironment, where - * ServerEventBus lives) -> no-op, so existing behavior is unchanged. - * - * The bridge replicates ServerEventBus's in-process responsibilities so the - * EventClient protocol is identical across the wire (see design doc). - */ -export function generateRuntimeBridgeCode(): string { - return ` -;(function __tsdRuntimeBridge() { - if (typeof import.meta === 'undefined' || !import.meta.hot) return; - if (globalThis.__TANSTACK_EVENT_TARGET__) return; - var target = new EventTarget(); - globalThis.__TANSTACK_EVENT_TARGET__ = target; - - // Complete EventClient's connect handshake locally so queued events flush. - target.addEventListener('tanstack-connect', function () { - target.dispatchEvent(new CustomEvent('tanstack-connect-success')); - }); - - // Worker -> Vite dev server. - target.addEventListener('tanstack-dispatch-event', function (e) { - import.meta.hot.send('tsd:to-server', e.detail); - }); - - // Vite dev server -> worker listeners. - import.meta.hot.on('tsd:to-client', function (event) { - target.dispatchEvent(new CustomEvent(event.type, { detail: event })); - target.dispatchEvent(new CustomEvent('tanstack-devtools-global', { detail: event })); - }); -})(); -` -} -``` - -- [ ] **Step 4: Run the test to verify it passes** - -Run: `pnpm --filter @tanstack/devtools-vite test:lib run src/runtime-bridge.test.ts` -Expected: PASS (5 tests). - -- [ ] **Step 5: Commit** - -```bash -git add packages/devtools-vite/src/runtime-bridge.ts packages/devtools-vite/src/runtime-bridge.test.ts -git commit -m "feat(devtools-vite): generate worker-side runtime bridge code" -``` - ---- - -## Task 3: Inject the bridge into the event-client module in server environments - -**Files:** -- Modify: `packages/devtools-vite/src/runtime-bridge.ts` -- Test: `packages/devtools-vite/src/runtime-bridge.test.ts` - -**Interfaces:** -- Consumes: `generateRuntimeBridgeCode()` from Task 2. -- Produces: `export function injectRuntimeBridge(code: string, id: string, environmentName: string | undefined): string | undefined` — returns `code` with the bridge appended when the module is the event-client entry in a non-client environment; otherwise `undefined` (Vite convention for "no transform"). Consumed by Task 5's plugin wiring. - -- [ ] **Step 1: Write the failing test (append to `runtime-bridge.test.ts`)** - -```ts -import { injectRuntimeBridge } from './runtime-bridge' - -describe('injectRuntimeBridge', () => { - const EVENT_CLIENT_ID = - '/repo/node_modules/@tanstack/devtools-event-client/dist/esm/index.js' - const EVENT_CLIENT_CODE = 'class EventClient { emit() {} }' - - test('injects into the event-client module in a server environment', () => { - const out = injectRuntimeBridge(EVENT_CLIENT_CODE, EVENT_CLIENT_ID, 'ssr') - expect(out).toBeDefined() - expect(out).toContain(EVENT_CLIENT_CODE) - expect(out).toContain('__tsdRuntimeBridge') - }) - - test('matches the workspace source path too', () => { - const id = '/repo/packages/event-bus-client/src/plugin.ts' - expect(injectRuntimeBridge(EVENT_CLIENT_CODE, id, 'ssr')).toBeDefined() - }) - - test('skips the client environment', () => { - expect( - injectRuntimeBridge(EVENT_CLIENT_CODE, EVENT_CLIENT_ID, 'client'), - ).toBeUndefined() - }) - - test('skips when environment name is unknown (pre-Environment-API)', () => { - expect( - injectRuntimeBridge(EVENT_CLIENT_CODE, EVENT_CLIENT_ID, undefined), - ).toBeUndefined() - }) - - test('skips modules that are not the event-client', () => { - expect( - injectRuntimeBridge('export const x = 1', '/repo/src/app.ts', 'ssr'), - ).toBeUndefined() - }) - - test('skips event-client-pathed modules that lack the EventClient class', () => { - const id = '/repo/node_modules/@tanstack/devtools-event-client/dist/esm/foo.js' - expect(injectRuntimeBridge('export const y = 2', id, 'ssr')).toBeUndefined() - }) -}) -``` - -- [ ] **Step 2: Run the test to verify it fails** - -Run: `pnpm --filter @tanstack/devtools-vite test:lib run src/runtime-bridge.test.ts` -Expected: FAIL — `injectRuntimeBridge` is not exported. - -- [ ] **Step 3: Implement `injectRuntimeBridge` (add to `runtime-bridge.ts`)** - -```ts -function isEventClientModule(id: string, code: string): boolean { - const isEventClientPath = - id.includes('devtools-event-client') || id.includes('event-bus-client') - // Only the module that actually defines the class — avoids re-export shims - // and unrelated files inside the package. - return isEventClientPath && code.includes('EventClient') -} - -export function injectRuntimeBridge( - code: string, - id: string, - environmentName: string | undefined, -): string | undefined { - // Only isolated server environments need the bridge. The client environment - // has `window`; the in-process RunnableDevEnvironment is handled by the - // runtime global guard inside the injected code. - if (!environmentName || environmentName === 'client') return undefined - if (!isEventClientModule(id, code)) return undefined - return `${code}\n${generateRuntimeBridgeCode()}` -} -``` - -- [ ] **Step 4: Run the test to verify it passes** - -Run: `pnpm --filter @tanstack/devtools-vite test:lib run src/runtime-bridge.test.ts` -Expected: PASS (all `generateRuntimeBridgeCode` + `injectRuntimeBridge` tests). - -- [ ] **Step 5: Commit** - -```bash -git add packages/devtools-vite/src/runtime-bridge.ts packages/devtools-vite/src/runtime-bridge.test.ts -git commit -m "feat(devtools-vite): inject runtime bridge into event-client for server envs" -``` - ---- - -## Task 4: Wire the dev-server hot channels - -**Files:** -- Modify: `packages/devtools-vite/src/runtime-bridge.ts` -- Test: `packages/devtools-vite/src/runtime-bridge.test.ts` - -**Interfaces:** -- Produces: `export function wireRuntimeBridgeChannels(server: { environments: Record, }, getTarget: () => EventTarget | null | undefined): () => void` — for every non-`client` environment with a hot channel, registers `hot.on('tsd:to-server', ...)` (dispatching `tanstack-dispatch-event` on the target) and forwards the target's `tanstack-devtools-global` events to `hot.send('tsd:to-client', ...)`. Returns a teardown function that removes the forward listeners. Consumed by Task 5. - -- [ ] **Step 1: Write the failing test (append to `runtime-bridge.test.ts`)** - -```ts -import { wireRuntimeBridgeChannels } from './runtime-bridge' - -describe('wireRuntimeBridgeChannels', () => { - function makeEnv() { - const handlers: Record = {} - const sent: Array<{ event: string; data: any }> = [] - return { - hot: { - on: (event: string, cb: Function) => (handlers[event] = cb), - send: (event: string, data: any) => sent.push({ event, data }), - }, - __handlers: handlers, - __sent: sent, - } - } - - test('worker event -> dispatches tanstack-dispatch-event on the target', () => { - const target = new EventTarget() - const ssr = makeEnv() - const server = { environments: { client: { hot: null }, ssr } } - const received: Array = [] - target.addEventListener('tanstack-dispatch-event', (e) => - received.push((e as CustomEvent).detail), - ) - - wireRuntimeBridgeChannels(server as any, () => target) - const evt = { type: 'q:foo', payload: 1 } - ssr.__handlers['tsd:to-server'](evt) - - expect(received).toEqual([evt]) - }) - - test('target global event -> forwarded to the env via tsd:to-client', () => { - const target = new EventTarget() - const ssr = makeEnv() - const server = { environments: { ssr } } - - wireRuntimeBridgeChannels(server as any, () => target) - const evt = { type: 'q:bar', payload: 2 } - target.dispatchEvent(new CustomEvent('tanstack-devtools-global', { detail: evt })) - - expect(ssr.__sent).toEqual([{ event: 'tsd:to-client', data: evt }]) - }) - - test('skips the client environment', () => { - const client = makeEnv() - const server = { environments: { client } } - wireRuntimeBridgeChannels(server as any, () => new EventTarget()) - expect(client.__handlers['tsd:to-server']).toBeUndefined() - }) - - test('teardown stops forwarding', () => { - const target = new EventTarget() - const ssr = makeEnv() - const server = { environments: { ssr } } - const teardown = wireRuntimeBridgeChannels(server as any, () => target) - teardown() - target.dispatchEvent( - new CustomEvent('tanstack-devtools-global', { detail: { type: 'x' } }), - ) - expect(ssr.__sent).toEqual([]) - }) -}) -``` - -- [ ] **Step 2: Run the test to verify it fails** - -Run: `pnpm --filter @tanstack/devtools-vite test:lib run src/runtime-bridge.test.ts` -Expected: FAIL — `wireRuntimeBridgeChannels` is not exported. - -- [ ] **Step 3: Implement `wireRuntimeBridgeChannels` (add to `runtime-bridge.ts`)** - -```ts -interface BridgeHotChannel { - on?: (event: string, cb: (data: any) => void) => void - send?: (event: string, data: any) => void -} -interface BridgeServerLike { - environments: Record -} - -export function wireRuntimeBridgeChannels( - server: BridgeServerLike, - getTarget: () => EventTarget | null | undefined, -): () => void { - const forwarders: Array<() => void> = [] - - for (const [name, env] of Object.entries(server.environments)) { - if (name === 'client') continue - const hot = env?.hot - if (!hot || typeof hot.on !== 'function' || typeof hot.send !== 'function') { - continue - } - - // Worker -> ServerEventBus (broadcasts to browser + in-process listeners). - hot.on('tsd:to-server', (event: any) => { - getTarget()?.dispatchEvent( - new CustomEvent('tanstack-dispatch-event', { detail: event }), - ) - }) - - // ServerEventBus output -> worker listeners. - const forward = (e: Event) => - hot.send!('tsd:to-client', (e as CustomEvent).detail) - const target = getTarget() - target?.addEventListener('tanstack-devtools-global', forward) - forwarders.push(() => - getTarget()?.removeEventListener('tanstack-devtools-global', forward), - ) - } - - return () => forwarders.forEach((off) => off()) -} -``` - -- [ ] **Step 4: Run the test to verify it passes** - -Run: `pnpm --filter @tanstack/devtools-vite test:lib run src/runtime-bridge.test.ts` -Expected: PASS (all runtime-bridge tests). - -- [ ] **Step 5: Commit** - -```bash -git add packages/devtools-vite/src/runtime-bridge.ts packages/devtools-vite/src/runtime-bridge.test.ts -git commit -m "feat(devtools-vite): wire dev-server hot channels to ServerEventBus" -``` - ---- - -## Task 5: Hook the bridge into the `devtools-vite` plugin - -**Files:** -- Modify: `packages/devtools-vite/src/plugin.ts` (add import; add an injection plugin object; call wiring in the `custom-server` `configureServer`) -- Test: `packages/devtools-vite/tests/index.test.ts` - -**Interfaces:** -- Consumes: `injectRuntimeBridge`, `wireRuntimeBridgeChannels` from Tasks 3–4. -- Produces: a new plugin named `@tanstack/devtools:runtime-bridge` in the array returned by `devtools(...)`, and a teardown wired to server close. - -- [ ] **Step 1: Write the failing test (append to `packages/devtools-vite/tests/index.test.ts`)** - -First open `packages/devtools-vite/tests/index.test.ts` and match its existing import/style. Add: - -```ts -import { devtools } from '../src/plugin' - -describe('runtime-bridge plugin', () => { - test('devtools() includes the runtime-bridge plugin', () => { - const plugins = devtools() - const names = plugins.map((p) => p.name) - expect(names).toContain('@tanstack/devtools:runtime-bridge') - }) - - test('runtime-bridge transform injects in a server environment', () => { - const plugin = devtools().find( - (p) => p.name === '@tanstack/devtools:runtime-bridge', - )! - // emulate Vite's per-environment plugin context - const ctx = { environment: { name: 'ssr' } } - const handler = - typeof plugin.transform === 'function' - ? plugin.transform - : (plugin.transform as any).handler - const out = handler.call( - ctx, - 'class EventClient {}', - '/x/node_modules/@tanstack/devtools-event-client/dist/esm/index.js', - ) - expect(out).toBeDefined() - expect(String(out)).toContain('__tsdRuntimeBridge') - }) - - test('runtime-bridge transform skips the client environment', () => { - const plugin = devtools().find( - (p) => p.name === '@tanstack/devtools:runtime-bridge', - )! - const ctx = { environment: { name: 'client' } } - const handler = - typeof plugin.transform === 'function' - ? plugin.transform - : (plugin.transform as any).handler - const out = handler.call( - ctx, - 'class EventClient {}', - '/x/node_modules/@tanstack/devtools-event-client/dist/esm/index.js', - ) - expect(out).toBeUndefined() - }) -}) -``` - -- [ ] **Step 2: Run the test to verify it fails** - -Run: `pnpm --filter @tanstack/devtools-vite test:lib run tests/index.test.ts` -Expected: FAIL — no plugin named `@tanstack/devtools:runtime-bridge`. - -- [ ] **Step 3: Add the import at the top of `packages/devtools-vite/src/plugin.ts`** - -```ts -import { - injectRuntimeBridge, - wireRuntimeBridgeChannels, -} from './runtime-bridge' -``` - -- [ ] **Step 4: Add the injection plugin object to the array returned by `devtools(...)`** - -Insert as a new object in the returned array (e.g. directly after the `@tanstack/devtools:connection-injection` plugin). `this.environment` is provided by Vite 6+ in the transform hook: - -```ts -{ - name: '@tanstack/devtools:runtime-bridge', - apply(config, { command }) { - return config.mode === 'development' && command === 'serve' - }, - transform(code, id) { - if (id.includes('?')) return - return injectRuntimeBridge( - code, - id, - (this as any).environment?.name as string | undefined, - ) - }, -}, -``` - -- [ ] **Step 5: Wire the hot channels in the existing `custom-server` `configureServer`** - -In the `@tanstack/devtools:custom-server` plugin's `configureServer(server)`, immediately after `devtoolsPort = await bus.start()` (inside the `if (serverBusEnabled)` block), add: - -```ts -const teardownBridge = wireRuntimeBridgeChannels( - server as unknown as Parameters[0], - () => globalThis.__TANSTACK_EVENT_TARGET__, -) -server.httpServer?.on('close', teardownBridge) -``` - -If `globalThis.__TANSTACK_EVENT_TARGET__` is not already declared in this file's scope, rely on the existing global declaration in `@tanstack/devtools-event-bus/server` (already imported as `ServerEventBus`); if TypeScript complains, add at the top of the file: - -```ts -declare global { - var __TANSTACK_EVENT_TARGET__: EventTarget | null | undefined -} -``` - -- [ ] **Step 6: Run the test to verify it passes** - -Run: `pnpm --filter @tanstack/devtools-vite test:lib run tests/index.test.ts` -Expected: PASS. - -- [ ] **Step 7: Typecheck and full package test** - -```bash -pnpm --filter @tanstack/devtools-vite test:types -pnpm --filter @tanstack/devtools-vite test:lib run -``` -Expected: both green. - -- [ ] **Step 8: Commit** - -```bash -git add packages/devtools-vite/src/plugin.ts packages/devtools-vite/tests/index.test.ts -git commit -m "feat(devtools-vite): hook runtime bridge into plugin and dev server" -``` - ---- - -## Task 6: Validate against the Cloudflare and Nitro example apps - -**Files:** -- Use: `examples/react/start-cloudflare`, `examples/react/start-nitro` (no code changes expected) - -**Interfaces:** -- Consumes: the full bridge from Tasks 1–5. This is the empirical check of the spec's one implementation risk (whether `import.meta.hot` is wired into the injected `event-client` module in workerd / Nitro). - -- [ ] **Step 1: Build the workspace packages the examples consume** - -```bash -pnpm --filter @tanstack/devtools-event-client --filter @tanstack/devtools-event-bus --filter @tanstack/devtools-vite build -``` -Expected: all build. (Examples consume built `dist`, so the injected-module match in Task 3 targets `dist/esm/*` — confirm the built file contains `EventClient`.) - -- [ ] **Step 2: Run the Nitro example and emit a server event** - -```bash -pnpm --filter ./examples/react/start-nitro dev -``` -In the browser, open devtools, trigger a server-emitted event (the example's Server Events panel / a server action). Confirm the event appears in the devtools panel. Enable `eventBusConfig.debug` if needed to trace `tsd:to-server` arriving on the dev server. -Expected: server events appear in devtools. - -- [ ] **Step 3: Run the Cloudflare example and emit a server event** - -```bash -pnpm --filter ./examples/react/start-cloudflare dev -``` -Repeat the verification. -Expected: server events appear in devtools. - -- [ ] **Step 4: If events do NOT arrive — apply the documented fallback injection point** - -If debug shows the worker never sends `tsd:to-server` (i.e. `import.meta.hot` was `undefined` in the injected `event-client` dep module), switch the injection target from the dep module to the isolated environment's **server entry**, reusing the entry-detection approach already in `console-pipe-transform`: -- In `injectRuntimeBridge`, replace `isEventClientModule(id, code)` with an entry check: inject when `environmentName !== 'client'` and the module is the environment's server entry (heuristic: `code` contains the framework server handler markers used by `console-pipe-transform`'s `isRootEntry`, or matches the configured SSR entry id). Keep all generated code and the dev-server wiring identical. -- Re-run Steps 2–3. -Expected after fallback: server events appear in devtools in both runtimes. - -- [ ] **Step 5: Confirm the reverse direction and production no-op** - -- Trigger a devtools/browser-originated event that a server-side `.on()` listener handles; confirm the worker listener fires. -- Run a production build of one example (`pnpm --filter ./examples/react/start-nitro build`) and confirm no `__tsdRuntimeBridge` / `tsd:to-server` string remains in the server bundle. -Expected: reverse direction works; bridge stripped from production. - -- [ ] **Step 6: Commit any fallback changes (if Step 4 was needed)** - -```bash -git add packages/devtools-vite/src/runtime-bridge.ts packages/devtools-vite/src/runtime-bridge.test.ts -git commit -m "fix(devtools-vite): inject runtime bridge at server entry for isolated runtimes" -``` - ---- - -## Task 7: Remove superseded #384 docs and finalize the PR - -**Files:** -- Delete: `docs/superpowers/plans/2026-03-12-network-transport-fallback.md`, `docs/superpowers/specs/2026-03-12-network-transport-fallback-design.md` - -- [ ] **Step 1: Remove the old #384 design/plan docs** - -```bash -git rm docs/superpowers/plans/2026-03-12-network-transport-fallback.md \ - docs/superpowers/specs/2026-03-12-network-transport-fallback-design.md -``` - -- [ ] **Step 2: Run the full affected test + typecheck sweep** - -```bash -pnpm --filter @tanstack/devtools-event-bus test:lib run -pnpm --filter @tanstack/devtools-event-client test:lib run -pnpm --filter @tanstack/devtools-vite test:lib run -pnpm --filter @tanstack/devtools-vite test:types -``` -Expected: all green. - -- [ ] **Step 3: Commit and push to update PR #384** - -```bash -git add -A -git commit -m "docs: remove superseded network-transport docs" -git push -``` -Expected: PR #384 updated. Report the PR URL. - -- [ ] **Step 4: (Decision point) Confirm with the user whether the new `docs/superpowers/` design+plan should stay in the PR or be stripped for a code-only PR** before the final push, per their standing "no generated artifacts in PRs" preference. - ---- - -## Self-Review - -**Spec coverage:** -- Revert `event-bus-client` + `event-bus` → Task 1. ✓ -- Worker-side bridge (generated, guarded) → Tasks 2. ✓ -- Injection into event-client in server envs → Task 3. ✓ -- Dev-server `hot.on`/`hot.send` wiring + teardown → Task 4. ✓ -- Plugin integration → Task 5. ✓ -- Edge cases (in-process no-op via global guard; production tree-shake) → covered by generated guards (Task 2) + validated Task 6 Step 5. ✓ -- Implementation risk (injection point) → Task 6 Steps 4. ✓ -- Keep example apps → Task 6 (used, not deleted). ✓ -- Remove old #384 docs → Task 7. ✓ - -**Placeholder scan:** No TBD/TODO; every code step shows complete code; the fallback (Task 6 Step 4) describes the concrete swap rather than deferring it. - -**Type/name consistency:** `generateRuntimeBridgeCode` / `injectRuntimeBridge` / `wireRuntimeBridgeChannels` used identically across Tasks 2–5. Hot-channel event names `tsd:to-server` / `tsd:to-client` and target event names (`tanstack-dispatch-event`, `tanstack-connect`, `tanstack-connect-success`, `tanstack-devtools-global`) are consistent on both worker and dev-server sides. 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 deleted file mode 100644 index 0c6c6e1c..00000000 --- a/docs/superpowers/specs/2026-03-12-network-transport-fallback-design.md +++ /dev/null @@ -1,160 +0,0 @@ -# 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/docs/superpowers/specs/2026-06-19-vite-hot-channel-runtime-bridge-design.md b/docs/superpowers/specs/2026-06-19-vite-hot-channel-runtime-bridge-design.md deleted file mode 100644 index 8cb36da9..00000000 --- a/docs/superpowers/specs/2026-06-19-vite-hot-channel-runtime-bridge-design.md +++ /dev/null @@ -1,168 +0,0 @@ -# Native Vite HotChannel Runtime Bridge for Isolated Server Runtimes - -**Date:** 2026-06-19 -**Status:** Approved (design) -**Issue:** https://github.com/TanStack/ai/issues/339 -**Supersedes approach in:** PR #384 (`feat: network transport fallback for isolated server runtimes`) - -## Problem - -When TanStack Start runs server code in an **isolated runtime** — Nitro v3's `nitro()` Vite plugin (worker thread), Cloudflare's `workerd`, or any separate thread/process — the devtools event system breaks. - -`ServerEventBus` (in the Vite main process) creates and listens on `globalThis.__TANSTACK_EVENT_TARGET__`. In the isolated worker, `globalThis` is a *different* object, so `globalThis.__TANSTACK_EVENT_TARGET__` is `null`. When the server-side `EventClient` calls `getGlobalTarget()` it falls through to creating a throwaway `EventTarget` that nobody listens on. Events emitted on the server (e.g. `chat()` / `TextEngine` events, Query/Router server events) go nowhere and never reach the devtools panel. - -The default TanStack Start dev setup does not hit this because it uses Vite's `RunnableDevEnvironment`, which runs in-process and shares the same `globalThis`. - -## Why not PR #384's approach - -PR #384 solved this by building a full **network transport into `EventClient`** (the tiny, widely-imported `@tanstack/devtools-event-client` package): a WebSocket client, exponential-backoff reconnection, an HTTP POST fallback, an `eventId` + 200-entry ring-buffer dedup layer, and `?bridge=server` routing in `ServerEventBus`. This added ~380 lines to the client package and roughly doubled its bundle size — a cost paid by every consumer of every TanStack devtools plugin, for a dev-only concern. - -Issue #339 itself identifies the better path (its "Option 3", marked "most impactful"): Vite's Environment API already maintains a `HotChannel` between each isolated runtime and the dev process for HMR. The framework plugins (`@cloudflare/vite-plugin`, Nitro) establish it; `@cloudflare/vite-plugin` already uses `import.meta.hot.send(...)` from `workerd` to talk to the Vite process. We reuse that same channel. - -## Insight - -Cross-runtime communication is already solved by Vite — we just have to use it: - -- **From the isolated runtime:** `import.meta.hot.send('event', data)` dispatches to the Vite dev server over the channel the framework plugin already opened. -- **On the dev server:** `server.environments[name].hot.on('event', handler)` receives it; `server.environments[name].hot.send('event', data)` sends back. - -This is confirmed by the Vite docs (Environment API for Runtimes / Plugins): the Environment API supports isolated SSR workers and worker threads with per-environment hot channels. - -**No new WebSocket, no fetch, no reconnection logic, no ring buffer.** Vite owns the connection lifecycle (including HMR restarts), and we faithfully replicate the existing single-process `EventTarget` dispatch semantics across the wire — which removes the need for dedup. - -## Architecture - -Three parts: two reverts and one new dev-only bridge. - -### Part 1 — Revert `event-bus-client` and `event-bus` to minimal - -Restore the published packages to their pre-#384 byte size. - -**`packages/event-bus-client/`** -- `src/plugin.ts` — restore `main`'s `EventClient`: remove the WebSocket transport, reconnection, HTTP POST fallback, `#useNetworkTransport`, `eventId`/`source` fields, and the `__TANSTACK_DEVTOOLS_*` placeholder declarations. (The HotChannel needs no port/host coordinates, so no placeholders are required here.) -- `src/ring-buffer.ts` — delete. -- `src/index.ts` — remove the `createNetworkTransportClient` export. -- `tests/network-transport.test.ts`, `tests/ring-buffer.test.ts`, `tests/integration.test.ts` — delete. - -**`packages/event-bus/`** -- `src/server/server.ts` — take `main`'s version (drop `?bridge=server` URL matching, bridge-vs-browser connection tagging, and POST `source`-based routing). Keep the `main` hardening from #466. -- `src/client/client.ts` — take `main`'s version (drop `eventId`/`source` interface fields). -- `tests/server.test.ts` — drop the new bridge/POST routing tests; keep `main`'s suite. - -**Net result:** the widely-imported `@tanstack/devtools-event-client` package returns to its pre-#384 size; all new code lives in the dev-only Vite plugin. - -### Part 2 — Worker-side bridge (injected by `devtools-vite`, dev-only) - -A small, runtime-guarded bridge injected into the `@tanstack/devtools-event-client` module **only when it is transformed in a non-client (server) environment during `serve` in development**. It runs at module evaluation — before any `EventClient` instance method is called — so `EventClient.getGlobalTarget()` finds a real global target instead of a throwaway. - -```js -if (import.meta.hot && !globalThis.__TANSTACK_EVENT_TARGET__) { - const target = new EventTarget() - globalThis.__TANSTACK_EVENT_TARGET__ = target - - // Complete EventClient's connect handshake locally so it flushes queued events. - target.addEventListener('tanstack-connect', () => - target.dispatchEvent(new CustomEvent('tanstack-connect-success')), - ) - - // Worker -> Vite dev server. - target.addEventListener('tanstack-dispatch-event', (e) => - import.meta.hot.send('tsd:to-server', e.detail), - ) - - // Vite dev server -> worker listeners. - import.meta.hot.on('tsd:to-client', (event) => { - target.dispatchEvent(new CustomEvent(event.type, { detail: event })) - target.dispatchEvent( - new CustomEvent('tanstack-devtools-global', { detail: event }), - ) - }) -} -``` - -The `!globalThis.__TANSTACK_EVENT_TARGET__` guard makes in-process runtimes (where `ServerEventBus` already set the global) skip the bridge entirely — zero behavior change for the common path. The `import.meta.hot` guard makes it a no-op (and tree-shaken) in production. - -#### Replicating the in-process protocol - -The bridge replicates the responsibilities `ServerEventBus` performs in-process, so the unchanged `EventClient` behaves identically: - -1. `EventClient.emit()` → (after handshake) dispatches `tanstack-dispatch-event` on the global target. In-process, `ServerEventBus#dispatcher` handles it. In the worker, the bridge forwards it over `tsd:to-server`. -2. `EventClient` first emit dispatches `tanstack-connect` and waits for `tanstack-connect-success`. In-process, `ServerEventBus` replies. In the worker, the bridge replies locally so the queue flushes. -3. Devtools/browser-originated events that `ServerEventBus` would dispatch on the in-process target (`event.type` + `tanstack-devtools-global`, for server-side `.on()` listeners) arrive over `tsd:to-client` and the bridge dispatches them on the worker's target. - -### Part 3 — Dev-server wiring (`devtools-vite` `configureServer`) - -For every server environment that exposes a hot channel (i.e. every environment except `client`): - -```js -const globalTarget = globalThis.__TANSTACK_EVENT_TARGET__ // set by ServerEventBus - -// Worker -> ServerEventBus (broadcasts to browser + in-process listeners). -env.hot.on('tsd:to-server', (event) => { - globalTarget?.dispatchEvent( - new CustomEvent('tanstack-dispatch-event', { detail: event }), - ) -}) - -// ServerEventBus output -> worker listeners. -const forward = (e) => env.hot.send('tsd:to-client', e.detail) -globalTarget?.addEventListener('tanstack-devtools-global', forward) -// (removed on server close / environment teardown) -``` - -Feeding the worker's event back in as `tanstack-dispatch-event` routes it through the *existing* `ServerEventBus` path — it reaches the browser exactly as an in-process server event would, with no `ServerEventBus` changes. - -## Why there is no echo / dedup problem - -We are not inventing a new protocol; we are extending the existing `EventTarget` dispatch across a wire. The **emit** path dispatches `tanstack-dispatch-event`; the **receive** path dispatches `event.type` / `tanstack-devtools-global`. These are disjoint event names, so a received event never re-triggers the send path — no loop. An event the worker emitted does come back to the worker's own `.on()` listeners, but that is exactly what already happens in a single process today (the emitter shares the target it listens on), so the behavior is consistent and no `eventId`/ring-buffer dedup is required. - -## Edge cases - -- **HMR / worker restart:** Vite tears down and re-establishes the HotChannel and re-evaluates modules; the bridge re-registers automatically. No custom reconnect/backoff. -- **Production builds:** `import.meta.hot` is `undefined`, so the bridge guard fails and the injected block is tree-shaken. `removeDevtoolsOnBuild` continues to strip devtools code as before. -- **Multiple isolated environments:** each environment wires its own `hot.on`/`hot.send` independently; the worker-side global guard prevents an in-process environment from double-handling. -- **In-process (`RunnableDevEnvironment`):** `ServerEventBus` already set the global target, so the worker bridge no-ops and the dev-server-side `tsd:to-client` forwarding to that environment is inert (nothing is listening on its `tsd:to-client`). Existing behavior is unchanged. - -## Implementation risk to validate - -The exact injection point depends on whether Vite wires `import.meta.hot` into the `@tanstack/devtools-event-client` dep module within `workerd` / Nitro's bundled worker graph: - -- **Primary:** inject the bridge into the `event-client` module via a `devtools-vite` transform keyed on module id + non-client environment. This is deterministic and runs before `EventClient` is used. -- **Fallback (if `import.meta.hot` is not wired into the dep there):** inject the bridge into the isolated environment's server entry instead (similar to how the existing `console-pipe-transform` injects into entry files). - -The design is identical either way; only the host module differs. This is validated empirically against the example apps (below). - -## Testing - -- **Reverts** restore the original, passing minimal test suites for `event-bus` and `event-bus-client`. -- **New unit tests** for the dev-server wiring: `tsd:to-server` dispatches `tanstack-dispatch-event` on the global target; a global event triggers `env.hot.send('tsd:to-client', ...)`. -- **Manual validation** against the `examples/react/start-cloudflare` and `examples/react/start-nitro` apps added in #384 (kept specifically for this): emit a server event in each isolated runtime and confirm it appears in the devtools panel, and that devtools/browser events reach server-side `.on()` listeners. This is also where the injection-point risk above is confirmed. - -## Files changed - -### Reverted to `main` -- `packages/event-bus-client/src/plugin.ts` -- `packages/event-bus-client/src/index.ts` -- `packages/event-bus/src/server/server.ts` -- `packages/event-bus/src/client/client.ts` - -### Deleted -- `packages/event-bus-client/src/ring-buffer.ts` -- `packages/event-bus-client/tests/network-transport.test.ts` -- `packages/event-bus-client/tests/ring-buffer.test.ts` -- `packages/event-bus-client/tests/integration.test.ts` -- `packages/event-bus/tests/server.test.ts` bridge/POST routing cases (file kept, cases removed) - -### New / modified (dev-only) -- `packages/devtools-vite/src/plugin.ts` — bridge-injection transform (non-client server environment, `serve` + development) and `configureServer` hot-channel wiring + teardown. -- `packages/devtools-vite/` tests for the new wiring. - -### Kept -- `examples/react/start-cloudflare/*`, `examples/react/start-nitro/*` — for final validation. - -### Removed from PR -- `docs/superpowers/plans/2026-03-12-network-transport-fallback.md` and `docs/superpowers/specs/2026-03-12-network-transport-fallback-design.md` — superseded by this document (generated artifacts, not committed source). - -## Branch / integration - -Work continues on PR #384's branch (`worktree-polished-cuddling-lark`). Latest `main` is merged in first (resolving event-bus conflicts in favor of the reverts described here), then the bridge is implemented, then the example apps are run for confirmation. From 45c5e0f88890cce800731a3a30282ca5df63ae82 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Fri, 19 Jun 2026 19:28:30 +0200 Subject: [PATCH 24/27] fix(devtools-vite): tear down runtime-bridge hot handlers to prevent duplicate events --- .../devtools-vite/src/runtime-bridge.test.ts | 30 +++++++++++++++++++ packages/devtools-vite/src/runtime-bridge.ts | 17 ++++++----- 2 files changed, 40 insertions(+), 7 deletions(-) diff --git a/packages/devtools-vite/src/runtime-bridge.test.ts b/packages/devtools-vite/src/runtime-bridge.test.ts index 1d0b0253..69087b6b 100644 --- a/packages/devtools-vite/src/runtime-bridge.test.ts +++ b/packages/devtools-vite/src/runtime-bridge.test.ts @@ -80,13 +80,16 @@ describe('injectRuntimeBridge', () => { describe('wireRuntimeBridgeChannels', () => { function makeEnv() { const handlers: Record = {} + const removed: Array<{ event: string; cb: Function }> = [] const sent: Array<{ event: string; data: any }> = [] return { hot: { on: (event: string, cb: Function) => (handlers[event] = cb), + off: (event: string, cb: Function) => removed.push({ event, cb }), send: (event: string, data: any) => sent.push({ event, data }), }, __handlers: handlers, + __removed: removed, __sent: sent, } } @@ -137,4 +140,31 @@ describe('wireRuntimeBridgeChannels', () => { ) expect(ssr.__sent).toEqual([]) }) + + test('teardown removes the tsd:to-server handler via hot.off (I1)', () => { + const target = new EventTarget() + const ssr = makeEnv() + const server = { environments: { ssr } } + const teardown = wireRuntimeBridgeChannels(server as any, () => target) + + // Capture the registered handler reference before teardown. + const registeredHandler = ssr.__handlers['tsd:to-server'] + expect(registeredHandler).toBeDefined() + + teardown() + + // hot.off must have been called with the exact same handler reference. + expect(ssr.__removed).toContainEqual({ event: 'tsd:to-server', cb: registeredHandler }) + + // Dispatching a worker event after teardown must not reach the target. + const received: any[] = [] + target.addEventListener('tanstack-dispatch-event', (e) => + received.push((e as CustomEvent).detail), + ) + registeredHandler!({ type: 'post-teardown' }) + // The handler still dispatches (it holds a closure over getTarget) but the + // important invariant is that hot.off was called so the real HMR channel + // will no longer invoke it on subsequent dev-server restarts. + expect(ssr.__removed.filter((r) => r.event === 'tsd:to-server')).toHaveLength(1) + }) }) diff --git a/packages/devtools-vite/src/runtime-bridge.ts b/packages/devtools-vite/src/runtime-bridge.ts index ae02ac36..ae63e9ae 100644 --- a/packages/devtools-vite/src/runtime-bridge.ts +++ b/packages/devtools-vite/src/runtime-bridge.ts @@ -66,6 +66,7 @@ export function injectRuntimeBridge( interface BridgeHotChannel { on?: (event: string, cb: (data: any) => void) => void + off?: (event: string, cb: (data: any) => void) => void send?: (event: string, data: any) => void } interface BridgeServerLike { @@ -76,7 +77,7 @@ export function wireRuntimeBridgeChannels( server: BridgeServerLike, getTarget: () => EventTarget | null | undefined, ): () => void { - const forwarders: Array<() => void> = [] + const teardowns: Array<() => void> = [] for (const [name, env] of Object.entries(server.environments)) { if (name === 'client') continue @@ -86,21 +87,23 @@ export function wireRuntimeBridgeChannels( } // Worker -> ServerEventBus (broadcasts to browser + in-process listeners). - hot.on('tsd:to-server', (event: any) => { + const onToServer = (event: any) => { getTarget()?.dispatchEvent( new CustomEvent('tanstack-dispatch-event', { detail: event }), ) - }) + } + hot.on('tsd:to-server', onToServer) + teardowns.push(() => hot.off?.('tsd:to-server', onToServer)) // ServerEventBus output -> worker listeners. + const target = getTarget() const forward = (e: Event) => hot.send!('tsd:to-client', (e as CustomEvent).detail) - const target = getTarget() target?.addEventListener('tanstack-devtools-global', forward) - forwarders.push(() => - getTarget()?.removeEventListener('tanstack-devtools-global', forward), + teardowns.push(() => + target?.removeEventListener('tanstack-devtools-global', forward), ) } - return () => forwarders.forEach((off) => off()) + return () => teardowns.forEach((off) => off()) } From 8c973f6bddcd11ecc37bb3c97695514cc1532dc7 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Fri, 19 Jun 2026 17:29:53 +0000 Subject: [PATCH 25/27] ci: apply automated fixes --- .../start-cloudflare/src/routes/__root.tsx | 4 ++-- .../start-cloudflare/src/routes/index.tsx | 21 +++++++++++++------ .../react/start-cloudflare/wrangler.jsonc | 2 +- .../react/start-nitro/src/routes/__root.tsx | 6 +++--- .../react/start-nitro/src/routes/index.tsx | 21 +++++++++++++------ packages/devtools-vite/src/plugin.ts | 4 +++- .../devtools-vite/src/runtime-bridge.test.ts | 16 ++++++++++---- packages/devtools-vite/src/runtime-bridge.ts | 6 +++++- 8 files changed, 56 insertions(+), 24 deletions(-) diff --git a/examples/react/start-cloudflare/src/routes/__root.tsx b/examples/react/start-cloudflare/src/routes/__root.tsx index 7d85f2ee..0e893dab 100644 --- a/examples/react/start-cloudflare/src/routes/__root.tsx +++ b/examples/react/start-cloudflare/src/routes/__root.tsx @@ -23,8 +23,8 @@ function RootDocument({ children }: { children: React.ReactNode }) { {children} >([]) const addResult = (text: string) => { - setResults((prev) => [`[${new Date().toLocaleTimeString()}] ${text}`, ...prev].slice(0, 20)) + setResults((prev) => + [`[${new Date().toLocaleTimeString()}] ${text}`, ...prev].slice(0, 20), + ) } return ( @@ -55,7 +57,14 @@ function App() { server.

-
+