diff --git a/.changeset/fresh-bars-matter.md b/.changeset/fresh-bars-matter.md new file mode 100644 index 00000000..87f900e1 --- /dev/null +++ b/.changeset/fresh-bars-matter.md @@ -0,0 +1,6 @@ +--- +'@tanstack/devtools-vite': patch +'@tanstack/devtools-event-bus': patch +--- + +fix issues with https servers and console piping diff --git a/packages/devtools-vite/src/plugin.ts b/packages/devtools-vite/src/plugin.ts index 8ae03e04..0ab52bcd 100644 --- a/packages/devtools-vite/src/plugin.ts +++ b/packages/devtools-vite/src/plugin.ts @@ -21,7 +21,10 @@ import { generateConsolePipeCode } from './virtual-console' import type { ServerResponse } from 'node:http' import type { Plugin } from 'vite' import type { EditorConfig } from './editor' -import type { ServerEventBusConfig } from '@tanstack/devtools-event-bus/server' +import type { + HttpServerLike, + ServerEventBusConfig, +} from '@tanstack/devtools-event-bus/server' export type ConsoleLevel = 'log' | 'warn' | 'error' | 'info' | 'debug' @@ -113,6 +116,8 @@ export const devtools = (args?: TanStackDevtoolsViteConfig): Array => { let devtoolsFileId: string | null = null let devtoolsPort: number | null = null + let devtoolsHost: string | null = null + let devtoolsProtocol: 'http' | 'https' | null = null return [ { @@ -170,9 +175,24 @@ export const devtools = (args?: TanStackDevtoolsViteConfig): Array => { async configureServer(server) { if (serverBusEnabled) { const preferredPort = args?.eventBusConfig?.port ?? 4206 + const isHttps = !!server.config.server.https + const serverHost = + typeof server.config.server.host === 'string' + ? server.config.server.host + : 'localhost' + + devtoolsProtocol = isHttps ? 'https' : 'http' + devtoolsHost = serverHost + const bus = new ServerEventBus({ ...args?.eventBusConfig, port: preferredPort, + host: serverHost, + // When HTTPS is enabled, piggyback on Vite's server + // so WebSocket/SSE connections share the same TLS certificate + ...(isHttps && server.httpServer + ? { httpServer: server.httpServer as HttpServerLike } + : {}), }) // start() now handles EADDRINUSE and returns the actual port devtoolsPort = await bus.start() @@ -608,22 +628,42 @@ export const devtools = (args?: TanStackDevtoolsViteConfig): Array => { }, }, { - name: '@tanstack/devtools:port-injection', + name: '@tanstack/devtools:connection-injection', apply(config, { command }) { return config.mode === 'development' && command === 'serve' }, transform(code, id) { - // Only transform @tanstack packages that contain the port placeholder - if (!code.includes('__TANSTACK_DEVTOOLS_PORT__')) return + // Only transform @tanstack packages that contain the connection placeholders + const hasPlaceholder = + code.includes('__TANSTACK_DEVTOOLS_PORT__') || + code.includes('__TANSTACK_DEVTOOLS_HOST__') || + code.includes('__TANSTACK_DEVTOOLS_PROTOCOL__') + if (!hasPlaceholder) return if ( !id.includes('@tanstack/devtools') && !id.includes('@tanstack/event-bus') ) return - // Replace placeholder with actual port (or fallback to 4206 if not resolved yet) + // Replace placeholders with actual values (or fallback defaults) const portValue = devtoolsPort ?? 4206 - return code.replace(/__TANSTACK_DEVTOOLS_PORT__/g, String(portValue)) + const hostValue = devtoolsHost ?? 'localhost' + const protocolValue = devtoolsProtocol ?? 'http' + + let result = code + result = result.replace( + /__TANSTACK_DEVTOOLS_PORT__/g, + String(portValue), + ) + result = result.replace( + /__TANSTACK_DEVTOOLS_HOST__/g, + JSON.stringify(hostValue), + ) + result = result.replace( + /__TANSTACK_DEVTOOLS_PROTOCOL__/g, + JSON.stringify(protocolValue), + ) + return result }, }, ] diff --git a/packages/devtools-vite/src/utils.ts b/packages/devtools-vite/src/utils.ts index aaa858df..428813fb 100644 --- a/packages/devtools-vite/src/utils.ts +++ b/packages/devtools-vite/src/utils.ts @@ -128,6 +128,7 @@ export const handleDevToolsViteRequest = ( options.onOpenSource?.(parsedData) } catch (e) {} res.write('OK') + res.end() }) } diff --git a/packages/devtools-vite/tests/index.test.ts b/packages/devtools-vite/tests/index.test.ts index d4a9bef9..ba13a33e 100644 --- a/packages/devtools-vite/tests/index.test.ts +++ b/packages/devtools-vite/tests/index.test.ts @@ -1,7 +1,338 @@ -import { describe, expect, it } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { devtools } from '../src/plugin' +import type { Plugin } from 'vite' -describe('test', () => { - it('works', () => { - expect(true).toBe(true) +// Helper to find a plugin by name from the array returned by devtools() +function findPlugin(plugins: Array, name: string): Plugin | undefined { + return plugins.find((p) => p.name === name) +} + +// Helper to create a mock Vite server +function createMockServer( + options: { + https?: boolean | object + host?: string | boolean + port?: number + httpServer?: any + } = {}, +) { + return { + config: { + server: { + https: options.https ?? false, + host: options.host ?? 'localhost', + port: options.port ?? 5173, + }, + mode: 'development', + }, + httpServer: options.httpServer ?? { + on: vi.fn(), + address: vi.fn().mockReturnValue({ port: 5173 }), + }, + middlewares: { + use: vi.fn(), + }, + } +} + +describe('devtools plugin', () => { + describe('connection-injection plugin', () => { + let connectionPlugin: Plugin + + beforeEach(() => { + const plugins = devtools({ + eventBusConfig: { enabled: false }, // Disable server bus for unit test + }) + const found = findPlugin( + plugins, + '@tanstack/devtools:connection-injection', + ) + if (!found) { + throw new Error('connection-injection plugin not found') + } + connectionPlugin = found + }) + + it('should exist in the plugins array', () => { + const plugins = devtools() + const plugin = findPlugin( + plugins, + '@tanstack/devtools:connection-injection', + ) + expect(plugin).toBeDefined() + }) + + it('should only apply in development mode with serve command', () => { + const apply = connectionPlugin.apply as (config: any, env: any) => boolean + expect(apply({ mode: 'development' }, { command: 'serve' })).toBe(true) + expect(apply({ mode: 'production' }, { command: 'serve' })).toBe(false) + expect(apply({ mode: 'development' }, { command: 'build' })).toBe(false) + }) + + it('should replace __TANSTACK_DEVTOOLS_PORT__ in matching modules', () => { + const transform = connectionPlugin.transform as ( + code: string, + id: string, + ) => string | undefined + const code = 'const port = __TANSTACK_DEVTOOLS_PORT__' + const result = transform( + code, + 'node_modules/@tanstack/event-bus/dist/client.js', + ) + expect(result).toBeDefined() + expect(result).not.toContain('__TANSTACK_DEVTOOLS_PORT__') + // Default port when bus is not started + expect(result).toContain('4206') + }) + + it('should replace __TANSTACK_DEVTOOLS_HOST__ in matching modules', () => { + const transform = connectionPlugin.transform as ( + code: string, + id: string, + ) => string | undefined + const code = 'const host = __TANSTACK_DEVTOOLS_HOST__' + const result = transform( + code, + 'node_modules/@tanstack/devtools/dist/client.js', + ) + expect(result).toBeDefined() + expect(result).not.toContain('__TANSTACK_DEVTOOLS_HOST__') + // Default host + expect(result).toContain('"localhost"') + }) + + it('should replace __TANSTACK_DEVTOOLS_PROTOCOL__ in matching modules', () => { + const transform = connectionPlugin.transform as ( + code: string, + id: string, + ) => string | undefined + const code = 'const protocol = __TANSTACK_DEVTOOLS_PROTOCOL__' + const result = transform( + code, + 'node_modules/@tanstack/event-bus/dist/client.js', + ) + expect(result).toBeDefined() + expect(result).not.toContain('__TANSTACK_DEVTOOLS_PROTOCOL__') + // Default protocol + expect(result).toContain('"http"') + }) + + it('should replace all three placeholders in the same code', () => { + const transform = connectionPlugin.transform as ( + code: string, + id: string, + ) => string | undefined + const code = [ + 'const port = __TANSTACK_DEVTOOLS_PORT__;', + 'const host = __TANSTACK_DEVTOOLS_HOST__;', + 'const protocol = __TANSTACK_DEVTOOLS_PROTOCOL__;', + ].join('\n') + const result = transform( + code, + 'node_modules/@tanstack/event-bus/dist/client.js', + ) + expect(result).toBeDefined() + expect(result).toContain('4206') + expect(result).toContain('"localhost"') + expect(result).toContain('"http"') + }) + + it('should return undefined for code without any placeholders', () => { + const transform = connectionPlugin.transform as ( + code: string, + id: string, + ) => string | undefined + const result = transform( + 'const x = 42', + 'node_modules/@tanstack/event-bus/dist/client.js', + ) + expect(result).toBeUndefined() + }) + + it('should return undefined for non-tanstack module IDs', () => { + const transform = connectionPlugin.transform as ( + code: string, + id: string, + ) => string | undefined + const result = transform( + 'const port = __TANSTACK_DEVTOOLS_PORT__', + 'node_modules/some-other-package/index.js', + ) + expect(result).toBeUndefined() + }) + }) + + describe('configureServer - HTTPS detection', () => { + it('should set protocol to https when server.https is truthy', async () => { + const plugins = devtools({ + eventBusConfig: { enabled: true, port: 0 }, + }) + const customServerPlugin = findPlugin( + plugins, + '@tanstack/devtools:custom-server', + ) + const connectionPlugin = findPlugin( + plugins, + '@tanstack/devtools:connection-injection', + ) + + expect(customServerPlugin).toBeDefined() + expect(connectionPlugin).toBeDefined() + + const mockHttpServer = { + on: vi.fn(), + address: vi.fn().mockReturnValue({ port: 5173 }), + listenerCount: vi.fn().mockReturnValue(0), + } + + const server = createMockServer({ + https: { key: 'fake-key', cert: 'fake-cert' }, + host: 'localhost', + port: 5173, + httpServer: mockHttpServer, + }) + + // Call configureServer to trigger HTTPS detection + const configureServer = customServerPlugin!.configureServer as ( + server: any, + ) => Promise + await configureServer(server) + + // Now test the transform to verify protocol was set to 'https' + const transform = connectionPlugin!.transform as ( + code: string, + id: string, + ) => string | undefined + const result = transform( + 'const protocol = __TANSTACK_DEVTOOLS_PROTOCOL__', + 'node_modules/@tanstack/event-bus/dist/client.js', + ) + expect(result).toContain('"https"') + }) + + it('should set protocol to http when server.https is falsy', async () => { + const plugins = devtools({ + eventBusConfig: { enabled: true, port: 0 }, + }) + const customServerPlugin = findPlugin( + plugins, + '@tanstack/devtools:custom-server', + ) + const connectionPlugin = findPlugin( + plugins, + '@tanstack/devtools:connection-injection', + ) + + const server = createMockServer({ + https: false, + port: 5173, + }) + + const configureServer = customServerPlugin!.configureServer as ( + server: any, + ) => Promise + await configureServer(server) + + const transform = connectionPlugin!.transform as ( + code: string, + id: string, + ) => string | undefined + const result = transform( + 'const protocol = __TANSTACK_DEVTOOLS_PROTOCOL__', + 'node_modules/@tanstack/event-bus/dist/client.js', + ) + expect(result).toContain('"http"') + }) + + it('should use server host config for host placeholder', async () => { + const plugins = devtools({ + eventBusConfig: { enabled: true, port: 0 }, + }) + const customServerPlugin = findPlugin( + plugins, + '@tanstack/devtools:custom-server', + ) + const connectionPlugin = findPlugin( + plugins, + '@tanstack/devtools:connection-injection', + ) + + const server = createMockServer({ + host: 'my-custom-host.local', + port: 5173, + }) + + const configureServer = customServerPlugin!.configureServer as ( + server: any, + ) => Promise + await configureServer(server) + + const transform = connectionPlugin!.transform as ( + code: string, + id: string, + ) => string | undefined + const result = transform( + 'const host = __TANSTACK_DEVTOOLS_HOST__', + 'node_modules/@tanstack/event-bus/dist/client.js', + ) + expect(result).toContain('"my-custom-host.local"') + }) + + it('should default host to localhost when server.host is boolean true', async () => { + const plugins = devtools({ + eventBusConfig: { enabled: true, port: 0 }, + }) + const customServerPlugin = findPlugin( + plugins, + '@tanstack/devtools:custom-server', + ) + const connectionPlugin = findPlugin( + plugins, + '@tanstack/devtools:connection-injection', + ) + + const server = createMockServer({ + host: true, // Vite uses `true` to mean "expose on all interfaces" + port: 5173, + }) + + const configureServer = customServerPlugin!.configureServer as ( + server: any, + ) => Promise + await configureServer(server) + + const transform = connectionPlugin!.transform as ( + code: string, + id: string, + ) => string | undefined + const result = transform( + 'const host = __TANSTACK_DEVTOOLS_HOST__', + 'node_modules/@tanstack/event-bus/dist/client.js', + ) + expect(result).toContain('"localhost"') + }) + }) + + describe('plugin array', () => { + it('should return an array of plugins', () => { + const plugins = devtools() + expect(Array.isArray(plugins)).toBe(true) + expect(plugins.length).toBeGreaterThan(0) + }) + + it('should not contain old port-injection plugin name', () => { + const plugins = devtools() + const oldPlugin = findPlugin(plugins, '@tanstack/devtools:port-injection') + expect(oldPlugin).toBeUndefined() + }) + + it('should contain connection-injection plugin', () => { + const plugins = devtools() + const plugin = findPlugin( + plugins, + '@tanstack/devtools:connection-injection', + ) + expect(plugin).toBeDefined() + }) }) }) diff --git a/packages/devtools-vite/tests/utils.test.ts b/packages/devtools-vite/tests/utils.test.ts index a32fb426..6913c216 100644 --- a/packages/devtools-vite/tests/utils.test.ts +++ b/packages/devtools-vite/tests/utils.test.ts @@ -117,10 +117,10 @@ describe('handleDevToolsViteRequest', () => { expect(cb).toHaveBeenCalledTimes(1) expect(cb).toHaveBeenCalledWith({ foo: 1 }) expect(res.write).toHaveBeenCalledWith('OK') + expect(res.end).toHaveBeenCalled() // these are not used in this branch expect(res.setHeader).not.toHaveBeenCalled() - expect(res.end).not.toHaveBeenCalled() expect(next).not.toHaveBeenCalled() }) @@ -134,6 +134,7 @@ describe('handleDevToolsViteRequest', () => { expect(cb).not.toHaveBeenCalled() expect(res.write).toHaveBeenCalledWith('OK') + expect(res.end).toHaveBeenCalled() }) }) diff --git a/packages/event-bus/src/client/client.ts b/packages/event-bus/src/client/client.ts index b3dfdfc7..a3c2f7b9 100644 --- a/packages/event-bus/src/client/client.ts +++ b/packages/event-bus/src/client/client.ts @@ -1,8 +1,10 @@ import { parseWithBigInt, stringifyWithBigInt } from '../utils/json' -// Declare the global placeholder that gets replaced by the Vite plugin at transform time -// Falls back to 4206 when not using the Vite plugin or in non-transformed environments +// Declare the global placeholders that get replaced by the Vite plugin at transform time +// Fall back to defaults when not using the Vite plugin or in non-transformed environments declare const __TANSTACK_DEVTOOLS_PORT__: number | undefined +declare const __TANSTACK_DEVTOOLS_HOST__: string | undefined +declare const __TANSTACK_DEVTOOLS_PROTOCOL__: 'http' | 'https' | undefined function getDefaultPort(configPort: number): number { if (typeof __TANSTACK_DEVTOOLS_PORT__ !== 'undefined') @@ -10,6 +12,20 @@ function getDefaultPort(configPort: number): number { return configPort } +function getDefaultHost(configHost: string): string { + if (typeof __TANSTACK_DEVTOOLS_HOST__ !== 'undefined') + return __TANSTACK_DEVTOOLS_HOST__ + return configHost +} + +function getDefaultProtocol( + configProtocol: 'http' | 'https', +): 'http' | 'https' { + if (typeof __TANSTACK_DEVTOOLS_PROTOCOL__ !== 'undefined') + return __TANSTACK_DEVTOOLS_PROTOCOL__ + return configProtocol +} + interface TanStackDevtoolsEvent { type: TEventName payload: TPayload @@ -33,10 +49,24 @@ export interface ClientEventBusConfig { * Defaults to 4206. */ port?: number + + /** + * Optional host to connect to the devtools server event bus. + * Defaults to 'localhost'. + */ + host?: string + + /** + * Optional protocol to use for connecting to the devtools server event bus. + * Defaults to 'http'. Set to 'https' when the dev server uses HTTPS. + */ + protocol?: 'http' | 'https' } export class ClientEventBus { #port: number + #host: string + #protocol: 'http' | 'https' #socket: WebSocket | null #eventSource: EventSource | null #eventTarget: EventTarget @@ -56,6 +86,8 @@ export class ClientEventBus { } constructor({ port = 4206, + host = 'localhost', + protocol = 'http', debug = false, connectToServerBus = false, }: ClientEventBusConfig = {}) { @@ -63,6 +95,8 @@ export class ClientEventBus { this.#broadcastChannel = new BroadcastChannel('tanstack-devtools') this.#eventSource = null this.#port = getDefaultPort(port) + this.#host = getDefaultHost(host) + this.#protocol = getDefaultProtocol(protocol) this.#socket = null this.#connectToServerBus = connectToServerBus this.#eventTarget = this.getGlobalTarget() @@ -102,7 +136,7 @@ export class ClientEventBus { } else if (this.#eventSource) { this.debugLog('Emitting event to server via SSE', event) - fetch(`http://localhost:${this.#port}/__devtools/send`, { + fetch(`${this.#protocol}://${this.#host}:${this.#port}/__devtools/send`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: json, @@ -160,7 +194,7 @@ export class ClientEventBus { private connectSSE() { this.debugLog('Connecting to SSE server') this.#eventSource = new EventSource( - `http://localhost:${this.#port}/__devtools/sse`, + `${this.#protocol}://${this.#host}:${this.#port}/__devtools/sse`, ) this.#eventSource.onmessage = (e) => { this.debugLog('Received message from SSE server', e.data) @@ -171,7 +205,10 @@ export class ClientEventBus { private connectWebSocket() { this.debugLog('Connecting to WebSocket server') - this.#socket = new WebSocket(`ws://localhost:${this.#port}/__devtools/ws`) + const wsProtocol = this.#protocol === 'https' ? 'wss' : 'ws' + this.#socket = new WebSocket( + `${wsProtocol}://${this.#host}:${this.#port}/__devtools/ws`, + ) this.#socket.onmessage = (e) => { this.debugLog('Received message from server', e.data) this.handleEventReceived(e.data) diff --git a/packages/event-bus/src/server/index.ts b/packages/event-bus/src/server/index.ts index 015a9aa7..37bf0ca9 100644 --- a/packages/event-bus/src/server/index.ts +++ b/packages/event-bus/src/server/index.ts @@ -1,2 +1,6 @@ export { ServerEventBus } from './server' -export type { TanStackDevtoolsEvent, ServerEventBusConfig } from './server' +export type { + TanStackDevtoolsEvent, + ServerEventBusConfig, + HttpServerLike, +} from './server' diff --git a/packages/event-bus/src/server/server.ts b/packages/event-bus/src/server/server.ts index d58234b3..603df0b2 100644 --- a/packages/event-bus/src/server/server.ts +++ b/packages/event-bus/src/server/server.ts @@ -1,6 +1,7 @@ import http from 'node:http' import { WebSocket, WebSocketServer } from 'ws' import { parseWithBigInt, stringifyWithBigInt } from '../utils/json' +import type { Duplex } from 'node:stream' // Shared types export interface TanStackDevtoolsEvent< @@ -20,9 +21,30 @@ declare global { var __TANSTACK_EVENT_TARGET__: EventTarget | null } +/** + * A minimal server interface that both `http.Server` and `http2.Http2SecureServer` satisfy. + * Used so the event bus can piggyback on any compatible server without depending on http2 types directly. + */ +export interface HttpServerLike { + on: (event: string, listener: (...args: Array) => void) => this + removeListener: ( + event: string, + listener: (...args: Array) => void, + ) => this + address: () => ReturnType +} + export interface ServerEventBusConfig { port?: number | undefined + host?: string | undefined debug?: boolean | undefined + /** + * An external HTTP server to attach to instead of creating a standalone one. + * When provided, the event bus will add its SSE/POST/WS handlers to this server + * instead of creating and listening on its own server. + * Useful for piggybacking on Vite's HTTPS-enabled server. + */ + httpServer?: HttpServerLike | undefined } export class ServerEventBus { @@ -32,7 +54,15 @@ export class ServerEventBus { #server: http.Server | null = null #wssServer: WebSocketServer | null = null #port: number + #host: string #debug: boolean + #externalServer: HttpServerLike | null = null + #externalRequestHandler: + | ((req: http.IncomingMessage, res: http.ServerResponse) => void) + | null = null + #externalUpgradeHandler: + | ((req: http.IncomingMessage, socket: Duplex, head: Buffer) => void) + | null = null #dispatcher = (e: Event) => { const event = (e as CustomEvent).detail this.debugLog('Dispatching event from dispatcher, forwarding', event) @@ -44,8 +74,14 @@ export class ServerEventBus { ) this.#eventTarget.dispatchEvent(new CustomEvent('tanstack-connect-success')) } - constructor({ port = 4206, debug = false }: ServerEventBusConfig = {}) { + constructor({ + port = 4206, + host = 'localhost', + debug = false, + httpServer, + }: ServerEventBusConfig = {}) { this.#port = port + this.#host = host this.#eventTarget = globalThis.__TANSTACK_EVENT_TARGET__ ?? new EventTarget() // we want to set the global event target only once so that we can emit/listen to events on the server @@ -54,6 +90,7 @@ export class ServerEventBus { } this.#server = globalThis.__TANSTACK_DEVTOOLS_SERVER__ ?? null this.#wssServer = globalThis.__TANSTACK_DEVTOOLS_WSS_SERVER__ ?? null + this.#externalServer = httpServer ?? null this.#debug = debug this.debugLog('Initializing server event bus') } @@ -173,9 +210,6 @@ export class ServerEventBus { resolve(this.#port) return } - this.debugLog('Starting server event bus') - const server = this.createSSEServer() - const wss = this.createWebSocketServer() this.#eventTarget.addEventListener( 'tanstack-dispatch-event', @@ -185,6 +219,85 @@ export class ServerEventBus { 'tanstack-connect', this.#connectFunction, ) + + // When an external server is provided (e.g. Vite's HTTPS server), + // piggyback on it instead of creating a standalone server. + if (this.#externalServer) { + this.debugLog('Piggybacking on external HTTP server') + const wss = this.createWebSocketServer() + this.handleNewConnection(wss) + + // Add request handler for SSE and POST endpoints + this.#externalRequestHandler = ( + req: http.IncomingMessage, + res: http.ServerResponse, + ) => { + if (req.url === '/__devtools/sse') { + res.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + 'Access-Control-Allow-Origin': '*', + }) + res.write('\n') + this.debugLog('New SSE client connected (external server)') + this.#sseClients.add(res) + req.on('close', () => this.#sseClients.delete(res)) + return + } + + 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, + ) + this.emitToServer(msg) + } catch {} + }) + res.writeHead(200).end() + return + } + } + + // Add upgrade handler for WebSocket + this.#externalUpgradeHandler = ( + req: http.IncomingMessage, + socket: Duplex, + head: Buffer, + ) => { + if (req.url === '/__devtools/ws') { + wss.handleUpgrade(req, socket, head, (ws) => { + this.debugLog( + 'WebSocket connection established (external server)', + ) + wss.emit('connection', ws, req) + }) + } + } + + this.#externalServer.on('request', this.#externalRequestHandler) + this.#externalServer.on('upgrade', this.#externalUpgradeHandler) + + // Resolve port from the external server's address + const address = this.#externalServer.address() + if (typeof address === 'object' && address) { + this.#port = address.port + } + this.debugLog(`Attached to external server on port ${this.#port}`) + resolve(this.#port) + return + } + + // Standalone mode: create our own HTTP + WebSocket server + this.debugLog('Starting server event bus') + const server = this.createSSEServer() + const wss = this.createWebSocketServer() + this.handleNewConnection(wss) // Handle connection upgrade for WebSocket @@ -198,12 +311,12 @@ export class ServerEventBus { }) const tryListen = (port: number) => { - server.listen(port, () => { + server.listen(port, this.#host, () => { const address = server.address() if (typeof address === 'object' && address) { this.#port = address.port } - this.debugLog(`Listening on http://localhost:${this.#port}`) + this.debugLog(`Listening on http://${this.#host}:${this.#port}`) resolve(this.#port) }) } @@ -232,9 +345,29 @@ export class ServerEventBus { } stop() { - this.#server?.close(() => { - this.debugLog('Server stopped') - }) + // Only close the server if we own it (standalone mode) + if (!this.#externalServer) { + this.#server?.close(() => { + this.debugLog('Server stopped') + }) + } else { + // Remove our listeners from the external server without closing it + if (this.#externalRequestHandler) { + this.#externalServer.removeListener( + 'request', + this.#externalRequestHandler, + ) + this.#externalRequestHandler = null + } + if (this.#externalUpgradeHandler) { + this.#externalServer.removeListener( + 'upgrade', + this.#externalUpgradeHandler, + ) + this.#externalUpgradeHandler = null + } + this.debugLog('Detached from external server') + } this.#wssServer?.close(() => { this.debugLog('WebSocket server stopped') }) @@ -245,6 +378,7 @@ export class ServerEventBus { this.debugLog('Cleared all WS/SSE connections') this.#server = null this.#wssServer = null + this.#externalServer = null this.#eventTarget.removeEventListener( 'tanstack-dispatch-event', this.#dispatcher, diff --git a/packages/event-bus/tests/client.test.ts b/packages/event-bus/tests/client.test.ts new file mode 100644 index 00000000..36f47c96 --- /dev/null +++ b/packages/event-bus/tests/client.test.ts @@ -0,0 +1,365 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { ClientEventBus } from '../src/client/client' + +// Stub BroadcastChannel since it's not available in test environment +vi.stubGlobal( + 'BroadcastChannel', + class { + postMessage = vi.fn() + addEventListener = vi.fn() + removeEventListener = vi.fn() + close = vi.fn() + onmessage: any = null + }, +) + +// Track WebSocket and EventSource constructor calls +const mockWebSocketInstances: Array = [] +const mockEventSourceInstances: Array = [] + +function createMockWebSocketClass() { + const cls = class MockWebSocket { + static OPEN = 1 + static CLOSED = 3 + url: string + readyState = 1 + onmessage: any = null + onclose: any = null + onerror: any = null + send = vi.fn() + close = vi.fn() + constructor(url: string) { + this.url = url + mockWebSocketInstances.push(this) + } + } + return cls +} + +function createMockEventSourceClass() { + return class MockEventSource { + url: string + onmessage: any = null + close = vi.fn() + constructor(url: string) { + this.url = url + mockEventSourceInstances.push(this) + } + } +} + +function createThrowingWebSocketClass() { + return class ThrowingWebSocket { + static OPEN = 1 + static CLOSED = 3 + constructor() { + throw new Error('WS not available') + } + } +} + +describe('ClientEventBus', () => { + beforeEach(() => { + mockWebSocketInstances.length = 0 + mockEventSourceInstances.length = 0 + vi.stubGlobal('WebSocket', createMockWebSocketClass()) + vi.stubGlobal('EventSource', createMockEventSourceClass()) + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({})) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + describe('constructor defaults', () => { + it('should initialize with default values', () => { + const bus = new ClientEventBus() + bus.start() + // Default does not connect to server bus, so no WS/SSE created + expect(mockWebSocketInstances.length).toBe(0) + expect(mockEventSourceInstances.length).toBe(0) + bus.stop() + }) + }) + + describe('connectWebSocket with protocol', () => { + it('should use ws:// when protocol is http (default)', () => { + const bus = new ClientEventBus({ + connectToServerBus: true, + port: 4206, + host: 'localhost', + protocol: 'http', + }) + bus.start() + + expect(mockWebSocketInstances.length).toBe(1) + expect(mockWebSocketInstances[0].url).toBe( + 'ws://localhost:4206/__devtools/ws', + ) + bus.stop() + }) + + it('should use wss:// when protocol is https', () => { + const bus = new ClientEventBus({ + connectToServerBus: true, + port: 4206, + host: 'localhost', + protocol: 'https', + }) + bus.start() + + expect(mockWebSocketInstances.length).toBe(1) + expect(mockWebSocketInstances[0].url).toBe( + 'wss://localhost:4206/__devtools/ws', + ) + bus.stop() + }) + + it('should use custom host in WebSocket URL', () => { + const bus = new ClientEventBus({ + connectToServerBus: true, + port: 9999, + host: 'myhost.local', + protocol: 'http', + }) + bus.start() + + expect(mockWebSocketInstances[0].url).toBe( + 'ws://myhost.local:9999/__devtools/ws', + ) + bus.stop() + }) + + it('should use custom host and https in WebSocket URL', () => { + const bus = new ClientEventBus({ + connectToServerBus: true, + port: 443, + host: 'secure.example.com', + protocol: 'https', + }) + bus.start() + + expect(mockWebSocketInstances[0].url).toBe( + 'wss://secure.example.com:443/__devtools/ws', + ) + bus.stop() + }) + }) + + describe('connectSSE with protocol', () => { + it('should use http:// when protocol is http', () => { + // Make WebSocket constructor throw to force SSE fallback + vi.stubGlobal('WebSocket', createThrowingWebSocketClass()) + + const bus = new ClientEventBus({ + connectToServerBus: true, + port: 4206, + host: 'localhost', + protocol: 'http', + }) + bus.start() + + expect(mockEventSourceInstances.length).toBe(1) + expect(mockEventSourceInstances[0].url).toBe( + 'http://localhost:4206/__devtools/sse', + ) + bus.stop() + }) + + it('should use https:// when protocol is https', () => { + vi.stubGlobal('WebSocket', createThrowingWebSocketClass()) + + const bus = new ClientEventBus({ + connectToServerBus: true, + port: 4206, + host: 'localhost', + protocol: 'https', + }) + bus.start() + + expect(mockEventSourceInstances.length).toBe(1) + expect(mockEventSourceInstances[0].url).toBe( + 'https://localhost:4206/__devtools/sse', + ) + bus.stop() + }) + + it('should use custom host in SSE URL', () => { + vi.stubGlobal('WebSocket', createThrowingWebSocketClass()) + + const bus = new ClientEventBus({ + connectToServerBus: true, + port: 8080, + host: 'dev.example.com', + protocol: 'https', + }) + bus.start() + + expect(mockEventSourceInstances[0].url).toBe( + 'https://dev.example.com:8080/__devtools/sse', + ) + bus.stop() + }) + }) + + describe('emitToServer with protocol and host', () => { + it('should use correct protocol and host in fetch URL when using SSE fallback', () => { + // Make WebSocket constructor throw to force SSE fallback + vi.stubGlobal('WebSocket', createThrowingWebSocketClass()) + + const bus = new ClientEventBus({ + connectToServerBus: true, + port: 9999, + host: 'myhost', + protocol: 'https', + }) + bus.start() + + // Dispatch an event to trigger emitToServer + window.dispatchEvent( + new CustomEvent('tanstack-dispatch-event', { + detail: { + type: 'test-event', + payload: { foo: 'bar' }, + }, + }), + ) + + expect(fetch).toHaveBeenCalledWith( + 'https://myhost:9999/__devtools/send', + expect.objectContaining({ + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + }), + ) + bus.stop() + }) + + it('should use http://localhost by default in fetch URL', () => { + vi.stubGlobal('WebSocket', createThrowingWebSocketClass()) + + const bus = new ClientEventBus({ + connectToServerBus: true, + }) + bus.start() + + window.dispatchEvent( + new CustomEvent('tanstack-dispatch-event', { + detail: { + type: 'test-event', + payload: {}, + }, + }), + ) + + expect(fetch).toHaveBeenCalledWith( + 'http://localhost:4206/__devtools/send', + expect.objectContaining({ + method: 'POST', + }), + ) + bus.stop() + }) + }) + + describe('event dispatching', () => { + it('should emit events to a subscribed listener', () => { + const bus = new ClientEventBus() + bus.start() + const handler = vi.fn() + window.addEventListener('test:event', handler) + + window.dispatchEvent( + new CustomEvent('tanstack-dispatch-event', { + detail: { + type: 'test:event', + payload: { foo: 'bar' }, + }, + }), + ) + expect(handler).toHaveBeenCalled() + window.removeEventListener('test:event', handler) + bus.stop() + }) + + it('should emit events to global listeners', () => { + const bus = new ClientEventBus() + bus.start() + const handler = vi.fn() + window.addEventListener('tanstack-devtools-global', handler) + + window.dispatchEvent( + new CustomEvent('tanstack-dispatch-event', { + detail: { + type: 'test:event', + payload: { foo: 'bar' }, + }, + }), + ) + expect(handler).toHaveBeenCalled() + window.removeEventListener('tanstack-devtools-global', handler) + bus.stop() + }) + }) + + describe('debug logging', () => { + it('should log when debug is enabled', () => { + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const bus = new ClientEventBus({ debug: true }) + bus.start() + + expect(logSpy).toHaveBeenCalledWith( + '🌴 [tanstack-devtools:client-bus]', + 'Initializing client event bus', + ) + bus.stop() + }) + + it('should not log when debug is disabled', () => { + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const bus = new ClientEventBus({ debug: false }) + bus.start() + + expect(logSpy).not.toHaveBeenCalled() + bus.stop() + }) + }) + + describe('stop()', () => { + it('should clean up event listeners on stop', () => { + const bus = new ClientEventBus() + bus.start() + + const handler = vi.fn() + window.addEventListener('test:cleanup', handler) + + bus.stop() + + // After stop, dispatching should not trigger the bus dispatcher + // (but the direct listener on window still works) + window.removeEventListener('test:cleanup', handler) + }) + + it('should close WebSocket on stop', () => { + const bus = new ClientEventBus({ connectToServerBus: true }) + bus.start() + + expect(mockWebSocketInstances.length).toBe(1) + bus.stop() + + expect(mockWebSocketInstances[0].close).toHaveBeenCalled() + }) + + it('should close EventSource on stop', () => { + vi.stubGlobal('WebSocket', createThrowingWebSocketClass()) + + const bus = new ClientEventBus({ connectToServerBus: true }) + bus.start() + + expect(mockEventSourceInstances.length).toBe(1) + bus.stop() + + expect(mockEventSourceInstances[0].close).toHaveBeenCalled() + }) + }) +}) diff --git a/packages/event-bus/tests/server.test.ts b/packages/event-bus/tests/server.test.ts new file mode 100644 index 00000000..e36ad165 --- /dev/null +++ b/packages/event-bus/tests/server.test.ts @@ -0,0 +1,251 @@ +import http from 'node:http' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { ServerEventBus } from '../src/server/server' + +// Clear globalThis between tests to avoid cross-test contamination +function clearGlobals() { + globalThis.__TANSTACK_DEVTOOLS_SERVER__ = null + globalThis.__TANSTACK_DEVTOOLS_WSS_SERVER__ = null + globalThis.__TANSTACK_EVENT_TARGET__ = null +} + +describe('ServerEventBus', () => { + let bus: ServerEventBus + const originalNodeEnv = process.env.NODE_ENV + + beforeEach(() => { + clearGlobals() + process.env.NODE_ENV = 'development' + }) + + afterEach(async () => { + bus?.stop() + clearGlobals() + process.env.NODE_ENV = originalNodeEnv + // Small delay to let servers fully close + await new Promise((resolve) => setTimeout(resolve, 50)) + }) + + describe('constructor', () => { + it('should initialize with default config', () => { + bus = new ServerEventBus() + expect(bus.port).toBe(4206) + }) + + it('should accept custom port', () => { + bus = new ServerEventBus({ port: 9999 }) + expect(bus.port).toBe(9999) + }) + + it('should log when debug is enabled', () => { + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + bus = new ServerEventBus({ debug: true }) + expect(logSpy).toHaveBeenCalledWith( + '🌴 [tanstack-devtools:server-bus] ', + 'Initializing server event bus', + ) + logSpy.mockRestore() + }) + + it('should not log when debug is disabled', () => { + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + bus = new ServerEventBus({ debug: false }) + expect(logSpy).not.toHaveBeenCalled() + logSpy.mockRestore() + }) + }) + + describe('start() - standalone mode', () => { + it('should start and resolve with a port', async () => { + bus = new ServerEventBus({ port: 0 }) // port 0 = OS-assigned + const port = await bus.start() + expect(port).toBeGreaterThan(0) + expect(bus.port).toBe(port) + }) + + it('should create its own HTTP server in standalone mode', async () => { + bus = new ServerEventBus({ port: 0 }) + await bus.start() + expect(globalThis.__TANSTACK_DEVTOOLS_SERVER__).not.toBeNull() + }) + + it('should create a WebSocket server in standalone mode', async () => { + bus = new ServerEventBus({ port: 0 }) + await bus.start() + expect(globalThis.__TANSTACK_DEVTOOLS_WSS_SERVER__).not.toBeNull() + }) + + it('should resolve immediately in non-development environment', async () => { + process.env.NODE_ENV = 'production' + bus = new ServerEventBus({ port: 5555 }) + const port = await bus.start() + expect(port).toBe(5555) + // No server should be created + expect(globalThis.__TANSTACK_DEVTOOLS_SERVER__).toBeNull() + }) + + it('should resolve immediately if server is already running (HMR guard)', async () => { + bus = new ServerEventBus({ port: 0 }) + await bus.start() + // Create a new bus that picks up globalThis servers + const bus2 = new ServerEventBus({ port: 0 }) + const port2 = await bus2.start() + // Should resolve with the port without creating new server + expect(port2).toBeGreaterThanOrEqual(0) + }) + + it('should handle EADDRINUSE by falling back to OS-assigned port', async () => { + // Occupy a port on localhost first + const blocker = http.createServer() + const blockerPort = await new Promise((resolve) => { + blocker.listen(0, 'localhost', () => { + const addr = blocker.address() + if (typeof addr === 'object' && addr) resolve(addr.port) + }) + }) + + try { + // Ensure globals are clean so the bus creates a fresh server + clearGlobals() + bus = new ServerEventBus({ port: blockerPort, host: 'localhost' }) + const port = await bus.start() + // Should get a different port since the preferred one was in use + expect(port).toBeGreaterThan(0) + // The port should be different from the blocked port since EADDRINUSE triggers fallback + expect(port).not.toBe(blockerPort) + } finally { + blocker.close() + } + }) + + it('should pass host to server.listen', async () => { + bus = new ServerEventBus({ port: 0, host: '127.0.0.1' }) + const port = await bus.start() + expect(port).toBeGreaterThan(0) + }) + }) + + describe('start() - external httpServer mode', () => { + let externalServer: http.Server + + beforeEach(async () => { + externalServer = http.createServer() + await new Promise((resolve) => { + externalServer.listen(0, () => resolve()) + }) + }) + + afterEach(() => { + externalServer.close() + }) + + it('should not create a standalone server when httpServer is provided', async () => { + bus = new ServerEventBus({ httpServer: externalServer }) + await bus.start() + // Should NOT have created its own server in globalThis + expect(globalThis.__TANSTACK_DEVTOOLS_SERVER__).toBeNull() + }) + + it('should create a WebSocket server when httpServer is provided', async () => { + bus = new ServerEventBus({ httpServer: externalServer }) + await bus.start() + expect(globalThis.__TANSTACK_DEVTOOLS_WSS_SERVER__).not.toBeNull() + }) + + it('should resolve port from external server address', async () => { + bus = new ServerEventBus({ httpServer: externalServer }) + const port = await bus.start() + const addr = externalServer.address() + const expectedPort = typeof addr === 'object' && addr ? addr.port : 0 + expect(port).toBe(expectedPort) + }) + + it('should handle SSE requests on external server', async () => { + bus = new ServerEventBus({ httpServer: externalServer }) + const port = await bus.start() + + // Make an SSE request + const response = await new Promise((resolve) => { + http.get(`http://localhost:${port}/__devtools/sse`, (res) => { + resolve(res) + }) + }) + + expect(response.statusCode).toBe(200) + expect(response.headers['content-type']).toBe('text/event-stream') + response.destroy() + }) + + it('should handle POST requests on external server', async () => { + bus = new ServerEventBus({ httpServer: externalServer }) + const port = await bus.start() + + const response = await new Promise((resolve) => { + const req = http.request( + { + hostname: 'localhost', + port, + path: '/__devtools/send', + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + }, + (res) => resolve(res), + ) + req.write( + JSON.stringify({ type: 'test-event', payload: { foo: 'bar' } }), + ) + req.end() + }) + + expect(response.statusCode).toBe(200) + }) + + it('should remove listeners from external server on stop() without closing it', async () => { + bus = new ServerEventBus({ httpServer: externalServer }) + await bus.start() + + const listenerCountBefore = externalServer.listenerCount('request') + expect(listenerCountBefore).toBeGreaterThan(0) + + bus.stop() + + // Wait a tick for cleanup + await new Promise((resolve) => setTimeout(resolve, 50)) + + // The external server should still be listening + const addr = externalServer.address() + expect(addr).not.toBeNull() + + // Our request listener should be removed + const listenerCountAfter = externalServer.listenerCount('request') + expect(listenerCountAfter).toBeLessThan(listenerCountBefore) + }) + }) + + describe('stop()', () => { + it('should close standalone server on stop', async () => { + bus = new ServerEventBus({ port: 0 }) + await bus.start() + expect(globalThis.__TANSTACK_DEVTOOLS_SERVER__).not.toBeNull() + + bus.stop() + await new Promise((resolve) => setTimeout(resolve, 100)) + }) + + it('should clear all connections on stop', async () => { + bus = new ServerEventBus({ port: 0 }) + await bus.start() + + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + bus = new ServerEventBus({ port: 0, debug: true }) + await bus.start() + bus.stop() + + expect(logSpy).toHaveBeenCalledWith( + '🌴 [tanstack-devtools:server-bus] ', + 'Clearing all connections', + ) + logSpy.mockRestore() + }) + }) +})