Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion packages/react-router-devtools/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@
"@types/babel__generator": "^7.27.0",
"@types/babel__traverse": "^7.28.0",
"@types/node": "24.10.0",
"@types/ws": "^8.18.1",
"@vitest/coverage-v8": "3.0.5",
"@vitest/ui": "3.0.5",
"happy-dom": "20.0.10",
Expand Down Expand Up @@ -141,7 +142,8 @@
"goober": "^2.1.18",
"react-d3-tree": "^3.6.6",
"react-hotkeys-hook": "^5.2.1",
"react-tooltip": "^5.30.0"
"react-tooltip": "^5.30.0",
"ws": "^8.19.0"
},
"optionalDependencies": {
"@biomejs/cli-darwin-arm64": "^2.3.5",
Expand Down
218 changes: 218 additions & 0 deletions packages/react-router-devtools/src/vite/https-event-bus.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
import type { ViteDevServer } from "vite"
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
import { createViteIntegratedEventBus } from "./https-event-bus"

type EventHandler = (...args: unknown[]) => void

// Mock the ws module
vi.mock("ws", () => {
const mockWebSocket = {
OPEN: 1,
CLOSED: 3,
}

class MockWebSocketServer {
private handlers: Map<string, EventHandler[]> = new Map()

on(event: string, handler: EventHandler) {
if (!this.handlers.has(event)) {
this.handlers.set(event, [])
}
this.handlers.get(event)?.push(handler)
}

emit(event: string, ...args: unknown[]) {
const handlers = this.handlers.get(event) || []
for (const handler of handlers) {
handler(...args)
}
}

handleUpgrade(_req: unknown, _socket: unknown, _head: unknown, callback: (ws: MockWebSocket) => void) {
const ws = new MockWebSocket()
callback(ws)
}

close() {
this.handlers.clear()
}
}

class MockWebSocket {
readyState = 1 // OPEN
private handlers: Map<string, EventHandler[]> = new Map()

on(event: string, handler: EventHandler) {
if (!this.handlers.has(event)) {
this.handlers.set(event, [])
}
this.handlers.get(event)?.push(handler)
}

send(_data: string) {
// Mock send
}

// Helper to simulate events
simulateMessage(data: unknown) {
const handlers = this.handlers.get("message") || []
for (const handler of handlers) {
handler(Buffer.from(JSON.stringify(data)))
}
}

simulateClose() {
const handlers = this.handlers.get("close") || []
for (const handler of handlers) {
handler()
}
}
}

return {
WebSocket: mockWebSocket,
WebSocketServer: MockWebSocketServer,
}
})

describe("https-event-bus", () => {
let mockServer: ViteDevServer
let upgradeHandler: ((req: unknown, socket: unknown, head: unknown) => void) | null

beforeEach(() => {
upgradeHandler = null

// Create a mock Vite server
mockServer = {
httpServer: {
on: vi.fn((event: string, handler: EventHandler) => {
if (event === "upgrade") {
upgradeHandler = handler as typeof upgradeHandler
}
}),
},
} as unknown as ViteDevServer

// Reset global event target
globalThis.__TANSTACK_EVENT_TARGET__ = null
})

afterEach(() => {
globalThis.__TANSTACK_EVENT_TARGET__ = null
})

describe("createViteIntegratedEventBus", () => {
it("should create an event bus with emitToClients and close methods", () => {
const eventBus = createViteIntegratedEventBus(mockServer)

expect(eventBus).toHaveProperty("emitToClients")
expect(eventBus).toHaveProperty("close")
expect(typeof eventBus.emitToClients).toBe("function")
expect(typeof eventBus.close).toBe("function")

eventBus.close()
})

it("should register upgrade handler on httpServer", () => {
const eventBus = createViteIntegratedEventBus(mockServer)

expect(mockServer.httpServer?.on).toHaveBeenCalledWith("upgrade", expect.any(Function))

eventBus.close()
})

it("should create global event target if not exists", () => {
expect(globalThis.__TANSTACK_EVENT_TARGET__).toBeNull()

const eventBus = createViteIntegratedEventBus(mockServer)

expect(globalThis.__TANSTACK_EVENT_TARGET__).toBeInstanceOf(EventTarget)

eventBus.close()
})

it("should reuse existing global event target", () => {
const existingTarget = new EventTarget()
globalThis.__TANSTACK_EVENT_TARGET__ = existingTarget

const eventBus = createViteIntegratedEventBus(mockServer)

expect(globalThis.__TANSTACK_EVENT_TARGET__).toBe(existingTarget)

eventBus.close()
})

it("should handle upgrade requests for /__devtools/ws path", () => {
const eventBus = createViteIntegratedEventBus(mockServer)

expect(upgradeHandler).not.toBeNull()

// Simulate an upgrade request
const mockReq = { url: "/__devtools/ws" }
const mockSocket = {}
const mockHead = Buffer.alloc(0)

// This should not throw
upgradeHandler?.(mockReq, mockSocket, mockHead)

eventBus.close()
})

it("should not handle upgrade requests for other paths", () => {
const eventBus = createViteIntegratedEventBus(mockServer)

expect(upgradeHandler).not.toBeNull()

// Simulate an upgrade request for a different path
const mockReq = { url: "/some-other-path" }
const mockSocket = {}
const mockHead = Buffer.alloc(0)

// This should not throw and should not process the upgrade
upgradeHandler?.(mockReq, mockSocket, mockHead)

eventBus.close()
})

it("should clean up on close", () => {
const eventBus = createViteIntegratedEventBus(mockServer)

// Close should not throw
expect(() => eventBus.close()).not.toThrow()
})

it("should handle server without httpServer", () => {
const serverWithoutHttp = {} as ViteDevServer

// Should not throw when httpServer is undefined
const eventBus = createViteIntegratedEventBus(serverWithoutHttp)

expect(eventBus).toHaveProperty("emitToClients")
expect(eventBus).toHaveProperty("close")

eventBus.close()
})
})

describe("event dispatching", () => {
it("should dispatch events to global event target", () => {
const eventBus = createViteIntegratedEventBus(mockServer)
const eventTarget = globalThis.__TANSTACK_EVENT_TARGET__

expect(eventTarget).not.toBeNull()

const mockHandler = vi.fn()
eventTarget?.addEventListener("tanstack-dispatch-event", mockHandler)

// Dispatch an event
const testEvent = new CustomEvent("tanstack-dispatch-event", {
detail: { type: "test", data: "hello" },
})
eventTarget?.dispatchEvent(testEvent)

expect(mockHandler).toHaveBeenCalled()

eventBus.close()
})
})
})
79 changes: 79 additions & 0 deletions packages/react-router-devtools/src/vite/https-event-bus.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import type { ViteDevServer } from "vite"
import { WebSocket, WebSocketServer } from "ws"

/**
* Creates a WebSocket server that uses Vite's existing HTTP(S) server.
* This ensures the devtools WebSocket works correctly when Vite is configured with HTTPS.
*/
export function createViteIntegratedEventBus(server: ViteDevServer) {
const clients = new Set<WebSocket>()
const eventTarget = globalThis.__TANSTACK_EVENT_TARGET__ ?? new EventTarget()

if (!globalThis.__TANSTACK_EVENT_TARGET__) {
globalThis.__TANSTACK_EVENT_TARGET__ = eventTarget
}

// Create WebSocket server without its own HTTP server
const wss = new WebSocketServer({ noServer: true })

// Handle WebSocket connections
wss.on("connection", (ws) => {
clients.add(ws)

ws.on("close", () => {
clients.delete(ws)
})

ws.on("message", (msg) => {
try {
const data = JSON.parse(msg.toString())
// Emit to server-side listeners
eventTarget.dispatchEvent(new CustomEvent(data.type, { detail: data }))
eventTarget.dispatchEvent(new CustomEvent("tanstack-devtools-global", { detail: data }))
} catch {
// Ignore parse errors
}
})
})

// Handle upgrade requests on Vite's HTTP server
server.httpServer?.on("upgrade", (req, socket, head) => {
if (req.url === "/__devtools/ws") {
wss.handleUpgrade(req, socket, head, (ws) => {
wss.emit("connection", ws, req)
})
}
})

// Function to emit events to all connected clients
const emitToClients = (event: unknown) => {
const json = JSON.stringify(event)
for (const client of clients) {
if (client.readyState === WebSocket.OPEN) {
client.send(json)
}
}
}

// Listen for server-side events and forward to clients
const dispatcher = (e: Event) => {
const event = (e as CustomEvent).detail
emitToClients(event)
}

eventTarget.addEventListener("tanstack-dispatch-event", dispatcher)

return {
emitToClients,
close: () => {
eventTarget.removeEventListener("tanstack-dispatch-event", dispatcher)
wss.close()
clients.clear()
},
}
}

// Declare global types for the event target
declare global {
var __TANSTACK_EVENT_TARGET__: EventTarget | null
}
Loading