diff --git a/.gitignore b/.gitignore index bee425ca..45a04700 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/docs/superpowers/plans/2026-03-12-network-transport-fallback.md b/docs/superpowers/plans/2026-03-12-network-transport-fallback.md new file mode 100644 index 00000000..fb90de43 --- /dev/null +++ b/docs/superpowers/plans/2026-03-12-network-transport-fallback.md @@ -0,0 +1,1507 @@ +# Network Transport Fallback Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Enable devtools events to flow bidirectionally across process/thread isolation boundaries (Nitro v3 workers, Cloudflare Workers, etc.) via automatic WebSocket fallback. + +**Architecture:** When `EventClient` detects it's in an isolated server environment (no `globalThis.__TANSTACK_EVENT_TARGET__`, no `window`), it falls back to a WebSocket connection to `ServerEventBus`. `ServerEventBus` distinguishes "server bridge" WebSocket connections from browser clients and routes bridge messages through both `emitEventToClients()` and `emitToServer()`. Echo prevention uses a 200-entry ring buffer of event IDs. + +**Tech Stack:** TypeScript, Vitest, WebSocket (native `globalThis.WebSocket` with HTTP POST fallback), Node.js EventTarget + +**Spec:** `docs/superpowers/specs/2026-03-12-network-transport-fallback-design.md` + +--- + +## Chunk 1: Event Protocol + ServerEventBus Changes + +### Task 1: Update TanStackDevtoolsEvent interface in all 3 locations + +**Files:** +- Modify: `packages/event-bus/src/server/server.ts:7-14` +- Modify: `packages/event-bus/src/client/client.ts:29-33` +- Modify: `packages/event-bus-client/src/plugin.ts:1-5` + +- [ ] **Step 1: Write failing type test for new fields** + +Create a file that verifies the new fields exist on the interface. Run existing tests first to confirm green baseline. + +Run: `cd packages/event-bus && pnpm test:lib --run` +Run: `cd packages/event-bus-client && pnpm test:lib --run` +Expected: All existing tests PASS + +- [ ] **Step 2: Add `eventId` and `source` to server.ts interface** + +```typescript +// packages/event-bus/src/server/server.ts lines 7-14 +export interface TanStackDevtoolsEvent< + TEventName extends string, + TPayload = any, +> { + type: TEventName + payload: TPayload + pluginId?: string + eventId?: string + source?: 'server-bridge' +} +``` + +- [ ] **Step 3: Add `eventId` and `source` to client.ts interface** + +```typescript +// packages/event-bus/src/client/client.ts lines 29-33 +interface TanStackDevtoolsEvent { + type: TEventName + payload: TPayload + pluginId?: string + eventId?: string + source?: 'server-bridge' +} +``` + +- [ ] **Step 4: Add `eventId` and `source` to plugin.ts interface** + +```typescript +// packages/event-bus-client/src/plugin.ts lines 1-5 +interface TanStackDevtoolsEvent { + type: TEventName + payload: TPayload + pluginId?: string + eventId?: string + source?: 'server-bridge' +} +``` + +- [ ] **Step 5: Run all tests to confirm no regressions** + +Run: `cd packages/event-bus && pnpm test:lib --run` +Run: `cd packages/event-bus-client && pnpm test:lib --run` +Expected: All tests PASS (additive change, fully backward compatible) + +- [ ] **Step 6: Commit** + +```bash +git add packages/event-bus/src/server/server.ts packages/event-bus/src/client/client.ts packages/event-bus-client/src/plugin.ts +git commit -m "feat: add eventId and source fields to TanStackDevtoolsEvent interface" +``` + +--- + +### Task 2: ServerEventBus — server bridge WebSocket support + +**Files:** +- Modify: `packages/event-bus/src/server/server.ts:186-200` (handleNewConnection) +- Modify: `packages/event-bus/src/server/server.ts:50-53` (new bridge tracking set) +- Modify: `packages/event-bus/src/server/server.ts:273` (external upgrade URL matching) +- Modify: `packages/event-bus/src/server/server.ts:305` (standalone upgrade URL matching) +- Test: `packages/event-bus/tests/server.test.ts` + +- [ ] **Step 1: Write failing test — bridge WebSocket connection is accepted** + +Add to `packages/event-bus/tests/server.test.ts`: + +```typescript +import WebSocket from 'ws' + +describe('server bridge connections', () => { + it('should accept WebSocket connections with ?bridge=server query param', async () => { + bus = new ServerEventBus({ port: 0 }) + const port = await bus.start() + + const ws = new WebSocket(`ws://localhost:${port}/__devtools/ws?bridge=server`) + await new Promise((resolve, reject) => { + ws.on('open', () => resolve()) + ws.on('error', (err) => reject(err)) + }) + + expect(ws.readyState).toBe(WebSocket.OPEN) + ws.close() + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd packages/event-bus && pnpm test:lib --run` +Expected: FAIL — connection refused or not upgraded (exact equality `req.url === '/__devtools/ws'` doesn't match `/__devtools/ws?bridge=server`) + +- [ ] **Step 3: Fix URL matching in both upgrade handlers** + +In `packages/event-bus/src/server/server.ts`, change the standalone upgrade handler (line 305): + +```typescript +// Before: +if (req.url === '/__devtools/ws') { +// After: +if (req.url === '/__devtools/ws' || req.url?.startsWith('/__devtools/ws?')) { +``` + +And the external server upgrade handler (line 273): + +```typescript +// Before: +if (req.url === '/__devtools/ws') { +// After: +if (req.url === '/__devtools/ws' || req.url?.startsWith('/__devtools/ws?')) { +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cd packages/event-bus && pnpm test:lib --run` +Expected: PASS + +- [ ] **Step 5: Write failing test — bridge messages are broadcast to browser clients** + +```typescript +it('should broadcast server bridge messages to other WebSocket clients', async () => { + bus = new ServerEventBus({ port: 0 }) + const port = await bus.start() + + // Connect a "browser" client (no ?bridge=server) + const browserWs = new WebSocket(`ws://localhost:${port}/__devtools/ws`) + await new Promise((resolve) => browserWs.on('open', resolve)) + + // Connect a "server bridge" client + const bridgeWs = new WebSocket(`ws://localhost:${port}/__devtools/ws?bridge=server`) + await new Promise((resolve) => bridgeWs.on('open', resolve)) + + // Listen for messages on the browser client + const received = new Promise((resolve) => { + browserWs.on('message', (data) => resolve(JSON.parse(data.toString()))) + }) + + // Send event from bridge + bridgeWs.send(JSON.stringify({ + type: 'test:event', + payload: { foo: 'bar' }, + pluginId: 'test', + source: 'server-bridge', + })) + + const event = await received + expect(event.type).toBe('test:event') + expect(event.payload).toEqual({ foo: 'bar' }) + + browserWs.close() + bridgeWs.close() +}) +``` + +- [ ] **Step 6: Run test to verify it fails** + +Run: `cd packages/event-bus && pnpm test:lib --run` +Expected: FAIL — bridge message goes to `emitToServer()` only, browser client never receives it + +- [ ] **Step 7: Implement bridge connection tracking and routing** + +Add a bridge tracking set and modify `handleNewConnection`: + +```typescript +// In ServerEventBus class, add new field after #clients: +#bridgeClients = new Set() + +// Replace handleNewConnection method: +private handleNewConnection(wss: WebSocketServer) { + wss.on('connection', (ws: WebSocket, req: http.IncomingMessage) => { + const isBridge = req?.url?.includes('bridge=server') ?? false + this.debugLog(`New WebSocket client connected (bridge: ${isBridge})`) + this.#clients.add(ws) + if (isBridge) { + this.#bridgeClients.add(ws) + } + ws.on('close', () => { + this.debugLog('WebSocket client disconnected') + this.#clients.delete(ws) + this.#bridgeClients.delete(ws) + }) + ws.on('message', (msg) => { + this.debugLog('Received message from WebSocket client', msg.toString()) + const data = parseWithBigInt(msg.toString()) + if (isBridge) { + // Bridge messages go to both browser clients and in-process EventTarget + this.emit(data) + } else { + // Browser messages go to in-process EventTarget only + this.emitToServer(data) + } + }) + }) +} +``` + +Also update `stop()` to clear `#bridgeClients`: + +```typescript +// In stop() method, after this.#clients.clear(): +this.#bridgeClients.clear() +``` + +- [ ] **Step 8: Run tests to verify they pass** + +Run: `cd packages/event-bus && pnpm test:lib --run` +Expected: All tests PASS including the new bridge tests + +- [ ] **Step 9: Write test — bridge messages also dispatch on in-process EventTarget** + +```typescript +it('should dispatch server bridge messages on in-process EventTarget', async () => { + bus = new ServerEventBus({ port: 0 }) + const port = await bus.start() + + const eventTarget = globalThis.__TANSTACK_EVENT_TARGET__! + const received = new Promise((resolve) => { + eventTarget.addEventListener('test:event', (e) => { + resolve((e as CustomEvent).detail) + }) + }) + + const bridgeWs = new WebSocket(`ws://localhost:${port}/__devtools/ws?bridge=server`) + await new Promise((resolve) => bridgeWs.on('open', resolve)) + + bridgeWs.send(JSON.stringify({ + type: 'test:event', + payload: { data: 123 }, + pluginId: 'test', + source: 'server-bridge', + })) + + const event = await received + expect(event.type).toBe('test:event') + expect(event.payload).toEqual({ data: 123 }) + + bridgeWs.close() +}) +``` + +- [ ] **Step 10: Run test to verify it passes** + +Run: `cd packages/event-bus && pnpm test:lib --run` +Expected: PASS (already handled by `emit()` calling `emitToServer()`) + +- [ ] **Step 11: Write test — regular browser messages do NOT broadcast to other clients** + +```typescript +it('should NOT broadcast regular browser client messages to other WebSocket clients', async () => { + bus = new ServerEventBus({ port: 0 }) + const port = await bus.start() + + const browserWs1 = new WebSocket(`ws://localhost:${port}/__devtools/ws`) + await new Promise((resolve) => browserWs1.on('open', resolve)) + + const browserWs2 = new WebSocket(`ws://localhost:${port}/__devtools/ws`) + await new Promise((resolve) => browserWs2.on('open', resolve)) + + let received = false + browserWs2.on('message', () => { received = true }) + + // Send from browser client 1 (no bridge) + browserWs1.send(JSON.stringify({ + type: 'test:event', + payload: {}, + })) + + // Wait a bit + await new Promise((resolve) => setTimeout(resolve, 100)) + + // Browser client 2 should NOT have received it (browser→server only) + expect(received).toBe(false) + + browserWs1.close() + browserWs2.close() +}) +``` + +- [ ] **Step 12: Run test to verify it passes** + +Run: `cd packages/event-bus && pnpm test:lib --run` +Expected: PASS + +- [ ] **Step 13: Commit** + +```bash +git add packages/event-bus/src/server/server.ts packages/event-bus/tests/server.test.ts +git commit -m "feat: add server bridge WebSocket connection support to ServerEventBus" +``` + +--- + +### Task 3: ServerEventBus — POST handler source-based routing + +**Files:** +- Modify: `packages/event-bus/src/server/server.ts:153-165` (standalone POST handler) +- Modify: `packages/event-bus/src/server/server.ts:249-264` (external POST handler) +- Test: `packages/event-bus/tests/server.test.ts` + +- [ ] **Step 1: Write failing test — POST with source=server-bridge broadcasts to clients** + +```typescript +describe('POST handler source-based routing', () => { + it('should broadcast POST messages with source=server-bridge to WebSocket clients', async () => { + bus = new ServerEventBus({ port: 0 }) + const port = await bus.start() + + // Connect a browser WebSocket client + const browserWs = new WebSocket(`ws://localhost:${port}/__devtools/ws`) + await new Promise((resolve) => browserWs.on('open', resolve)) + + const received = new Promise((resolve) => { + browserWs.on('message', (data) => resolve(JSON.parse(data.toString()))) + }) + + // POST with source: 'server-bridge' + await new Promise((resolve) => { + const req = http.request({ + hostname: 'localhost', + port, + path: '/__devtools/send', + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + }, () => resolve()) + req.write(JSON.stringify({ + type: 'test:event', + payload: { from: 'bridge' }, + source: 'server-bridge', + })) + req.end() + }) + + const event = await received + expect(event.type).toBe('test:event') + expect(event.payload).toEqual({ from: 'bridge' }) + + browserWs.close() + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd packages/event-bus && pnpm test:lib --run` +Expected: FAIL — POST handler calls `emitToServer()` only, browser client never receives + +- [ ] **Step 3: Update standalone POST handler to check source field** + +In `createSSEServer()`, change the POST handler (lines 153-165): + +```typescript +if (req.url === '/__devtools/send' && req.method === 'POST') { + let body = '' + req.on('data', (chunk) => (body += chunk)) + req.on('end', () => { + try { + const msg = parseWithBigInt(body) + this.debugLog('Received event from client', msg) + if (msg.source === 'server-bridge') { + this.emit(msg) + } else { + this.emitToServer(msg) + } + } catch {} + }) + res.writeHead(200).end() + return +} +``` + +- [ ] **Step 4: Update external server POST handler** + +In `start()`, change the external POST handler (lines 249-264): + +```typescript +if (req.url === '/__devtools/send' && req.method === 'POST') { + let body = '' + req.on('data', (chunk) => (body += chunk)) + req.on('end', () => { + try { + const msg = parseWithBigInt(body) + this.debugLog('Received event from client (external server)', msg) + if (msg.source === 'server-bridge') { + this.emit(msg) + } else { + this.emitToServer(msg) + } + } catch {} + }) + res.writeHead(200).end() + return +} +``` + +- [ ] **Step 5: Run tests to verify all pass** + +Run: `cd packages/event-bus && pnpm test:lib --run` +Expected: All PASS + +- [ ] **Step 6: Write test for external server POST routing** + +```typescript +describe('POST handler source-based routing (external server)', () => { + let externalServer: http.Server + + beforeEach(async () => { + externalServer = http.createServer() + await new Promise((resolve) => { + externalServer.listen(0, () => resolve()) + }) + }) + + afterEach(() => { + externalServer.close() + }) + + it('should broadcast POST with source=server-bridge on external server', async () => { + bus = new ServerEventBus({ httpServer: externalServer }) + const port = await bus.start() + + const browserWs = new WebSocket(`ws://localhost:${port}/__devtools/ws`) + await new Promise((resolve) => browserWs.on('open', resolve)) + + const received = new Promise((resolve) => { + browserWs.on('message', (data) => resolve(JSON.parse(data.toString()))) + }) + + await new Promise((resolve) => { + const req = http.request({ + hostname: 'localhost', + port, + path: '/__devtools/send', + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + }, () => resolve()) + req.write(JSON.stringify({ + type: 'test:event', + payload: { from: 'bridge' }, + source: 'server-bridge', + })) + req.end() + }) + + const event = await received + expect(event.type).toBe('test:event') + + browserWs.close() + }) +}) +``` + +- [ ] **Step 7: Run tests** + +Run: `cd packages/event-bus && pnpm test:lib --run` +Expected: All PASS + +- [ ] **Step 8: Commit** + +```bash +git add packages/event-bus/src/server/server.ts packages/event-bus/tests/server.test.ts +git commit -m "feat: add source-based routing to POST handlers for server bridge support" +``` + +--- + +## Chunk 2: EventClient Network Transport + +### Task 4: EventClient — ring buffer utility + +**Files:** +- Create: `packages/event-bus-client/src/ring-buffer.ts` +- Test: `packages/event-bus-client/tests/ring-buffer.test.ts` + +- [ ] **Step 1: Write failing tests for ring buffer** + +Create `packages/event-bus-client/tests/ring-buffer.test.ts`: + +```typescript +// @vitest-environment node +import { describe, expect, it } from 'vitest' +import { RingBuffer } from '../src/ring-buffer' + +describe('RingBuffer', () => { + it('should track added items via has()', () => { + const buf = new RingBuffer(5) + buf.add('a') + expect(buf.has('a')).toBe(true) + expect(buf.has('b')).toBe(false) + }) + + it('should evict oldest items when capacity is exceeded', () => { + const buf = new RingBuffer(3) + buf.add('a') + buf.add('b') + buf.add('c') + buf.add('d') // evicts 'a' + expect(buf.has('a')).toBe(false) + expect(buf.has('b')).toBe(true) + expect(buf.has('c')).toBe(true) + expect(buf.has('d')).toBe(true) + }) + + it('should handle wrapping around the buffer', () => { + const buf = new RingBuffer(2) + buf.add('a') + buf.add('b') + buf.add('c') // evicts 'a' + buf.add('d') // evicts 'b' + expect(buf.has('a')).toBe(false) + expect(buf.has('b')).toBe(false) + expect(buf.has('c')).toBe(true) + expect(buf.has('d')).toBe(true) + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd packages/event-bus-client && pnpm test:lib --run` +Expected: FAIL — module not found + +- [ ] **Step 3: Implement RingBuffer** + +Create `packages/event-bus-client/src/ring-buffer.ts`: + +```typescript +export class RingBuffer { + #buffer: Array + #set: Set + #index = 0 + #capacity: number + + constructor(capacity: number) { + this.#capacity = capacity + this.#buffer = new Array(capacity).fill('') + this.#set = new Set() + } + + add(item: string) { + const evicted = this.#buffer[this.#index] + if (evicted) { + this.#set.delete(evicted) + } + this.#buffer[this.#index] = item + this.#set.add(item) + this.#index = (this.#index + 1) % this.#capacity + } + + has(item: string): boolean { + return this.#set.has(item) + } +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cd packages/event-bus-client && pnpm test:lib --run` +Expected: All PASS + +- [ ] **Step 5: Commit** + +```bash +git add packages/event-bus-client/src/ring-buffer.ts packages/event-bus-client/tests/ring-buffer.test.ts +git commit -m "feat: add RingBuffer utility for event ID deduplication" +``` + +--- + +### Task 5: EventClient — network transport detection + +**Files:** +- Modify: `packages/event-bus-client/src/plugin.ts:1-8` (add placeholders) +- Modify: `packages/event-bus-client/src/plugin.ts:14-27` (add new private fields) +- Modify: `packages/event-bus-client/src/plugin.ts:121-160` (modify getGlobalTarget) +- Test: `packages/event-bus-client/tests/network-transport.test.ts` + +- [ ] **Step 1: Write failing test for network transport detection** + +Create `packages/event-bus-client/tests/network-transport.test.ts`: + +```typescript +// @vitest-environment node +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { EventClient } from '../src' + +describe('EventClient network transport detection', () => { + beforeEach(() => { + // Ensure no global event target (simulating isolated worker) + globalThis.__TANSTACK_EVENT_TARGET__ = null + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('should not activate network transport when placeholders are not replaced', () => { + // Without Vite plugin, __TANSTACK_DEVTOOLS_PORT__ is undefined + const client = new EventClient({ + pluginId: 'test-no-network', + debug: false, + }) + // Client should fall back to local EventTarget (no network) + // Emitting should not throw + client.emit('event', { foo: 'bar' }) + }) +}) +``` + +- [ ] **Step 2: Run test to verify baseline behavior works** + +Run: `cd packages/event-bus-client && pnpm test:lib --run` +Expected: PASS (current behavior — creates local EventTarget, events go nowhere but no crash) + +- [ ] **Step 3: Add compile-time placeholders to plugin.ts** + +Add at top of `packages/event-bus-client/src/plugin.ts`, after the interface: + +```typescript +// Compile-time placeholders replaced by the Vite plugin's connection-injection transform. +// When not replaced (no Vite plugin), these remain undefined and network transport is disabled. +declare const __TANSTACK_DEVTOOLS_PORT__: number | undefined +declare const __TANSTACK_DEVTOOLS_HOST__: string | undefined +declare const __TANSTACK_DEVTOOLS_PROTOCOL__: 'http' | 'https' | undefined + +function getDevtoolsPort(): number | undefined { + try { + return typeof __TANSTACK_DEVTOOLS_PORT__ !== 'undefined' ? __TANSTACK_DEVTOOLS_PORT__ : undefined + } catch { + return undefined + } +} + +function getDevtoolsHost(): string | undefined { + try { + return typeof __TANSTACK_DEVTOOLS_HOST__ !== 'undefined' ? __TANSTACK_DEVTOOLS_HOST__ : undefined + } catch { + return undefined + } +} + +function getDevtoolsProtocol(): 'http' | 'https' | undefined { + try { + return typeof __TANSTACK_DEVTOOLS_PROTOCOL__ !== 'undefined' ? __TANSTACK_DEVTOOLS_PROTOCOL__ : undefined + } catch { + return undefined + } +} +``` + +- [ ] **Step 4: Add new private fields to EventClient class** + +Add to the class after `#internalEventTarget`: + +```typescript +#useNetworkTransport = false +#networkTransportDetected = false // one-time detection flag +#cachedLocalTarget: EventTarget | null = null // cached for consistent listener registration +#ws: WebSocket | null = null +#wsConnecting = false +#wsReconnectTimer: ReturnType | null = null +#wsReconnectDelay = 100 // exponential backoff: 100, 200, 400, ... 5000ms +#wsMaxReconnectAttempts = 10 // give up on WebSocket after this many failures +#wsReconnectAttempts = 0 +#wsGaveUp = false // true when WebSocket is permanently unavailable, use HTTP-only +#sentEventIds: RingBuffer = new RingBuffer(200) +#networkPort: number | undefined = undefined +#networkHost: string | undefined = undefined +#networkProtocol: 'http' | 'https' | undefined = undefined +``` + +Import `RingBuffer` at the top: + +```typescript +import { RingBuffer } from './ring-buffer' +``` + +- [ ] **Step 5: Modify getGlobalTarget() for network transport detection** + +Replace the `getGlobalTarget()` method. **Critical: cache the local EventTarget** so `.on()` listeners and `emit()` use the same instance: + +```typescript +private getGlobalTarget() { + // server one is the global event target + if ( + typeof globalThis !== 'undefined' && + globalThis.__TANSTACK_EVENT_TARGET__ + ) { + this.debugLog('Using global event target') + return globalThis.__TANSTACK_EVENT_TARGET__ + } + // Client event target is the browser window object + if ( + typeof window !== 'undefined' && + typeof window.addEventListener !== 'undefined' + ) { + this.debugLog('Using window as event target') + return window + } + + // We're in an isolated server environment (worker thread, separate process, etc.) + // Check if devtools server coordinates are available (Vite plugin replaced placeholders) + if (!this.#networkTransportDetected) { + this.#networkTransportDetected = true + const port = getDevtoolsPort() + if (port !== undefined) { + this.#useNetworkTransport = true + this.debugLog('Network transport activated — devtools server detected at port', port) + } + } + + // Return cached local EventTarget to ensure .on() and emit() use the same instance + if (this.#cachedLocalTarget) { + return this.#cachedLocalTarget + } + + // Protect against non-web environments like react-native + if (typeof EventTarget === 'undefined') { + this.debugLog( + 'No event mechanism available, running in non-web environment', + ) + const noop = { + addEventListener: () => {}, + removeEventListener: () => {}, + dispatchEvent: () => false, + } + this.#cachedLocalTarget = noop as any + return noop + } + + const eventTarget = new EventTarget() + this.#cachedLocalTarget = eventTarget + this.debugLog('Using cached local EventTarget as fallback') + return eventTarget +} +``` + +- [ ] **Step 6: Run all tests to verify no regressions** + +Run: `cd packages/event-bus-client && pnpm test:lib --run` +Expected: All PASS (network transport does nothing yet, existing behavior preserved) + +- [ ] **Step 7: Commit** + +```bash +git add packages/event-bus-client/src/plugin.ts packages/event-bus-client/src/ring-buffer.ts packages/event-bus-client/tests/network-transport.test.ts +git commit -m "feat: add network transport detection and compile-time placeholders to EventClient" +``` + +--- + +### Task 6: EventClient — WebSocket connection, emit, and receive + +**Files:** +- Modify: `packages/event-bus-client/src/plugin.ts` (add connection, emit, receive logic) +- Test: `packages/event-bus-client/tests/network-transport.test.ts` + +This is the core task. We add: lazy WebSocket connection on first `emit()`, event ID stamping, sending via WebSocket, receiving and deduplicating incoming messages, and reconnection. + +- [ ] **Step 1: Write failing integration test — emit via network transport reaches ServerEventBus** + +Add to `packages/event-bus-client/tests/network-transport.test.ts`. Note: all imports at top of file: + +```typescript +import { ServerEventBus } from '@tanstack/devtools-event-bus/server' +import { createNetworkTransportClient } from '../src/plugin' + +describe('EventClient network transport emit', () => { + let serverBus: ServerEventBus + + beforeEach(async () => { + globalThis.__TANSTACK_EVENT_TARGET__ = null + }) + + afterEach(async () => { + serverBus?.stop() + globalThis.__TANSTACK_EVENT_TARGET__ = null + await new Promise((resolve) => setTimeout(resolve, 50)) + }) + + it('should emit events to ServerEventBus via WebSocket when using network transport', async () => { + // Start a server bus to receive events + serverBus = new ServerEventBus({ port: 0 }) + const port = await serverBus.start() + + // Save the server's event target before we null it for the client + const serverEventTarget = globalThis.__TANSTACK_EVENT_TARGET__! + + // Null out the global so EventClient detects isolation + globalThis.__TANSTACK_EVENT_TARGET__ = null + + const client = createNetworkTransportClient({ + pluginId: 'test-network', + port, + host: 'localhost', + protocol: 'http', + }) + + // Listen on the server's event target for the event + const received = new Promise((resolve) => { + serverEventTarget.addEventListener('test-network:event', (e) => { + resolve((e as CustomEvent).detail) + }) + }) + + client.emit('event', { hello: 'world' }) + + // Wait for WebSocket connection + message delivery + const event = await Promise.race([ + received, + new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), 3000)), + ]) + + expect(event.type).toBe('test-network:event') + expect(event.payload).toEqual({ hello: 'world' }) + expect(event.source).toBe('server-bridge') + + client.destroy() + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd packages/event-bus-client && pnpm test:lib --run` +Expected: FAIL — `createNetworkTransportClient` doesn't exist yet + +- [ ] **Step 3: Add event ID generation helper** + +Add to `packages/event-bus-client/src/plugin.ts`: + +```typescript +let globalEventIdCounter = 0 + +function generateEventId(): string { + return `${++globalEventIdCounter}-${Date.now()}` +} +``` + +- [ ] **Step 4: Add WebSocket connection method to EventClient** + +Add to the EventClient class: + +```typescript +private connectWebSocket() { + if (this.#wsConnecting || this.#ws) return + this.#wsConnecting = true + + const port = getDevtoolsPort() + const host = getDevtoolsHost() ?? 'localhost' + const protocol = getDevtoolsProtocol() ?? 'http' + const wsProtocol = protocol === 'https' ? 'wss' : 'ws' + const url = `${wsProtocol}://${host}:${port}/__devtools/ws?bridge=server` + + this.debugLog('Connecting to ServerEventBus via WebSocket', url) + + try { + const ws = new WebSocket(url) + + ws.addEventListener('open', () => { + this.debugLog('WebSocket connected to ServerEventBus') + this.#ws = ws + this.#wsConnecting = false + this.#connected = true + this.#wsReconnectDelay = 100 // reset backoff + + // Flush queued events + const queued = [...this.#queuedEvents] + this.#queuedEvents = [] + for (const event of queued) { + this.sendViaNetwork(event) + } + }) + + ws.addEventListener('message', (e) => { + try { + const data = typeof e.data === 'string' ? e.data : e.data.toString() + const event = JSON.parse(data) + + // Dedup: ignore events we sent ourselves + if (event.eventId && this.#sentEventIds.has(event.eventId)) { + this.debugLog('Ignoring echoed event', event.eventId) + return + } + + this.debugLog('Received event via network transport', event) + + // Dispatch on local EventTarget so .on() listeners fire + const target = this.#eventTarget() + try { + target.dispatchEvent(new CustomEvent(event.type, { detail: event })) + target.dispatchEvent(new CustomEvent('tanstack-devtools-global', { detail: event })) + } catch { + // EventTarget may not support CustomEvent in all environments + } + } catch { + this.debugLog('Failed to parse incoming WebSocket message') + } + }) + + ws.addEventListener('close', () => { + this.debugLog('WebSocket connection closed') + this.#ws = null + this.#connected = false + this.#wsConnecting = false + this.scheduleReconnect() + }) + + ws.addEventListener('error', () => { + this.debugLog('WebSocket connection error') + this.#wsConnecting = false + }) + } catch { + this.debugLog('Failed to create WebSocket connection') + this.#wsConnecting = false + this.scheduleReconnect() + } +} + +private scheduleReconnect() { + if (this.#wsReconnectTimer) return + if (!this.#useNetworkTransport) return + + this.debugLog(`Scheduling reconnect in ${this.#wsReconnectDelay}ms`) + this.#wsReconnectTimer = setTimeout(() => { + this.#wsReconnectTimer = null + this.connectWebSocket() + }, this.#wsReconnectDelay) + + // Exponential backoff, max 5s + this.#wsReconnectDelay = Math.min(this.#wsReconnectDelay * 2, 5000) +} + +private sendViaNetwork(event: TanStackDevtoolsEvent) { + const eventWithId = { + ...event, + eventId: generateEventId(), + source: 'server-bridge' as const, + } + this.#sentEventIds.add(eventWithId.eventId!) + + if (this.#ws && this.#ws.readyState === WebSocket.OPEN) { + this.debugLog('Sending event via WebSocket', eventWithId) + this.#ws.send(JSON.stringify(eventWithId)) + } else { + // HTTP POST fallback + this.sendViaHttp(eventWithId) + } +} + +private sendViaHttp(event: TanStackDevtoolsEvent) { + const port = getDevtoolsPort() + const host = getDevtoolsHost() ?? 'localhost' + const protocol = getDevtoolsProtocol() ?? 'http' + + if (!port) return + + this.debugLog('Sending event via HTTP POST fallback', event) + + try { + fetch(`${protocol}://${host}:${port}/__devtools/send`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(event), + }).catch(() => { + this.debugLog('HTTP POST fallback failed') + }) + } catch { + this.debugLog('fetch not available for HTTP POST fallback') + } +} +``` + +- [ ] **Step 5: Modify the `emit()` method — network transport BEFORE `#failedToConnect`** + +**Critical ordering:** The network transport check must come BEFORE `#failedToConnect`, because in an isolated worker the in-process connect loop always fails and sets `#failedToConnect = true`. If we check after, network transport is permanently blocked. + +In the `emit()` method, add the network transport path AFTER the `#internalEventTarget` dispatch block and BEFORE the `if (this.#failedToConnect)` check: + +```typescript +// Network transport path — skip in-process handshake entirely. +// Must come BEFORE #failedToConnect check because in isolated workers +// the in-process handshake always fails. +if (this.#useNetworkTransport) { + const event = this.createEventPayload(eventSuffix, payload) + if (!this.#connected) { + this.#queuedEvents.push(event) + this.connectWebSocket() + return + } + this.sendViaNetwork(event) + return +} +``` + +Also, add queue preservation. When `getGlobalTarget()` first detects network transport (during the first `emit()`), events may have already been queued by the in-process path. Since `stopConnectLoop()` clears `#queuedEvents`, we need to prevent the in-process connect loop from ever starting when `#useNetworkTransport` is true. The ordering above achieves this — network transport check happens first, so `#connectFunction` / `startConnectLoop` are never called. + +- [ ] **Step 6: Add `createNetworkTransportClient` test helper and `destroy` method** + +Add internal methods to EventClient class: + +```typescript +/** @internal — only for testing and createNetworkTransportClient */ +___enableNetworkTransport(port: number, host: string, protocol: 'http' | 'https') { + this.#useNetworkTransport = true + this.#networkTransportDetected = true + this.#networkPort = port + this.#networkHost = host + this.#networkProtocol = protocol +} + +/** @internal */ +___destroyNetworkTransport() { + if (this.#wsReconnectTimer) { + clearTimeout(this.#wsReconnectTimer) + this.#wsReconnectTimer = null + } + if (this.#ws) { + this.#ws.close() + this.#ws = null + } + this.#connected = false + this.#useNetworkTransport = false +} +``` + +Add to `packages/event-bus-client/src/plugin.ts` at the end of the file: + +```typescript +/** + * Creates an EventClient with network transport explicitly enabled. + * Used for testing and for environments where compile-time placeholder + * replacement is not available. + */ +export function createNetworkTransportClient>({ + pluginId, + port, + host = 'localhost', + protocol = 'http', + debug = false, +}: { + pluginId: string + port: number + host?: string + protocol?: 'http' | 'https' + debug?: boolean +}): EventClient & { destroy: () => void } { + const client = new EventClient({ pluginId, debug }) + ;(client as any).___enableNetworkTransport(port, host, protocol) + // Attach destroy directly — keeps the original instance with all its methods intact + ;(client as any).destroy = () => (client as any).___destroyNetworkTransport() + return client as EventClient & { destroy: () => void } +} +``` + +Also export it from `packages/event-bus-client/src/index.ts`: + +```typescript +export { EventClient, createNetworkTransportClient } from './plugin' +``` + +Update `connectWebSocket()` to use override coordinates when available, and **add WebSocket retry limit** to fall back to HTTP-only: + +```typescript +private connectWebSocket() { + if (this.#wsConnecting || this.#ws) return + if (this.#wsGaveUp) return // WebSocket permanently unavailable, use HTTP-only + + this.#wsConnecting = true + + const port = this.#networkPort ?? getDevtoolsPort() + const host = this.#networkHost ?? getDevtoolsHost() ?? 'localhost' + const protocol = this.#networkProtocol ?? getDevtoolsProtocol() ?? 'http' + // ... rest unchanged +``` + +Update `scheduleReconnect()` to track attempts and give up: + +```typescript +private scheduleReconnect() { + if (this.#wsReconnectTimer) return + if (!this.#useNetworkTransport) return + if (this.#wsGaveUp) return + + this.#wsReconnectAttempts++ + if (this.#wsReconnectAttempts > this.#wsMaxReconnectAttempts) { + this.debugLog('WebSocket permanently unavailable, falling back to HTTP-only') + this.#wsGaveUp = true + // Flush any queued events via HTTP POST + const queued = [...this.#queuedEvents] + this.#queuedEvents = [] + for (const event of queued) { + this.sendViaHttp({ ...event, eventId: generateEventId(), source: 'server-bridge' }) + } + return + } + + this.debugLog(`Scheduling reconnect in ${this.#wsReconnectDelay}ms (attempt ${this.#wsReconnectAttempts}/${this.#wsMaxReconnectAttempts})`) + this.#wsReconnectTimer = setTimeout(() => { + this.#wsReconnectTimer = null + this.connectWebSocket() + }, this.#wsReconnectDelay) + + // Exponential backoff, max 5s + this.#wsReconnectDelay = Math.min(this.#wsReconnectDelay * 2, 5000) +} +``` + +Similarly update `sendViaHttp()`: + +```typescript +private sendViaHttp(event: TanStackDevtoolsEvent) { + const port = this.#networkPort ?? getDevtoolsPort() + const host = this.#networkHost ?? getDevtoolsHost() ?? 'localhost' + const protocol = this.#networkProtocol ?? getDevtoolsProtocol() ?? 'http' + // ... rest unchanged +``` + +Update `sendViaNetwork()` to use HTTP-only when WebSocket gave up: + +```typescript +private sendViaNetwork(event: TanStackDevtoolsEvent) { + const eventWithId = { + ...event, + eventId: generateEventId(), + source: 'server-bridge' as const, + } + this.#sentEventIds.add(eventWithId.eventId!) + + if (this.#wsGaveUp) { + // HTTP-only mode — WebSocket permanently unavailable + this.sendViaHttp(eventWithId) + return + } + + if (this.#ws && this.#ws.readyState === (globalThis.WebSocket?.OPEN ?? 1)) { + this.debugLog('Sending event via WebSocket', eventWithId) + this.#ws.send(JSON.stringify(eventWithId)) + } else { + // HTTP POST fallback for when WebSocket is temporarily disconnected + this.sendViaHttp(eventWithId) + } +} +``` + +- [ ] **Step 7: Run tests** + +Run: `cd packages/event-bus-client && pnpm test:lib --run` +Expected: All PASS including the new network transport test + +- [ ] **Step 8: Write test — receive events from ServerEventBus via network transport** + +Add to the network transport test file: + +```typescript +it('should receive events from ServerEventBus via WebSocket', async () => { + serverBus = new ServerEventBus({ port: 0 }) + const port = await serverBus.start() + const serverEventTarget = globalThis.__TANSTACK_EVENT_TARGET__! + globalThis.__TANSTACK_EVENT_TARGET__ = null + + const client = createNetworkTransportClient({ + pluginId: 'test-receive', + port, + host: 'localhost', + protocol: 'http', + }) + + // Register a listener + const received = new Promise((resolve) => { + client.on('incoming', (event) => resolve(event)) + }) + + // Trigger an emit to force the WebSocket connection to open + client.emit('ping', {}) + // Wait for connection + await new Promise((resolve) => setTimeout(resolve, 500)) + + // Now dispatch an event from the server side (simulating another plugin) + serverEventTarget.dispatchEvent( + new CustomEvent('tanstack-dispatch-event', { + detail: { + type: 'test-receive:incoming', + payload: { msg: 'from-server' }, + pluginId: 'test-receive', + }, + }), + ) + + const event = await Promise.race([ + received, + new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), 3000)), + ]) + + expect(event.type).toBe('test-receive:incoming') + expect(event.payload).toEqual({ msg: 'from-server' }) + + client.destroy() +}) +``` + +- [ ] **Step 9: Run tests** + +Run: `cd packages/event-bus-client && pnpm test:lib --run` +Expected: All PASS + +- [ ] **Step 10: Write test — echo deduplication** + +```typescript +it('should not receive its own echoed events', async () => { + serverBus = new ServerEventBus({ port: 0 }) + const port = await serverBus.start() + globalThis.__TANSTACK_EVENT_TARGET__ = null + + const client = createNetworkTransportClient({ + pluginId: 'test-dedup', + port, + host: 'localhost', + protocol: 'http', + }) + + const receivedEvents: Array = [] + client.on('event', (e) => receivedEvents.push(e)) + + // Emit — this goes to server, server broadcasts back, client should dedup + client.emit('event', { data: 'test' }) + + // Wait for round-trip + await new Promise((resolve) => setTimeout(resolve, 1000)) + + // Should not have received our own event back + expect(receivedEvents.length).toBe(0) + + client.destroy() +}) +``` + +- [ ] **Step 11: Run tests** + +Run: `cd packages/event-bus-client && pnpm test:lib --run` +Expected: All PASS + +- [ ] **Step 12: Write test — events queue during connection and flush on connect** + +```typescript +it('should queue events during connection and flush when connected', async () => { + serverBus = new ServerEventBus({ port: 0 }) + const port = await serverBus.start() + const serverEventTarget = globalThis.__TANSTACK_EVENT_TARGET__! + globalThis.__TANSTACK_EVENT_TARGET__ = null + + const client = createNetworkTransportClient({ + pluginId: 'test-queue', + port, + host: 'localhost', + protocol: 'http', + }) + + const received: Array = [] + serverEventTarget.addEventListener('test-queue:event', (e) => { + received.push((e as CustomEvent).detail) + }) + + // Emit multiple events before connection is established + client.emit('event', { n: 1 }) + client.emit('event', { n: 2 }) + client.emit('event', { n: 3 }) + + // Wait for connection + flush + await new Promise((resolve) => setTimeout(resolve, 2000)) + + expect(received.length).toBe(3) + expect(received[0].payload).toEqual({ n: 1 }) + expect(received[1].payload).toEqual({ n: 2 }) + expect(received[2].payload).toEqual({ n: 3 }) + + client.destroy() +}) +``` + +- [ ] **Step 13: Run tests** + +Run: `cd packages/event-bus-client && pnpm test:lib --run` +Expected: All PASS + +- [ ] **Step 14: Verify existing tests still pass** + +Run: `cd packages/event-bus && pnpm test:lib --run` +Run: `cd packages/event-bus-client && pnpm test:lib --run` +Expected: All PASS + +- [ ] **Step 15: Commit** + +```bash +git add packages/event-bus-client/src/plugin.ts packages/event-bus-client/tests/network-transport.test.ts +git commit -m "feat: add WebSocket network transport fallback to EventClient + +When EventClient detects it is in an isolated server environment +(no shared globalThis.__TANSTACK_EVENT_TARGET__, no window), it +automatically connects to ServerEventBus via WebSocket. Bidirectional: +events emitted in the worker reach the devtools panel, and events +from the devtools panel reach listeners in the worker. + +Includes echo prevention via 200-entry ring buffer, exponential +backoff reconnection, HTTP POST fallback, and event queuing." +``` + +--- + +## Chunk 3: Final verification + +### Task 7: Full cross-package integration test + +**Files:** +- Test: `packages/event-bus-client/tests/integration.test.ts` + +- [ ] **Step 1: Write end-to-end integration test** + +Create `packages/event-bus-client/tests/integration.test.ts`: + +```typescript +// @vitest-environment node +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { ServerEventBus } from '@tanstack/devtools-event-bus/server' +import { createNetworkTransportClient } from '../src/plugin' + +describe('End-to-end: ServerEventBus + EventClient network transport', () => { + let serverBus: ServerEventBus + + beforeEach(() => { + globalThis.__TANSTACK_EVENT_TARGET__ = null + globalThis.__TANSTACK_DEVTOOLS_SERVER__ = null + globalThis.__TANSTACK_DEVTOOLS_WSS_SERVER__ = null + process.env.NODE_ENV = 'development' + }) + + afterEach(async () => { + serverBus?.stop() + globalThis.__TANSTACK_EVENT_TARGET__ = null + globalThis.__TANSTACK_DEVTOOLS_SERVER__ = null + globalThis.__TANSTACK_DEVTOOLS_WSS_SERVER__ = null + await new Promise((resolve) => setTimeout(resolve, 100)) + }) + + it('should support bidirectional events between isolated EventClient and ServerEventBus', async () => { + // 1. Start ServerEventBus + serverBus = new ServerEventBus({ port: 0 }) + const port = await serverBus.start() + const serverEventTarget = globalThis.__TANSTACK_EVENT_TARGET__! + + // 2. Simulate isolation: null out globalThis + globalThis.__TANSTACK_EVENT_TARGET__ = null + + // 3. Create isolated EventClient with network transport + const client = createNetworkTransportClient({ + pluginId: 'e2e-test', + port, + host: 'localhost', + protocol: 'http', + }) + + // 4. Set up listener on the isolated client + const clientReceived = new Promise((resolve) => { + client.on('from-server', (event) => resolve(event)) + }) + + // 5. Emit from client → should reach server + const serverReceived = new Promise((resolve) => { + serverEventTarget.addEventListener('e2e-test:from-client', (e) => { + resolve((e as CustomEvent).detail) + }) + }) + + client.emit('from-client', { direction: 'client-to-server' }) + + // Wait for connection + delivery + const fromClient = await Promise.race([ + serverReceived, + new Promise((_, reject) => setTimeout(() => reject(new Error('timeout: client→server')), 3000)), + ]) + + expect(fromClient.payload).toEqual({ direction: 'client-to-server' }) + + // 6. Now emit from server → should reach isolated client + // Wait a moment for WebSocket to be fully ready for receiving + await new Promise((resolve) => setTimeout(resolve, 200)) + + serverEventTarget.dispatchEvent( + new CustomEvent('tanstack-dispatch-event', { + detail: { + type: 'e2e-test:from-server', + payload: { direction: 'server-to-client' }, + pluginId: 'e2e-test', + }, + }), + ) + + const fromServer = await Promise.race([ + clientReceived, + new Promise((_, reject) => setTimeout(() => reject(new Error('timeout: server→client')), 3000)), + ]) + + expect(fromServer.payload).toEqual({ direction: 'server-to-client' }) + + client.destroy() + }) + + it('should handle multiple isolated clients simultaneously', async () => { + serverBus = new ServerEventBus({ port: 0 }) + const port = await serverBus.start() + const serverEventTarget = globalThis.__TANSTACK_EVENT_TARGET__! + globalThis.__TANSTACK_EVENT_TARGET__ = null + + const client1 = createNetworkTransportClient({ + pluginId: 'multi-1', + port, + host: 'localhost', + }) + + const client2 = createNetworkTransportClient({ + pluginId: 'multi-2', + port, + host: 'localhost', + }) + + // Both emit, both should reach server + const received: Array = [] + serverEventTarget.addEventListener('multi-1:ping', (e) => { + received.push((e as CustomEvent).detail) + }) + serverEventTarget.addEventListener('multi-2:ping', (e) => { + received.push((e as CustomEvent).detail) + }) + + client1.emit('ping', { from: 1 }) + client2.emit('ping', { from: 2 }) + + await new Promise((resolve) => setTimeout(resolve, 2000)) + + expect(received.length).toBe(2) + expect(received.map((e) => e.payload.from).sort()).toEqual([1, 2]) + + client1.destroy() + client2.destroy() + }) +}) +``` + +- [ ] **Step 2: Run integration tests** + +Run: `cd packages/event-bus-client && pnpm test:lib --run` +Expected: All PASS + +- [ ] **Step 3: Run ALL package tests to confirm no regressions** + +Run: `cd packages/event-bus && pnpm test:lib --run` +Run: `cd packages/event-bus-client && pnpm test:lib --run` +Expected: All PASS + +- [ ] **Step 4: Commit** + +```bash +git add packages/event-bus-client/tests/integration.test.ts +git commit -m "test: add end-to-end integration tests for network transport fallback" +``` + +- [ ] **Step 5: Final commit — update spec status** + +Update `docs/superpowers/specs/2026-03-12-network-transport-fallback-design.md` status from "Draft" to "Implemented". + +```bash +git add docs/superpowers/specs/2026-03-12-network-transport-fallback-design.md +git commit -m "docs: mark network transport fallback spec as implemented" +``` diff --git a/docs/superpowers/specs/2026-03-12-network-transport-fallback-design.md b/docs/superpowers/specs/2026-03-12-network-transport-fallback-design.md new file mode 100644 index 00000000..0c6c6e1c --- /dev/null +++ b/docs/superpowers/specs/2026-03-12-network-transport-fallback-design.md @@ -0,0 +1,160 @@ +# Network Transport Fallback for Isolated Server Runtimes + +**Date:** 2026-03-12 +**Status:** Implemented +**Issue:** https://github.com/TanStack/ai/issues/339 + +## Problem + +When TanStack Start uses Nitro v3's `nitro()` Vite plugin (or any runtime that isolates server code in a separate thread/process), the devtools event system breaks. `ServerEventBus` creates and listens on `globalThis.__TANSTACK_EVENT_TARGET__` in the Vite main thread, but in the isolated worker, `globalThis.__TANSTACK_EVENT_TARGET__` is `null` (no `ServerEventBus` there). When `EventClient` calls `getGlobalTarget()`, it falls through to creating a throwaway `EventTarget` that nobody is listening on. Events go nowhere. + +With `nitroV2Plugin` this doesn't occur because it's build-only — in dev, Start uses `RunnableDevEnvironment` which runs in-process and shares the same global. + +This affects any isolation layer: Nitro v3 worker threads, Cloudflare Workers, separate Node processes, etc. + +## Solution: Network Transport Fallback in EventClient + +When `EventClient` detects it's in an isolated server environment (no shared `globalThis.__TANSTACK_EVENT_TARGET__`, no `window`), it automatically falls back to a WebSocket connection to `ServerEventBus`. This is fully bidirectional — events emitted in the worker reach the devtools panel, and events from the devtools panel reach listeners in the worker. + +### Design Principles + +- **Zero API changes** — existing consumers of `EventClient` work unchanged +- **Zero configuration** — detection and fallback are automatic +- **Universal** — works for any isolation layer (worker threads, separate processes, edge runtimes) +- **Dev-only** — network transport only activates when the Vite plugin has replaced compile-time placeholders + +## Architecture + +### Detection: When to Use Network Transport + +`EventClient.getGlobalTarget()` currently has this fallback chain: + +1. `globalThis.__TANSTACK_EVENT_TARGET__` exists → use it (in-process, `ServerEventBus` is here) +2. `window` exists → use it (browser) +3. Create new `EventTarget` → goes nowhere (broken case) + +**Change:** When we hit case 3, check if devtools server coordinates are available via compile-time placeholders. Follow the existing codebase convention (used in `packages/event-bus/src/client/client.ts`): + +```typescript +declare const __TANSTACK_DEVTOOLS_PORT__: number | undefined +declare const __TANSTACK_DEVTOOLS_HOST__: string | undefined +declare const __TANSTACK_DEVTOOLS_PROTOCOL__: 'http' | 'https' | undefined +``` + +These are already replaced by the Vite plugin's `connection-injection` transform for packages matching `@tanstack/devtools*` or `@tanstack/event-bus*`. The package `@tanstack/devtools-event-client` matches via `@tanstack/devtools`. If replaced with real values (`typeof __TANSTACK_DEVTOOLS_PORT__ !== 'undefined'`), activate network transport. If still undefined, no-op (current behavior). + +**One-time detection:** The `#useNetworkTransport` flag is set once on the first call to `getGlobalTarget()` and cached. Subsequent calls return the cached result without re-evaluating. + +### ServerEventBus: Server Bridge Connections + +`ServerEventBus` must distinguish two types of WebSocket clients: + +**Browser clients** (current): Messages go to `emitToServer()` only — dispatches on in-process EventTarget. Correct because the browser already has the event locally. + +**Server bridge clients** (new): Messages go to `emit()` — both `emitEventToClients()` (browser devtools sees it) AND `emitToServer()` (in-process listeners get it). Conversely, in-process events already reach all WebSocket clients via `emitEventToClients()`, so server bridges receive them automatically. + +**Differentiation:** Server bridges connect to `/__devtools/ws?bridge=server`. This requires two changes to the existing upgrade handlers: + +1. **URL matching:** The WebSocket upgrade handlers use exact string equality (`req.url === '/__devtools/ws'`) in both the standalone server (line 305) and external server (line 273) code paths. Both must change to prefix matching or URL parsing (e.g., `req.url?.startsWith('/__devtools/ws')`) to support the `?bridge=server` query parameter. Note: the SSE (`/__devtools/sse`) and POST (`/__devtools/send`) URL checks do NOT need this change since they don't use query parameters. +2. **`handleNewConnection` signature:** The current `wss.on('connection', (ws: WebSocket) => {...})` callback only receives `ws`. It must also accept the `req` parameter (which `wss.emit('connection', ws, req)` already passes) to inspect the URL and tag the connection as a server bridge. + +**Echo prevention:** Events include a unique `eventId`. The sending `EventClient` tracks sent IDs in a ring buffer (200 entries) and ignores incoming events with matching IDs. + +**Multi-worker echo safety:** When multiple isolated workers each have bridge connections, an event from worker A is broadcast by `ServerEventBus` to worker B (correct) and back to worker A (deduped by ring buffer). Worker B's listeners may fire but should not re-emit the same event — this is application-level responsibility (plugins should not blindly echo). No framework-level concern here since `emit()` and `on()` are separate code paths. + +### EventClient: Network Transport Flow + +**New private fields:** +- `#useNetworkTransport: boolean` +- `#ws: WebSocket | null` +- `#sentEventIds: RingBuffer` (200 entries) + +**Initialization:** +- Constructor unchanged — no API changes +- `getGlobalTarget()` detects isolated environment, sets `#useNetworkTransport = true` +- Returns a local `EventTarget` for internal event dispatching (`.on()` listeners register here) + +**Connection (lazy, on first `emit()`):** +- Skip `tanstack-connect` handshake, go straight to WebSocket: `ws://${DEVTOOLS_HOST}:${DEVTOOLS_PORT}/__devtools/ws?bridge=server` +- On open: set `#connected = true`, flush `#queuedEvents` +- On message: parse event, check `eventId` against `#sentEventIds` for dedup, dispatch on local EventTarget (`.on()` listeners fire) +- On close/error: reconnect with exponential backoff (100ms → 200ms → 400ms... up to 5s) + +**Emit path (when `#useNetworkTransport`):** +- Generate unique `eventId`, add to `#sentEventIds` +- Set `source: "server-bridge"` on the event +- If connected: send JSON over WebSocket +- If not yet connected: queue (existing queuing logic reused) + +**Listen path (`.on()` / `.onAll()` / `.onAllPluginEvents()`):** +- Register on local EventTarget as they do now +- Incoming WebSocket messages dispatched as CustomEvents on local EventTarget +- Listeners work transparently — they don't know events came from the network + +### Event Protocol Changes + +Two new optional fields added to `TanStackDevtoolsEvent`: + +```typescript +interface TanStackDevtoolsEvent { + type: TEventName + payload: TPayload + pluginId?: string + eventId?: string // unique per emission, for dedup + source?: 'server-bridge' // helps ServerEventBus route +} +``` + +- `eventId`: Short random string via counter+timestamp (preferred for broad runtime compatibility over `crypto.randomUUID()` which may not be available in all edge runtimes). Used by sending `EventClient` to ignore echoed events. Ring buffer of 200 entries bounds memory. +- `source`: Set to `"server-bridge"` by network-transport `EventClient`. `ServerEventBus` uses this for routing decisions. For WebSocket connections, the `?bridge=server` URL param is the primary differentiator. For the HTTP POST fallback (`/__devtools/send`), the `source` field in the JSON body is inspected to determine routing: `"server-bridge"` → `emit()` (broadcast to browser clients AND in-process EventTarget), absent → `emitToServer()` only (current browser client behavior). + +Additive changes — existing events without these fields work exactly as before. + +## Error Handling and Edge Cases + +**WebSocket unavailability:** Some runtimes lack native `WebSocket` and won't have `ws` package. Fall back to HTTP-only: POST to `/__devtools/send` for emit, no receive. Degraded mode (emit-only) but better than nothing. The POST handler must check the `source` field to route server-bridge messages through `emit()` (broadcast) rather than just `emitToServer()`. + +**Dev-only guard:** Network transport only activates when placeholders are replaced. In production, `removeDevtoolsOnBuild` strips devtools code. Even without that, unreplaced placeholders prevent activation (`typeof DEVTOOLS_PORT === 'number'` check). + +**HMR / server restart:** WebSocket breaks on server restart. `EventClient` reconnects with exponential backoff. Events queue during reconnection. + +**Multiple EventClients in same worker:** Each instance independently connects via WebSocket. Fine for v1 — shared connection optimization possible later. + +**Queue preservation on network fallback:** The current `stopConnectLoop()` clears `#queuedEvents`. When transitioning from failed in-process handshake to network transport, the queue must be preserved. The network transport path should not call `stopConnectLoop()` or should preserve the queue before it's cleared. + +**Ordering:** WebSocket is ordered (TCP). No reordering concerns. + +## Files Changed + +### `packages/event-bus/src/server/server.ts` (ServerEventBus) +- Add optional `eventId` and `source` fields to `TanStackDevtoolsEvent` interface +- Change upgrade URL matching from exact equality (`=== '/__devtools/ws'`) to prefix matching or URL parsing to support `?bridge=server` query param +- Extend `handleNewConnection` to accept the `req` parameter from WebSocket `connection` event +- Track server bridge vs browser client WebSocket connections (tag based on `?bridge=server`) +- Route server bridge WebSocket messages through `emit()` (both `emitEventToClients` and `emitToServer`) +- Update POST handler (`/__devtools/send`) to check `source` field and route `"server-bridge"` messages through `emit()` instead of just `emitToServer()` — both the standalone handler (in `createSSEServer()`) and the external server handler (in `start()`) need this change + +### `packages/event-bus-client/src/plugin.ts` (EventClient) +- Add `declare const __TANSTACK_DEVTOOLS_PORT__` / `__TANSTACK_DEVTOOLS_HOST__` / `__TANSTACK_DEVTOOLS_PROTOCOL__` placeholders (following existing codebase convention from `client.ts`) +- Modify `getGlobalTarget()` to detect isolated server environment and set `#useNetworkTransport` (one-time, cached) +- Add WebSocket connection logic (lazy, on first emit) +- Add `eventId` generation (counter+timestamp) and dedup ring buffer (200 entries) +- Add reconnect with exponential backoff +- Incoming WebSocket messages dispatched on local EventTarget for `.on()` listeners +- HTTP POST fallback when WebSocket unavailable +- Preserve queued events when transitioning from failed in-process to network transport + +### `packages/event-bus/src/client/client.ts` (ClientEventBus) +- Add optional `eventId` and `source` fields to its copy of `TanStackDevtoolsEvent` interface (must stay in sync with server.ts and plugin.ts copies) + +### `packages/event-bus-client/src/plugin.ts` (EventClient interface) +- Add optional `eventId` and `source` fields to its copy of `TanStackDevtoolsEvent` interface + +### Tests +- `packages/event-bus/tests/` — tests for server bridge connection routing, POST source-based routing +- `packages/event-bus-client/tests/` — tests for network transport detection, fallback, dedup, reconnection + +### No changes to: +- Vite plugin (`devtools-vite`) — placeholder injection already covers `@tanstack/devtools-event-client` (matches via `@tanstack/devtools` in package name) +- Browser-side `ClientEventBus` — unaffected beyond the interface update +- Any consuming libraries (`@tanstack/ai`, etc.) — transparent diff --git a/examples/react/start-cloudflare/.gitignore b/examples/react/start-cloudflare/.gitignore new file mode 100644 index 00000000..3c53f8cf --- /dev/null +++ b/examples/react/start-cloudflare/.gitignore @@ -0,0 +1,11 @@ +node_modules +.DS_Store +dist +dist-ssr +*.local +.env +.nitro +.tanstack +.wrangler +.output +.vinxi diff --git a/examples/react/start-cloudflare/package.json b/examples/react/start-cloudflare/package.json new file mode 100644 index 00000000..f9d072e8 --- /dev/null +++ b/examples/react/start-cloudflare/package.json @@ -0,0 +1,32 @@ +{ + "name": "start-cloudflare", + "private": true, + "type": "module", + "scripts": { + "dev": "vite dev --port 3002", + "build": "vite build", + "preview": "vite preview", + "deploy": "npm run build && wrangler deploy" + }, + "dependencies": { + "@cloudflare/vite-plugin": "^1.13.8", + "@tanstack/devtools-event-client": "workspace:*", + "@tanstack/react-devtools": "workspace:*", + "@tanstack/react-router": "^1.132.0", + "@tanstack/react-start": "^1.132.0", + "@tanstack/router-plugin": "^1.132.0", + "react": "^19.2.0", + "react-dom": "^19.2.0", + "vite-tsconfig-paths": "^6.0.2" + }, + "devDependencies": { + "@tanstack/devtools-vite": "workspace:*", + "@types/node": "^22.15.2", + "@types/react": "^19.2.0", + "@types/react-dom": "^19.2.0", + "@vitejs/plugin-react": "^5.0.4", + "typescript": "~5.9.2", + "vite": "^7.1.7", + "wrangler": "^4.40.3" + } +} diff --git a/examples/react/start-cloudflare/src/devtools/ServerEventsPanel.tsx b/examples/react/start-cloudflare/src/devtools/ServerEventsPanel.tsx new file mode 100644 index 00000000..53c84075 --- /dev/null +++ b/examples/react/start-cloudflare/src/devtools/ServerEventsPanel.tsx @@ -0,0 +1,162 @@ +import { useEffect, useState } from 'react' +import { serverEventClient } from './server-event-client' +import type { ServerEvent } from './server-event-client' + +export function ServerEventsPanel() { + const [events, setEvents] = useState>([]) + + useEffect(() => { + const cleanup = serverEventClient.on( + 'server-fn-called', + (event) => { + setEvents((prev) => [event.payload, ...prev].slice(0, 100)) + }, + { withEventTarget: true }, + ) + + return cleanup + }, []) + + const formatTime = (timestamp: number) => { + return new Date(timestamp).toLocaleTimeString('en-US', { + hour12: false, + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + fractionalSecondDigits: 3, + }) + } + + return ( +
+
+

+ Server Events ({events.length}) +

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

+ Nitro v3 Devtools Test +

+

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

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

+ Server responses: +

+
+ {results.map((r, i) => ( +
+ {r} +
+ ))} +
+
+ )} +
+ ) +} diff --git a/examples/react/start-nitro/tsconfig.json b/examples/react/start-nitro/tsconfig.json new file mode 100644 index 00000000..6bf32b6c --- /dev/null +++ b/examples/react/start-nitro/tsconfig.json @@ -0,0 +1,24 @@ +{ + "include": ["**/*.ts", "**/*.tsx"], + "compilerOptions": { + "target": "ES2022", + "jsx": "react-jsx", + "module": "ESNext", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "types": ["vite/client"], + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": false, + "noEmit": true, + "skipLibCheck": true, + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true, + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + } + } +} diff --git a/examples/react/start-nitro/vite.config.ts b/examples/react/start-nitro/vite.config.ts new file mode 100644 index 00000000..e2300abc --- /dev/null +++ b/examples/react/start-nitro/vite.config.ts @@ -0,0 +1,24 @@ +import { defineConfig } from 'vite' +import { devtools } from '@tanstack/devtools-vite' +import { tanstackStart } from '@tanstack/react-start/plugin/vite' +import viteReact from '@vitejs/plugin-react' +import viteTsConfigPaths from 'vite-tsconfig-paths' +import { nitro } from 'nitro/vite' + +const config = defineConfig({ + plugins: [ + devtools({ + consolePiping: {}, + }), + // Nitro v3 runs server code in a worker thread (separate globalThis). + // This is the exact setup that previously broke devtools event delivery. + nitro(), + viteTsConfigPaths({ + projects: ['./tsconfig.json'], + }), + tanstackStart(), + viteReact(), + ], +}) + +export default config diff --git a/package.json b/package.json index e3baca1d..74a30a5b 100644 --- a/package.json +++ b/package.json @@ -51,7 +51,7 @@ }, { "path": "packages/event-bus-client/dist/esm/plugin.js", - "limit": "1.2 KB" + "limit": "2.4 KB" } ], "devDependencies": { @@ -97,4 +97,4 @@ "bin": { "intent": "./bin/intent.js" } -} +} \ No newline at end of file diff --git a/packages/event-bus-client/src/index.ts b/packages/event-bus-client/src/index.ts index 6b3402a0..001e9cc7 100644 --- a/packages/event-bus-client/src/index.ts +++ b/packages/event-bus-client/src/index.ts @@ -1 +1 @@ -export { EventClient } from './plugin' +export { EventClient, createNetworkTransportClient } from './plugin' diff --git a/packages/event-bus-client/src/plugin.ts b/packages/event-bus-client/src/plugin.ts index 9fd2d07b..03ec5d62 100644 --- a/packages/event-bus-client/src/plugin.ts +++ b/packages/event-bus-client/src/plugin.ts @@ -1,12 +1,58 @@ +import { RingBuffer } from './ring-buffer' + interface TanStackDevtoolsEvent { type: TEventName payload: TPayload pluginId?: string // Optional pluginId to filter events by plugin + eventId?: string + source?: 'server-bridge' } declare global { var __TANSTACK_EVENT_TARGET__: EventTarget | null } +// Compile-time placeholders replaced by the Vite plugin's connection-injection transform. +// When not replaced (no Vite plugin), these remain undefined and network transport is disabled. +declare const __TANSTACK_DEVTOOLS_PORT__: number | undefined +declare const __TANSTACK_DEVTOOLS_HOST__: string | undefined +declare const __TANSTACK_DEVTOOLS_PROTOCOL__: 'http' | 'https' | undefined + +function getDevtoolsPort(): number | undefined { + try { + return typeof __TANSTACK_DEVTOOLS_PORT__ !== 'undefined' + ? __TANSTACK_DEVTOOLS_PORT__ + : undefined + } catch { + return undefined + } +} + +function getDevtoolsHost(): string | undefined { + try { + return typeof __TANSTACK_DEVTOOLS_HOST__ !== 'undefined' + ? __TANSTACK_DEVTOOLS_HOST__ + : undefined + } catch { + return undefined + } +} + +function getDevtoolsProtocol(): 'http' | 'https' | undefined { + try { + return typeof __TANSTACK_DEVTOOLS_PROTOCOL__ !== 'undefined' + ? __TANSTACK_DEVTOOLS_PROTOCOL__ + : undefined + } catch { + return undefined + } +} + +let globalEventIdCounter = 0 + +function generateEventId(): string { + return `${++globalEventIdCounter}-${Date.now()}` +} + type AllDevtoolsEvents> = { [Key in keyof TEventMap & string]: TanStackDevtoolsEvent }[keyof TEventMap & string] @@ -25,6 +71,20 @@ export class EventClient> { #connecting = false #failedToConnect = false #internalEventTarget: EventTarget | null = null + #useNetworkTransport = false + #networkTransportDetected = false // one-time detection flag + #cachedLocalTarget: EventTarget | null = null // cached for consistent listener registration + #ws: WebSocket | null = null + #wsConnecting = false + #wsReconnectTimer: ReturnType | null = null + #wsReconnectDelay = 100 // exponential backoff: 100, 200, 400, ... 5000ms + #wsMaxReconnectAttempts = 10 + #wsReconnectAttempts = 0 + #wsGaveUp = false // true when WebSocket is permanently unavailable, use HTTP-only + #sentEventIds = new RingBuffer(200) + #networkPort: number | undefined = undefined + #networkHost: string | undefined = undefined + #networkProtocol: 'http' | 'https' | undefined = undefined #onConnected = () => { this.debugLog('Connected to event bus') @@ -127,35 +187,56 @@ export class EventClient> { this.debugLog('Using global event target') return globalThis.__TANSTACK_EVENT_TARGET__ } - // CLient event target is the browser window object + // Client event target is the browser window object if ( typeof window !== 'undefined' && typeof window.addEventListener !== 'undefined' ) { this.debugLog('Using window as event target') - return window } - // Protect against non-web environments like react-native - const eventTarget = - typeof EventTarget !== 'undefined' ? new EventTarget() : undefined - // For non-web environments like react-native - if ( - typeof eventTarget === 'undefined' || - typeof eventTarget.addEventListener === 'undefined' - ) { + // We're in an isolated server environment (worker thread, separate process, etc.) + // Check if devtools server coordinates are available (Vite plugin replaced placeholders) + if (!this.#networkTransportDetected) { + this.#networkTransportDetected = true + const port = getDevtoolsPort() + const host = getDevtoolsHost() + const protocol = getDevtoolsProtocol() + if (port !== undefined) { + this.#useNetworkTransport = true + this.#networkPort = port + this.#networkHost = host + this.#networkProtocol = protocol + this.debugLog( + 'Network transport activated — devtools server detected at port', + port, + ) + } + } + + // Return cached local EventTarget to ensure .on() and emit() use the same instance + if (this.#cachedLocalTarget) { + return this.#cachedLocalTarget + } + + // Protect against non-web environments like react-native + if (typeof EventTarget === 'undefined') { this.debugLog( 'No event mechanism available, running in non-web environment', ) - return { + const noop = { addEventListener: () => {}, removeEventListener: () => {}, dispatchEvent: () => false, } + this.#cachedLocalTarget = noop as any + return noop } - this.debugLog('Using new EventTarget as fallback') + const eventTarget = new EventTarget() + this.#cachedLocalTarget = eventTarget + this.debugLog('Using cached local EventTarget as fallback') return eventTarget } @@ -187,6 +268,172 @@ export class EventClient> { this.dispatchCustomEvent('tanstack-dispatch-event', event) } + private connectWebSocket() { + if (this.#wsConnecting || this.#ws) return + if (this.#wsGaveUp) return // WebSocket permanently unavailable, use HTTP-only + + this.#wsConnecting = true + + const port = this.#networkPort ?? getDevtoolsPort() + const host = this.#networkHost ?? getDevtoolsHost() ?? 'localhost' + const protocol = this.#networkProtocol ?? getDevtoolsProtocol() ?? 'http' + const wsProtocol = protocol === 'https' ? 'wss' : 'ws' + const url = `${wsProtocol}://${host}:${port}/__devtools/ws?bridge=server` + + this.debugLog('Connecting to ServerEventBus via WebSocket', url) + + try { + const ws = new WebSocket(url) + + ws.addEventListener('open', () => { + this.debugLog('WebSocket connected to ServerEventBus') + this.#ws = ws + this.#wsConnecting = false + this.#connected = true + this.#wsReconnectDelay = 100 // reset backoff + this.#wsReconnectAttempts = 0 + + // Flush queued events + const queued = [...this.#queuedEvents] + this.#queuedEvents = [] + for (const event of queued) { + this.sendViaNetwork(event) + } + }) + + ws.addEventListener('message', (e) => { + try { + const data = typeof e.data === 'string' ? e.data : e.data.toString() + const event = JSON.parse(data) + + // Dedup: ignore events we sent ourselves + if (event.eventId && this.#sentEventIds.has(event.eventId)) { + this.debugLog('Ignoring echoed event', event.eventId) + return + } + + this.debugLog('Received event via network transport', event) + + // Dispatch on local EventTarget so .on() listeners fire + const target = this.#eventTarget() + try { + target.dispatchEvent(new CustomEvent(event.type, { detail: event })) + target.dispatchEvent( + new CustomEvent('tanstack-devtools-global', { detail: event }), + ) + } catch { + // EventTarget may not support CustomEvent in all environments + } + } catch { + this.debugLog('Failed to parse incoming WebSocket message') + } + }) + + ws.addEventListener('close', () => { + this.debugLog('WebSocket connection closed') + this.#ws = null + this.#connected = false + this.#wsConnecting = false + this.scheduleReconnect() + }) + + ws.addEventListener('error', () => { + this.debugLog('WebSocket connection error') + this.#wsConnecting = false + // In non-browser runtimes, 'close' may not follow 'error'. + // Guard: only schedule reconnect if close handler hasn't already. + if (!this.#wsReconnectTimer && !this.#ws) { + this.scheduleReconnect() + } + }) + } catch { + this.debugLog('Failed to create WebSocket connection') + this.#wsConnecting = false + this.scheduleReconnect() + } + } + + private scheduleReconnect() { + if (this.#wsReconnectTimer) return + if (!this.#useNetworkTransport) return + if (this.#wsGaveUp) return + + this.#wsReconnectAttempts++ + if (this.#wsReconnectAttempts >= this.#wsMaxReconnectAttempts) { + this.debugLog( + 'WebSocket permanently unavailable, falling back to HTTP-only', + ) + this.#wsGaveUp = true + // Flush any queued events via HTTP POST + const queued = [...this.#queuedEvents] + this.#queuedEvents = [] + for (const event of queued) { + this.sendViaHttp({ + ...event, + eventId: generateEventId(), + source: 'server-bridge', + }) + } + return + } + + this.debugLog( + `Scheduling reconnect in ${this.#wsReconnectDelay}ms (attempt ${this.#wsReconnectAttempts}/${this.#wsMaxReconnectAttempts})`, + ) + this.#wsReconnectTimer = setTimeout(() => { + this.#wsReconnectTimer = null + this.connectWebSocket() + }, this.#wsReconnectDelay) + + // Exponential backoff, max 5s + this.#wsReconnectDelay = Math.min(this.#wsReconnectDelay * 2, 5000) + } + + private sendViaNetwork(event: TanStackDevtoolsEvent) { + const eventWithId = { + ...event, + eventId: generateEventId(), + source: 'server-bridge' as const, + } + this.#sentEventIds.add(eventWithId.eventId!) + + if (this.#wsGaveUp) { + // HTTP-only mode — WebSocket permanently unavailable + this.sendViaHttp(eventWithId) + return + } + + if (this.#ws && this.#ws.readyState === (globalThis.WebSocket?.OPEN ?? 1)) { + this.debugLog('Sending event via WebSocket', eventWithId) + this.#ws.send(JSON.stringify(eventWithId)) + } else { + // HTTP POST fallback for when WebSocket is temporarily disconnected + this.sendViaHttp(eventWithId) + } + } + + private sendViaHttp(event: TanStackDevtoolsEvent) { + const port = this.#networkPort ?? getDevtoolsPort() + const host = this.#networkHost ?? getDevtoolsHost() ?? 'localhost' + const protocol = this.#networkProtocol ?? getDevtoolsProtocol() ?? 'http' + + if (!port) return + + this.debugLog('Sending event via HTTP POST fallback', event) + + try { + fetch(`${protocol}://${host}:${port}/__devtools/send`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(event), + }).catch(() => { + this.debugLog('HTTP POST fallback failed') + }) + } catch { + this.debugLog('fetch not available for HTTP POST fallback') + } + } + createEventPayload( eventSuffix: TEvent, payload: TEventMap[TEvent], @@ -222,6 +469,20 @@ export class EventClient> { ) } + // Network transport path — skip in-process handshake entirely. + // Must come BEFORE #failedToConnect check because in isolated workers + // the in-process handshake always fails. + if (this.#useNetworkTransport) { + const event = this.createEventPayload(eventSuffix, payload) + if (!this.#connected) { + this.#queuedEvents.push(event) + this.connectWebSocket() + return + } + this.sendViaNetwork(event) + return + } + if (this.#failedToConnect) { this.debugLog('Previously failed to connect, not emitting to bus') return @@ -316,4 +577,60 @@ export class EventClient> { handler, ) } + + /** @internal — only for testing and createNetworkTransportClient */ + ___enableNetworkTransport( + port: number, + host: string, + protocol: 'http' | 'https', + ) { + this.#useNetworkTransport = true + this.#networkTransportDetected = true + this.#networkPort = port + this.#networkHost = host + this.#networkProtocol = protocol + } + + /** @internal */ + ___destroyNetworkTransport() { + if (this.#wsReconnectTimer) { + clearTimeout(this.#wsReconnectTimer) + this.#wsReconnectTimer = null + } + if (this.#ws) { + this.#ws.close() + this.#ws = null + } + this.#connected = false + this.#useNetworkTransport = false + this.#wsGaveUp = false + this.#wsReconnectAttempts = 0 + this.#wsReconnectDelay = 100 + } +} + +/** + * Creates an EventClient with network transport explicitly enabled. + * Used for testing and for environments where compile-time placeholder + * replacement is not available. + */ +export function createNetworkTransportClient< + TEventMap extends Record, +>({ + pluginId, + port, + host = 'localhost', + protocol = 'http', + debug = false, +}: { + pluginId: string + port: number + host?: string + protocol?: 'http' | 'https' + debug?: boolean +}): EventClient & { destroy: () => void } { + const client = new EventClient({ pluginId, debug }) + ;(client as any).___enableNetworkTransport(port, host, protocol) + ;(client as any).destroy = () => (client as any).___destroyNetworkTransport() + return client as EventClient & { destroy: () => void } } diff --git a/packages/event-bus-client/src/ring-buffer.ts b/packages/event-bus-client/src/ring-buffer.ts new file mode 100644 index 00000000..c816dae4 --- /dev/null +++ b/packages/event-bus-client/src/ring-buffer.ts @@ -0,0 +1,26 @@ +export class RingBuffer { + #buffer: Array + #set: Set + #index = 0 + #capacity: number + + constructor(capacity: number) { + this.#capacity = capacity + this.#buffer = new Array(capacity).fill('') + this.#set = new Set() + } + + add(item: string) { + const evicted = this.#buffer[this.#index] + if (evicted) { + this.#set.delete(evicted) + } + this.#buffer[this.#index] = item + this.#set.add(item) + this.#index = (this.#index + 1) % this.#capacity + } + + has(item: string): boolean { + return this.#set.has(item) + } +} diff --git a/packages/event-bus-client/tests/integration.test.ts b/packages/event-bus-client/tests/integration.test.ts new file mode 100644 index 00000000..1d73b1d5 --- /dev/null +++ b/packages/event-bus-client/tests/integration.test.ts @@ -0,0 +1,128 @@ +// @vitest-environment node +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { ServerEventBus } from '@tanstack/devtools-event-bus/server' +import { createNetworkTransportClient } from '../src/plugin' + +describe('End-to-end: ServerEventBus + EventClient network transport', () => { + let serverBus: ServerEventBus + + beforeEach(() => { + globalThis.__TANSTACK_EVENT_TARGET__ = null + globalThis.__TANSTACK_DEVTOOLS_SERVER__ = null + globalThis.__TANSTACK_DEVTOOLS_WSS_SERVER__ = null + process.env.NODE_ENV = 'development' + }) + + afterEach(async () => { + serverBus?.stop() + globalThis.__TANSTACK_EVENT_TARGET__ = null + globalThis.__TANSTACK_DEVTOOLS_SERVER__ = null + globalThis.__TANSTACK_DEVTOOLS_WSS_SERVER__ = null + await new Promise((resolve) => setTimeout(resolve, 100)) + }) + + it('should support bidirectional events between isolated EventClient and ServerEventBus', async () => { + // 1. Start ServerEventBus + serverBus = new ServerEventBus({ port: 0 }) + const port = await serverBus.start() + const serverEventTarget = globalThis.__TANSTACK_EVENT_TARGET__! + + // 2. Simulate isolation: null out globalThis + globalThis.__TANSTACK_EVENT_TARGET__ = null + + // 3. Create isolated EventClient with network transport + const client = createNetworkTransportClient({ + pluginId: 'e2e-test', + port, + host: 'localhost', + protocol: 'http', + }) + + // 4. Set up listener on the isolated client + const clientReceived = new Promise((resolve) => { + client.on('from-server', (event) => resolve(event)) + }) + + // 5. Emit from client → should reach server + const serverReceived = new Promise((resolve) => { + serverEventTarget.addEventListener('e2e-test:from-client', (e) => { + resolve((e as CustomEvent).detail) + }) + }) + + client.emit('from-client', { direction: 'client-to-server' }) + + // Wait for connection + delivery + const fromClient = await Promise.race([ + serverReceived, + new Promise((_, reject) => + setTimeout(() => reject(new Error('timeout: client→server')), 3000), + ), + ]) + + expect(fromClient.payload).toEqual({ direction: 'client-to-server' }) + + // 6. Now emit from server → should reach isolated client + await new Promise((resolve) => setTimeout(resolve, 200)) + + serverEventTarget.dispatchEvent( + new CustomEvent('tanstack-dispatch-event', { + detail: { + type: 'e2e-test:from-server', + payload: { direction: 'server-to-client' }, + pluginId: 'e2e-test', + }, + }), + ) + + const fromServer = await Promise.race([ + clientReceived, + new Promise((_, reject) => + setTimeout(() => reject(new Error('timeout: server→client')), 3000), + ), + ]) + + expect(fromServer.payload).toEqual({ direction: 'server-to-client' }) + + client.destroy() + }) + + it('should handle multiple isolated clients simultaneously', async () => { + serverBus = new ServerEventBus({ port: 0 }) + const port = await serverBus.start() + const serverEventTarget = globalThis.__TANSTACK_EVENT_TARGET__! + globalThis.__TANSTACK_EVENT_TARGET__ = null + + const client1 = createNetworkTransportClient({ + pluginId: 'multi-1', + port, + host: 'localhost', + }) + + const client2 = createNetworkTransportClient({ + pluginId: 'multi-2', + port, + host: 'localhost', + }) + + // Both emit, both should reach server + const received: Array = [] + serverEventTarget.addEventListener('multi-1:ping', (e) => { + received.push((e as CustomEvent).detail) + }) + serverEventTarget.addEventListener('multi-2:ping', (e) => { + received.push((e as CustomEvent).detail) + }) + + client1.emit('ping', { from: 1 }) + client2.emit('ping', { from: 2 }) + + await new Promise((resolve) => setTimeout(resolve, 2000)) + + expect(received.length).toBe(2) + expect(received.map((e) => e.payload.from).sort()).toEqual([1, 2]) + + client1.destroy() + client2.destroy() + }) +}) diff --git a/packages/event-bus-client/tests/network-transport.test.ts b/packages/event-bus-client/tests/network-transport.test.ts new file mode 100644 index 00000000..8c65c79d --- /dev/null +++ b/packages/event-bus-client/tests/network-transport.test.ts @@ -0,0 +1,161 @@ +// @vitest-environment node +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { ServerEventBus } from '@tanstack/devtools-event-bus/server' +import { createNetworkTransportClient } from '../src/plugin' + +describe('EventClient network transport emit', () => { + let serverBus: ServerEventBus + const originalNodeEnv = process.env.NODE_ENV + + beforeEach(() => { + globalThis.__TANSTACK_DEVTOOLS_SERVER__ = null + globalThis.__TANSTACK_DEVTOOLS_WSS_SERVER__ = null + globalThis.__TANSTACK_EVENT_TARGET__ = null + process.env.NODE_ENV = 'development' + }) + + afterEach(async () => { + serverBus?.stop() + globalThis.__TANSTACK_DEVTOOLS_SERVER__ = null + globalThis.__TANSTACK_DEVTOOLS_WSS_SERVER__ = null + globalThis.__TANSTACK_EVENT_TARGET__ = null + process.env.NODE_ENV = originalNodeEnv + await new Promise((resolve) => setTimeout(resolve, 50)) + }) + + it('should emit events to ServerEventBus via WebSocket when using network transport', async () => { + serverBus = new ServerEventBus({ port: 0 }) + const port = await serverBus.start() + const serverEventTarget = globalThis.__TANSTACK_EVENT_TARGET__! + globalThis.__TANSTACK_EVENT_TARGET__ = null + + const client = createNetworkTransportClient({ + pluginId: 'test-network', + port, + host: 'localhost', + protocol: 'http', + }) + + const received = new Promise((resolve) => { + serverEventTarget.addEventListener('test-network:event', (e) => { + resolve((e as CustomEvent).detail) + }) + }) + + client.emit('event', { hello: 'world' }) + + const event = await Promise.race([ + received, + new Promise((_, reject) => + setTimeout(() => reject(new Error('timeout')), 3000), + ), + ]) + + expect(event.type).toBe('test-network:event') + expect(event.payload).toEqual({ hello: 'world' }) + expect(event.source).toBe('server-bridge') + + client.destroy() + }) + + it('should receive events from ServerEventBus via WebSocket', async () => { + serverBus = new ServerEventBus({ port: 0 }) + const port = await serverBus.start() + const serverEventTarget = globalThis.__TANSTACK_EVENT_TARGET__! + globalThis.__TANSTACK_EVENT_TARGET__ = null + + const client = createNetworkTransportClient({ + pluginId: 'test-receive', + port, + host: 'localhost', + protocol: 'http', + }) + + const received = new Promise((resolve) => { + client.on('incoming', (event) => resolve(event)) + }) + + // Trigger emit to force WebSocket connection + client.emit('ping', {}) + await new Promise((resolve) => setTimeout(resolve, 500)) + + // Dispatch event from server side + serverEventTarget.dispatchEvent( + new CustomEvent('tanstack-dispatch-event', { + detail: { + type: 'test-receive:incoming', + payload: { msg: 'from-server' }, + pluginId: 'test-receive', + }, + }), + ) + + const event = await Promise.race([ + received, + new Promise((_, reject) => + setTimeout(() => reject(new Error('timeout')), 3000), + ), + ]) + + expect(event.type).toBe('test-receive:incoming') + expect(event.payload).toEqual({ msg: 'from-server' }) + + client.destroy() + }) + + it('should not receive its own echoed events', async () => { + serverBus = new ServerEventBus({ port: 0 }) + const port = await serverBus.start() + globalThis.__TANSTACK_EVENT_TARGET__ = null + + const client = createNetworkTransportClient({ + pluginId: 'test-dedup', + port, + host: 'localhost', + protocol: 'http', + }) + + const receivedEvents: Array = [] + client.on('event', (e) => receivedEvents.push(e)) + + client.emit('event', { data: 'test' }) + + await new Promise((resolve) => setTimeout(resolve, 1000)) + + expect(receivedEvents.length).toBe(0) + + client.destroy() + }) + + it('should queue events during connection and flush when connected', async () => { + serverBus = new ServerEventBus({ port: 0 }) + const port = await serverBus.start() + const serverEventTarget = globalThis.__TANSTACK_EVENT_TARGET__! + globalThis.__TANSTACK_EVENT_TARGET__ = null + + const client = createNetworkTransportClient({ + pluginId: 'test-queue', + port, + host: 'localhost', + protocol: 'http', + }) + + const received: Array = [] + serverEventTarget.addEventListener('test-queue:event', (e) => { + received.push((e as CustomEvent).detail) + }) + + client.emit('event', { n: 1 }) + client.emit('event', { n: 2 }) + client.emit('event', { n: 3 }) + + await new Promise((resolve) => setTimeout(resolve, 2000)) + + expect(received.length).toBe(3) + expect(received[0].payload).toEqual({ n: 1 }) + expect(received[1].payload).toEqual({ n: 2 }) + expect(received[2].payload).toEqual({ n: 3 }) + + client.destroy() + }) +}) diff --git a/packages/event-bus-client/tests/ring-buffer.test.ts b/packages/event-bus-client/tests/ring-buffer.test.ts new file mode 100644 index 00000000..d109f1a2 --- /dev/null +++ b/packages/event-bus-client/tests/ring-buffer.test.ts @@ -0,0 +1,36 @@ +// @vitest-environment node +import { describe, expect, it } from 'vitest' +import { RingBuffer } from '../src/ring-buffer' + +describe('RingBuffer', () => { + it('should track added items via has()', () => { + const buf = new RingBuffer(5) + buf.add('a') + expect(buf.has('a')).toBe(true) + expect(buf.has('b')).toBe(false) + }) + + it('should evict oldest items when capacity is exceeded', () => { + const buf = new RingBuffer(3) + buf.add('a') + buf.add('b') + buf.add('c') + buf.add('d') // evicts 'a' + expect(buf.has('a')).toBe(false) + expect(buf.has('b')).toBe(true) + expect(buf.has('c')).toBe(true) + expect(buf.has('d')).toBe(true) + }) + + it('should handle wrapping around the buffer', () => { + const buf = new RingBuffer(2) + buf.add('a') + buf.add('b') + buf.add('c') // evicts 'a' + buf.add('d') // evicts 'b' + expect(buf.has('a')).toBe(false) + expect(buf.has('b')).toBe(false) + expect(buf.has('c')).toBe(true) + expect(buf.has('d')).toBe(true) + }) +}) diff --git a/packages/event-bus/src/client/client.ts b/packages/event-bus/src/client/client.ts index a3c2f7b9..5544893c 100644 --- a/packages/event-bus/src/client/client.ts +++ b/packages/event-bus/src/client/client.ts @@ -30,6 +30,8 @@ interface TanStackDevtoolsEvent { type: TEventName payload: TPayload pluginId?: string // Optional pluginId to filter events by plugin + eventId?: string + source?: 'server-bridge' } export interface ClientEventBusConfig { diff --git a/packages/event-bus/src/server/server.ts b/packages/event-bus/src/server/server.ts index 603df0b2..1b11800e 100644 --- a/packages/event-bus/src/server/server.ts +++ b/packages/event-bus/src/server/server.ts @@ -11,6 +11,8 @@ export interface TanStackDevtoolsEvent< type: TEventName payload: TPayload pluginId?: string // Optional pluginId to filter events by plugin + eventId?: string + source?: 'server-bridge' } // Used so no new server starts up when HMR happens declare global { @@ -50,6 +52,7 @@ export interface ServerEventBusConfig { export class ServerEventBus { #eventTarget: EventTarget #clients = new Set() + #bridgeClients = new Set() #sseClients = new Set() #server: http.Server | null = null #wssServer: WebSocketServer | null = null @@ -157,7 +160,11 @@ export class ServerEventBus { try { const msg = parseWithBigInt(body) this.debugLog('Received event from client', msg) - this.emitToServer(msg) + if (msg.source === 'server-bridge') { + this.emit(msg) + } else { + this.emitToServer(msg) + } } catch {} }) res.writeHead(200).end() @@ -184,17 +191,35 @@ export class ServerEventBus { } private handleNewConnection(wss: WebSocketServer) { - wss.on('connection', (ws: WebSocket) => { - this.debugLog('New WebSocket client connected') + wss.on('connection', (ws: WebSocket, req: http.IncomingMessage) => { + const isBridge = (() => { + try { + const url = new URL(req?.url ?? '', 'http://localhost') + return url.searchParams.get('bridge') === 'server' + } catch { + return false + } + })() + this.debugLog(`New WebSocket client connected (bridge: ${isBridge})`) this.#clients.add(ws) + if (isBridge) { + this.#bridgeClients.add(ws) + } ws.on('close', () => { this.debugLog('WebSocket client disconnected') this.#clients.delete(ws) + this.#bridgeClients.delete(ws) }) ws.on('message', (msg) => { this.debugLog('Received message from WebSocket client', msg.toString()) const data = parseWithBigInt(msg.toString()) - this.emitToServer(data) + if (isBridge) { + // Bridge messages go to both browser clients and in-process EventTarget + this.emit(data) + } else { + // Browser messages go to in-process EventTarget only + this.emitToServer(data) + } }) }) } @@ -256,7 +281,11 @@ export class ServerEventBus { 'Received event from client (external server)', msg, ) - this.emitToServer(msg) + if (msg.source === 'server-bridge') { + this.emit(msg) + } else { + this.emitToServer(msg) + } } catch {} }) res.writeHead(200).end() @@ -270,7 +299,10 @@ export class ServerEventBus { socket: Duplex, head: Buffer, ) => { - if (req.url === '/__devtools/ws') { + if ( + req.url === '/__devtools/ws' || + req.url?.startsWith('/__devtools/ws?') + ) { wss.handleUpgrade(req, socket, head, (ws) => { this.debugLog( 'WebSocket connection established (external server)', @@ -302,7 +334,10 @@ export class ServerEventBus { // Handle connection upgrade for WebSocket server.on('upgrade', (req, socket, head) => { - if (req.url === '/__devtools/ws') { + if ( + req.url === '/__devtools/ws' || + req.url?.startsWith('/__devtools/ws?') + ) { wss.handleUpgrade(req, socket, head, (ws) => { this.debugLog('WebSocket connection established') wss.emit('connection', ws, req) @@ -373,6 +408,7 @@ export class ServerEventBus { }) this.debugLog('Clearing all connections') this.#clients.clear() + this.#bridgeClients.clear() this.#sseClients.forEach((res) => res.end()) this.#sseClients.clear() this.debugLog('Cleared all WS/SSE connections') diff --git a/packages/event-bus/tests/server.test.ts b/packages/event-bus/tests/server.test.ts index e36ad165..a2a573f2 100644 --- a/packages/event-bus/tests/server.test.ts +++ b/packages/event-bus/tests/server.test.ts @@ -1,5 +1,6 @@ import http from 'node:http' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import WebSocket from 'ws' import { ServerEventBus } from '../src/server/server' // Clear globalThis between tests to avoid cross-test contamination @@ -248,4 +249,265 @@ describe('ServerEventBus', () => { logSpy.mockRestore() }) }) + + describe('server bridge connections', () => { + it('should accept WebSocket connections with ?bridge=server query param', async () => { + bus = new ServerEventBus({ port: 0 }) + const port = await bus.start() + + const ws = new WebSocket( + `ws://localhost:${port}/__devtools/ws?bridge=server`, + ) + await new Promise((resolve, reject) => { + ws.on('open', () => resolve()) + ws.on('error', (err) => reject(err)) + }) + + expect(ws.readyState).toBe(WebSocket.OPEN) + ws.close() + }) + + it('should broadcast server bridge messages to other WebSocket clients', async () => { + bus = new ServerEventBus({ port: 0 }) + const port = await bus.start() + + // Connect a "browser" client (no ?bridge=server) + const browserWs = new WebSocket(`ws://localhost:${port}/__devtools/ws`) + await new Promise((resolve) => browserWs.on('open', resolve)) + + // Connect a "server bridge" client + const bridgeWs = new WebSocket( + `ws://localhost:${port}/__devtools/ws?bridge=server`, + ) + await new Promise((resolve) => bridgeWs.on('open', resolve)) + + // Listen for messages on the browser client + const received = new Promise((resolve) => { + browserWs.on('message', (data) => resolve(JSON.parse(data.toString()))) + }) + + // Send event from bridge + bridgeWs.send( + JSON.stringify({ + type: 'test:event', + payload: { foo: 'bar' }, + pluginId: 'test', + source: 'server-bridge', + }), + ) + + const event = await received + expect(event.type).toBe('test:event') + expect(event.payload).toEqual({ foo: 'bar' }) + + browserWs.close() + bridgeWs.close() + }) + + it('should dispatch server bridge messages on in-process EventTarget', async () => { + bus = new ServerEventBus({ port: 0 }) + const port = await bus.start() + + const eventTarget = globalThis.__TANSTACK_EVENT_TARGET__! + const received = new Promise((resolve) => { + eventTarget.addEventListener('test:event', (e) => { + resolve((e as CustomEvent).detail) + }) + }) + + const bridgeWs = new WebSocket( + `ws://localhost:${port}/__devtools/ws?bridge=server`, + ) + await new Promise((resolve) => bridgeWs.on('open', resolve)) + + bridgeWs.send( + JSON.stringify({ + type: 'test:event', + payload: { data: 123 }, + pluginId: 'test', + source: 'server-bridge', + }), + ) + + const event = await received + expect(event.type).toBe('test:event') + expect(event.payload).toEqual({ data: 123 }) + + bridgeWs.close() + }) + + it('should NOT broadcast regular browser client messages to other WebSocket clients', async () => { + bus = new ServerEventBus({ port: 0 }) + const port = await bus.start() + + const browserWs1 = new WebSocket(`ws://localhost:${port}/__devtools/ws`) + await new Promise((resolve) => browserWs1.on('open', resolve)) + + const browserWs2 = new WebSocket(`ws://localhost:${port}/__devtools/ws`) + await new Promise((resolve) => browserWs2.on('open', resolve)) + + let received = false + browserWs2.on('message', () => { + received = true + }) + + browserWs1.send( + JSON.stringify({ + type: 'test:event', + payload: {}, + }), + ) + + await new Promise((resolve) => setTimeout(resolve, 100)) + + expect(received).toBe(false) + + browserWs1.close() + browserWs2.close() + }) + }) + + describe('server bridge connections (external server)', () => { + let externalServer: http.Server + + beforeEach(async () => { + externalServer = http.createServer() + await new Promise((resolve) => { + externalServer.listen(0, () => resolve()) + }) + }) + + afterEach(() => { + externalServer.close() + }) + + it('should route bridge messages on external server mode', async () => { + bus = new ServerEventBus({ httpServer: externalServer }) + const port = await bus.start() + + const browserWs = new WebSocket(`ws://localhost:${port}/__devtools/ws`) + await new Promise((resolve) => browserWs.on('open', resolve)) + + const bridgeWs = new WebSocket( + `ws://localhost:${port}/__devtools/ws?bridge=server`, + ) + await new Promise((resolve) => bridgeWs.on('open', resolve)) + + const received = new Promise((resolve) => { + browserWs.on('message', (data) => resolve(JSON.parse(data.toString()))) + }) + + bridgeWs.send( + JSON.stringify({ + type: 'test:event', + payload: { from: 'external-bridge' }, + pluginId: 'test', + source: 'server-bridge', + }), + ) + + const event = await received + expect(event.type).toBe('test:event') + expect(event.payload).toEqual({ from: 'external-bridge' }) + + browserWs.close() + bridgeWs.close() + }) + }) + + describe('POST handler source-based routing', () => { + it('should broadcast POST messages with source=server-bridge to WebSocket clients', async () => { + bus = new ServerEventBus({ port: 0 }) + const port = await bus.start() + + // Connect a browser WebSocket client + const browserWs = new WebSocket(`ws://localhost:${port}/__devtools/ws`) + await new Promise((resolve) => browserWs.on('open', resolve)) + + const received = new Promise((resolve) => { + browserWs.on('message', (data) => resolve(JSON.parse(data.toString()))) + }) + + // POST with source: 'server-bridge' + await new Promise((resolve) => { + const req = http.request( + { + hostname: 'localhost', + port, + path: '/__devtools/send', + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + }, + () => resolve(), + ) + req.write( + JSON.stringify({ + type: 'test:event', + payload: { from: 'bridge' }, + source: 'server-bridge', + }), + ) + req.end() + }) + + const event = await received + expect(event.type).toBe('test:event') + expect(event.payload).toEqual({ from: 'bridge' }) + + browserWs.close() + }) + }) + + describe('POST handler source-based routing (external server)', () => { + let externalServer: http.Server + + beforeEach(async () => { + externalServer = http.createServer() + await new Promise((resolve) => { + externalServer.listen(0, () => resolve()) + }) + }) + + afterEach(() => { + externalServer.close() + }) + + it('should broadcast POST with source=server-bridge on external server', async () => { + bus = new ServerEventBus({ httpServer: externalServer }) + const port = await bus.start() + + const browserWs = new WebSocket(`ws://localhost:${port}/__devtools/ws`) + await new Promise((resolve) => browserWs.on('open', resolve)) + + const received = new Promise((resolve) => { + browserWs.on('message', (data) => resolve(JSON.parse(data.toString()))) + }) + + await new Promise((resolve) => { + const req = http.request( + { + hostname: 'localhost', + port, + path: '/__devtools/send', + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + }, + () => resolve(), + ) + req.write( + JSON.stringify({ + type: 'test:event', + payload: { from: 'bridge' }, + source: 'server-bridge', + }), + ) + req.end() + }) + + const event = await received + expect(event.type).toBe('test:event') + + browserWs.close() + }) + }) }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ac5658c8..a6f570f3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -261,7 +261,7 @@ importers: version: 1.163.3(@tanstack/query-core@5.90.20)(@tanstack/react-query@5.90.21(react@19.2.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)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@tanstack/react-start': specifier: ^1.132.0 - version: 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)) + version: 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)) '@tanstack/react-store': specifier: ^0.9.0 version: 0.9.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -398,7 +398,7 @@ importers: version: 1.163.3(@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)(csstype@3.2.3)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@tanstack/react-start': specifier: ^1.132.0 - version: 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)) + version: 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)) react: specifier: ^19.2.0 version: 19.2.4 @@ -523,7 +523,7 @@ importers: version: 0.561.0(react@19.2.4) nitro: specifier: latest - version: 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)) + version: 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)) react: specifier: ^19.2.0 version: 19.2.4 @@ -574,6 +574,113 @@ importers: specifier: ^5.1.0 version: 5.1.0 + examples/react/start-cloudflare: + dependencies: + '@cloudflare/vite-plugin': + specifier: ^1.13.8 + version: 1.26.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))(workerd@1.20260301.1)(wrangler@4.70.0) + '@tanstack/devtools-event-client': + specifier: workspace:* + version: link:../../../packages/event-bus-client + '@tanstack/react-devtools': + specifier: workspace:* + version: link:../../../packages/react-devtools + '@tanstack/react-router': + specifier: ^1.132.0 + version: 1.163.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@tanstack/react-start': + specifier: ^1.132.0 + version: 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)) + '@tanstack/router-plugin': + specifier: ^1.132.0 + version: 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)) + react: + specifier: ^19.2.0 + version: 19.2.4 + react-dom: + specifier: ^19.2.0 + version: 19.2.4(react@19.2.4) + vite-tsconfig-paths: + specifier: ^6.0.2 + version: 6.1.1(typescript@5.9.3)(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)) + devDependencies: + '@tanstack/devtools-vite': + specifier: workspace:* + version: link:../../../packages/devtools-vite + '@types/node': + specifier: ^22.15.2 + version: 22.19.13 + '@types/react': + specifier: ^19.2.0 + version: 19.2.14 + '@types/react-dom': + specifier: ^19.2.0 + version: 19.2.3(@types/react@19.2.14) + '@vitejs/plugin-react': + specifier: ^5.0.4 + version: 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)) + typescript: + specifier: ~5.9.2 + version: 5.9.3 + vite: + specifier: ^7.1.7 + version: 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) + wrangler: + specifier: ^4.40.3 + version: 4.70.0 + + examples/react/start-nitro: + dependencies: + '@tanstack/devtools-event-client': + specifier: workspace:* + version: link:../../../packages/event-bus-client + '@tanstack/react-devtools': + specifier: workspace:* + version: link:../../../packages/react-devtools + '@tanstack/react-router': + specifier: ^1.132.0 + version: 1.163.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@tanstack/react-start': + specifier: ^1.132.0 + version: 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)) + '@tanstack/router-plugin': + specifier: ^1.132.0 + version: 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)) + nitro: + specifier: latest + version: 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)) + react: + specifier: ^19.2.0 + version: 19.2.4 + react-dom: + specifier: ^19.2.0 + version: 19.2.4(react@19.2.4) + vite-tsconfig-paths: + specifier: ^6.0.2 + version: 6.1.1(typescript@5.9.3)(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)) + devDependencies: + '@tanstack/devtools-vite': + specifier: workspace:* + version: link:../../../packages/devtools-vite + '@types/node': + specifier: ^22.15.2 + version: 22.19.13 + '@types/react': + specifier: ^19.2.0 + version: 19.2.14 + '@types/react-dom': + specifier: ^19.2.0 + version: 19.2.3(@types/react@19.2.14) + '@vitejs/plugin-react': + specifier: ^5.0.4 + version: 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)) + typescript: + specifier: ~5.9.2 + version: 5.9.3 + vite: + specifier: ^7.1.7 + version: 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) + examples/react/time-travel: dependencies: '@tanstack/devtools-event-client': @@ -683,7 +790,7 @@ importers: dependencies: '@solidjs/start': specifier: ^1.2.0 - version: 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)) + version: 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)) '@tanstack/solid-devtools': specifier: ^0.7.33 version: link:../../../packages/solid-devtools @@ -692,7 +799,7 @@ importers: version: 1.9.11 vinxi: specifier: ^0.5.8 - version: 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) + version: 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) examples/vue/basic: dependencies: @@ -2406,6 +2513,9 @@ packages: cpu: [x64] os: [win32] + '@oxc-project/types@0.115.0': + resolution: {integrity: sha512-4n91DKnebUS4yjUHl2g3/b2T+IUdCfmoZGhmwsovZCDaJSs+QkVAM+0AqqTxHSsHfeiMuueT75cZaZcT/m0pSw==} + '@oxc-resolver/binding-android-arm-eabi@11.19.1': resolution: {integrity: sha512-aUs47y+xyXHUKlbhqHUjBABjvycq6YSD7bpxSW7vplUmdzAlJ93yXY6ZR0c1o1x5A/QKbENCvs3+NlY8IpIVzg==} cpu: [arm] @@ -2795,6 +2905,95 @@ packages: resolution: {integrity: sha512-HDVTWq3H0uTXiU0eeSQntcVUTPP3GamzeXI41+x7uU9J65JgWQh3qWZHblR1i0npXfFtF+mxBiU2nJH8znxWnQ==} engines: {node: '>=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==} @@ -2804,6 +3003,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'} @@ -5343,6 +5545,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==} @@ -5910,6 +6121,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==} @@ -5996,6 +6217,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} @@ -6041,6 +6265,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 @@ -6896,6 +7123,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} @@ -6915,6 +7145,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} @@ -7010,6 +7268,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==} @@ -7583,6 +7844,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: @@ -7609,6 +7875,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==} @@ -7855,6 +8124,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'} @@ -10325,6 +10599,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 @@ -10604,12 +10880,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 @@ -10978,11 +11303,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 @@ -10994,7 +11319,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' @@ -11352,13 +11677,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: @@ -11384,15 +11709,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) @@ -11582,7 +11907,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 @@ -11593,7 +11918,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 @@ -11626,13 +11951,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: @@ -12132,7 +12457,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 @@ -12143,18 +12468,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: @@ -12871,10 +13196,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: @@ -13294,6 +13618,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: @@ -14063,12 +14395,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: {} @@ -14230,6 +14569,8 @@ snapshots: hookable@5.5.3: {} + hookable@6.0.1: {} + html-encoding-sniffer@6.0.0: dependencies: '@exodus/bytes': 1.15.0 @@ -14289,6 +14630,8 @@ snapshots: httpxy@0.1.7: {} + httpxy@0.3.1: {} + human-id@4.1.3: {} human-signals@5.0.0: {} @@ -15333,7 +15676,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) @@ -15350,6 +15695,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: @@ -15381,7 +15727,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) @@ -15434,7 +15832,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 @@ -15592,6 +15990,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 @@ -16246,19 +16648,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: @@ -16294,6 +16718,8 @@ snapshots: rou3@0.7.12: {} + rou3@0.8.1: {} + roughjs@4.6.6: dependencies: hachure-fill: 0.5.2 @@ -16583,6 +17009,8 @@ snapshots: srvx@0.11.8: {} + srvx@0.11.9: {} + stable-hash-x@0.2.0: {} stack-trace@1.0.0-pre2: {} @@ -17209,7 +17637,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) @@ -17230,7 +17658,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