diff --git a/packages/react-router-devtools/package.json b/packages/react-router-devtools/package.json index 7fe3b0d..046d8be 100644 --- a/packages/react-router-devtools/package.json +++ b/packages/react-router-devtools/package.json @@ -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", @@ -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", diff --git a/packages/react-router-devtools/src/vite/https-event-bus.test.ts b/packages/react-router-devtools/src/vite/https-event-bus.test.ts new file mode 100644 index 0000000..1a174a5 --- /dev/null +++ b/packages/react-router-devtools/src/vite/https-event-bus.test.ts @@ -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 = 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 = 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() + }) + }) +}) diff --git a/packages/react-router-devtools/src/vite/https-event-bus.ts b/packages/react-router-devtools/src/vite/https-event-bus.ts new file mode 100644 index 0000000..92717cd --- /dev/null +++ b/packages/react-router-devtools/src/vite/https-event-bus.ts @@ -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() + 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 +} diff --git a/packages/react-router-devtools/src/vite/plugin.tsx b/packages/react-router-devtools/src/vite/plugin.tsx index 1bd2a46..1ab0ca7 100644 --- a/packages/react-router-devtools/src/vite/plugin.tsx +++ b/packages/react-router-devtools/src/vite/plugin.tsx @@ -2,10 +2,11 @@ import fs from "node:fs" import type { ClientEventBusConfig, TanStackDevtoolsConfig } from "@tanstack/devtools" import { devtools } from "@tanstack/devtools-vite" import type { TanStackDevtoolsViteConfig } from "@tanstack/devtools-vite" -import { type Plugin, normalizePath } from "vite" +import { type Plugin, type ResolvedConfig, normalizePath } from "vite" import type { RdtClientConfig } from "../client/context/RDTContext.js" import type { DevToolsServerConfig } from "../server/config.js" import { eventClient } from "../shared/event-client.js" +import { createViteIntegratedEventBus } from "./https-event-bus.js" import { runner } from "./node-server.js" import { processPlugins } from "./utils.js" import { addRouteTypes } from "./utils/codegen.js" @@ -186,6 +187,8 @@ export const reactRouterDevTools: (args?: ReactRouterViteConfig) => Plugin[] = ( const includeServer = args?.includeInProd?.server ?? false const includeDevtools = args?.includeInProd?.devTools ?? false let port = 5173 + let isHttps = false + let resolvedConfig: ResolvedConfig | null = null // Get appDir synchronously from cache (will be populated when first route loads) const appDir = cachedAppDir || "./app" const appDirName = appDir.replace("./", "") @@ -227,8 +230,79 @@ export const reactRouterDevTools: (args?: ReactRouterViteConfig) => Plugin[] = ( if (typeof process !== "undefined") { process.rdt_config = serverConfig } + + // Merge tanstack config - we'll handle the event bus ourselves when HTTPS is detected + const tanstackConfig: TanStackDevtoolsViteConfig = { + ...args?.tanstackViteConfig, + } + return [ - ...devtools(args?.tanstackViteConfig), + // Plugin to detect HTTPS and set up the integrated event bus + { + name: "react-router-devtools:https-setup", + enforce: "pre" as const, + apply(config) { + return config.mode === "development" + }, + configResolved(config) { + resolvedConfig = config + isHttps = !!config.server.https + port = config.server.port ?? 5173 + + // If HTTPS is enabled, disable the tanstack event bus server + // We'll use our own integrated one that works with Vite's HTTPS server + if (isHttps && tanstackConfig.eventBusConfig?.enabled !== false) { + tanstackConfig.eventBusConfig = { + ...tanstackConfig.eventBusConfig, + enabled: false, + } + } + }, + configureServer(server) { + // If HTTPS is enabled, set up our integrated event bus on Vite's server + if (isHttps) { + createViteIntegratedEventBus(server) + } + }, + }, + + // Plugin to transform client code to use correct protocol (wss:// instead of ws://) + { + name: "react-router-devtools:https-client-transform", + enforce: "pre" as const, + apply(config) { + return config.mode === "development" + }, + transform(code, id) { + // Transform devtools client code from @tanstack or our bundled client + const isDevtoolsCode = + id.includes("devtools") && (id.includes("@tanstack") || id.includes("react-router-devtools")) + if (!isDevtoolsCode) { + return + } + + // If HTTPS is enabled, patch the client to use secure protocols and Vite's port + if (isHttps && resolvedConfig) { + let transformed = code + // Replace ws:// with wss:// + transformed = transformed.replace(/ws:\/\/localhost/g, "wss://localhost") + // Replace http://localhost with https://localhost + transformed = transformed.replace(/http:\/\/localhost/g, "https://localhost") + // Replace the devtools port with Vite's port + transformed = transformed.replace(/__TANSTACK_DEVTOOLS_PORT__/g, String(port)) + // Replace hardcoded default port 4206 with Vite's port + transformed = transformed.replace(/localhost:4206/g, `localhost:${port}`) + // Also replace template string port references + transformed = transformed.replace(/\$\{this\.#port\}/g, String(port)) + + if (transformed !== code) { + return { code: transformed, map: null } + } + } + }, + }, + + ...devtools(tanstackConfig), { name: "react-router-devtools", @@ -243,14 +317,21 @@ export const reactRouterDevTools: (args?: ReactRouterViteConfig) => Plugin[] = ( } }, config(config) { + // When HTTPS is detected, we need to exclude the client from pre-bundling + // so our transform plugin can patch the WebSocket URLs + const needsTransform = !!config.server?.https config.optimizeDeps = { ...config.optimizeDeps, include: [ ...(config.optimizeDeps?.include ?? []), "react-router-devtools > react-d3-tree", - "react-router-devtools/client", - "react-router-devtools/context", - "react-router-devtools/server", + ...(needsTransform + ? [] + : ["react-router-devtools/client", "react-router-devtools/context", "react-router-devtools/server"]), + ], + exclude: [ + ...(config.optimizeDeps?.exclude ?? []), + ...(needsTransform ? ["react-router-devtools/client", "@tanstack/devtools-event-bus"] : []), ], } }, @@ -263,11 +344,22 @@ export const reactRouterDevTools: (args?: ReactRouterViteConfig) => Plugin[] = ( const plugins = pluginDir && process.env.NODE_ENV === "development" ? await processPlugins(pluginDir) : [] const pluginNames = plugins.map((p) => p.name) const pluginImports = plugins.map((plugin) => `import { ${plugin.name} } from "${plugin.path}";`).join("\n") + // Merge HTTPS-aware client bus config when HTTPS is detected + const clientBusConfig = { + ...(args?.tanstackClientBusConfig || {}), + ...(isHttps + ? { + protocol: "wss" as const, + port: port, + host: "localhost", + } + : {}), + } const config = `{ "config": ${JSON.stringify(clientConfig)}, "plugins": "[${pluginNames.join(",")}]", "tanstackConfig": ${JSON.stringify(args?.tanstackConfig || {})}, - "tanstackClientBusConfig": ${JSON.stringify(args?.tanstackClientBusConfig || {})} + "tanstackClientBusConfig": ${JSON.stringify(clientBusConfig)} }` return injectRdtClient(code, config, pluginImports, id) }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a16af5b..a18fe8b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -292,6 +292,9 @@ importers: react-tooltip: specifier: ^5.30.0 version: 5.30.0(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + ws: + specifier: ^8.19.0 + version: 8.19.0 devDependencies: '@react-router/dev': specifier: 7.9.5 @@ -323,6 +326,9 @@ importers: '@types/node': specifier: 24.10.0 version: 24.10.0 + '@types/ws': + specifier: ^8.18.1 + version: 8.18.1 '@vitest/coverage-v8': specifier: 3.0.5 version: 3.0.5(@vitest/browser@3.0.5)(vitest@3.0.5) @@ -2257,6 +2263,9 @@ packages: '@types/whatwg-mimetype@3.0.2': resolution: {integrity: sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==} + '@types/ws@8.18.1': + resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} + '@ungap/structured-clone@1.3.0': resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} @@ -3474,11 +3483,13 @@ packages: glob@10.4.5: resolution: {integrity: sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me hasBin: true glob@11.0.3: resolution: {integrity: sha512-2Nim7dha1KVkaiF4q6Dj+ngPPMdfvLJEOpZk/jKiUAkqKebpGAWQXAq9z1xu9HKu5lWfqw/FASuccEjyznjPaA==} engines: {node: 20 || >=22} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me hasBin: true global-directory@4.0.1: @@ -5716,6 +5727,7 @@ packages: tar@7.5.1: resolution: {integrity: sha512-nlGpxf+hv0v7GkWBK2V9spgactGOp0qvfWRxUMjqHyzrt3SgwE48DIv/FhqPHJYLHpgW1opq3nERbz5Anq7n1g==} engines: {node: '>=18'} + deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me term-size@2.2.1: resolution: {integrity: sha512-wK0Ri4fOGjv/XPy8SBHZChl8CM7uMc5VML7SqiQ0zG7+J5Vr+RMQDoHa2CNT6KHUnTGIXH34UDMkPzAUyapBZg==} @@ -6276,6 +6288,18 @@ packages: utf-8-validate: optional: true + ws@8.19.0: + resolution: {integrity: sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + wsl-utils@0.1.0: resolution: {integrity: sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==} engines: {node: '>=18'} @@ -7859,7 +7883,7 @@ snapshots: '@tanstack/devtools-event-bus@0.4.0': dependencies: - ws: 8.18.3 + ws: 8.19.0 transitivePeerDependencies: - bufferutil - utf-8-validate @@ -8280,6 +8304,10 @@ snapshots: '@types/whatwg-mimetype@3.0.2': {} + '@types/ws@8.18.1': + dependencies: + '@types/node': 24.10.0 + '@ungap/structured-clone@1.3.0': {} '@vitest/browser@3.0.5(@types/node@24.10.0)(playwright@1.50.1)(typescript@5.9.3)(vite@7.2.2(@types/node@24.10.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.1))(vitest@3.0.5)': @@ -10200,7 +10228,7 @@ snapshots: slash: 3.0.0 string-hash: 1.1.3 update-notifier: 7.3.1 - ws: 8.18.3 + ws: 8.19.0 transitivePeerDependencies: - bufferutil - postcss @@ -12840,6 +12868,8 @@ snapshots: ws@8.18.3: {} + ws@8.19.0: {} + wsl-utils@0.1.0: dependencies: is-wsl: 3.1.0