diff --git a/README.md b/README.md index f6fcc0d..d482b0f 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,7 @@ Dory is: - 🎨 Customizable via Tailwind and minimal theme overrides - 🌍 Deploy to Netlify, Vercel, S3, GitHub Pages β€” your call - 🌐 HTTP client for testing API endpoints (automatic inference from openapi.json) +- πŸ”„ WebSocket client for testing real-time connections with message history and authentication --- @@ -192,4 +193,4 @@ We’re actively improving Dory. Here’s what’s on deck: * [ ] 🎨 **Themes** β€” full theming support with a flexible theme API * [ ] 🌐 **Multi-language Support** β€” internationalization (i18n) & localization (l10n) * [ ] πŸš€ **GraphQL Client** β€” integrated GraphQL playground and client support -* [ ] πŸ”„ **WebSocket Client** β€” built-in WebSocket utilities for real-time API demos \ No newline at end of file +* [x] πŸ”„ **WebSocket Client** β€” built-in WebSocket utilities for real-time API demos \ No newline at end of file diff --git a/docs/features.mdx b/docs/features.mdx index 6878a44..d09a5f4 100644 --- a/docs/features.mdx +++ b/docs/features.mdx @@ -438,3 +438,61 @@ E = mc^2 + +
+
+
+ +## API Testing Components + +### HTTP API Playground + +Interactive playground for testing HTTP API endpoints with authentication, headers, and request/response handling. + +```mdx + +``` + +### WebSocket Playground + +Interactive playground for testing WebSocket connections with real-time messaging capabilities. + +```mdx + +``` + + + +The WebSocket playground provides: +- Real-time connection management +- Message history with timestamps +- Custom headers and subprotocols +- Text and binary message support +- Multiple server endpoints +- Connection status indicators diff --git a/docs/websocket-playground-demo.mdx b/docs/websocket-playground-demo.mdx new file mode 100644 index 0000000..774fa42 --- /dev/null +++ b/docs/websocket-playground-demo.mdx @@ -0,0 +1,128 @@ +--- +title: 'WebSocket Playground Demo' +description: 'Interactive WebSocket playground for testing real-time connections' +--- + +# WebSocket Playground Demo + +This page demonstrates the new WebSocket playground that allows you to test WebSocket connections directly from the documentation. + +## Basic WebSocket Connection + +Test a simple WebSocket connection: + + + +## WebSocket with Subprotocols + +Test a WebSocket connection with specific subprotocols: + + + +## Chat WebSocket + +Test a chat WebSocket with authentication headers: + + + +## Features + +The WebSocket playground provides: + +- **Real-time Connection Management**: Connect and disconnect from WebSocket servers +- **Connection Status Indicator**: Visual feedback for connection state (connected, connecting, disconnected, error) +- **Message History**: See all sent and received messages with timestamps +- **Custom Headers**: Add headers for the WebSocket handshake (authentication, etc.) +- **Subprotocol Support**: Specify WebSocket subprotocols for specialized connections +- **Text and Binary Messages**: Send both text and binary data +- **Multiple Servers**: Easy switching between different WebSocket endpoints +- **Error Handling**: Clear error messages for connection and message failures + +## Usage in Documentation + +To add a WebSocket playground to your documentation, use the `WebSocketPlayground` component: + +```mdx + +``` + +## WebSocket vs HTTP + +Unlike HTTP requests, WebSocket connections are: + +- **Stateful**: The connection remains open for bidirectional communication +- **Real-time**: Messages can be sent and received instantly +- **Event-driven**: Both client and server can initiate message sending +- **Lower overhead**: No need for repeated HTTP handshakes + +## Common Use Cases + +WebSocket playgrounds are perfect for: + +- **Real-time APIs**: Chat applications, live updates, notifications +- **GraphQL Subscriptions**: Real-time data subscriptions +- **Gaming**: Real-time multiplayer game communication +- **IoT**: Device communication and monitoring +- **Trading**: Real-time market data and order updates +- **Collaborative Tools**: Real-time document editing, shared whiteboards + +The playground seamlessly integrates with your existing documentation and provides a comprehensive testing environment for WebSocket-based APIs. \ No newline at end of file diff --git a/src/mdx/mdx.tsx b/src/mdx/mdx.tsx index 17c3923..1130845 100644 --- a/src/mdx/mdx.tsx +++ b/src/mdx/mdx.tsx @@ -18,6 +18,7 @@ export { Steps, Step } from './step'; export { Table as table, Th as th, Td as td } from './table'; export { UnorderedList as ul, OrderedList as ol, ListItem as li } from './list'; export { APIPlayground } from './api-playground'; +export { WebSocketPlayground } from './websocket-playground'; export { Source } from './source'; export const wrapper = ({ children }: { children: preact.ComponentChildren }) => { diff --git a/src/mdx/websocket-playground.tsx b/src/mdx/websocket-playground.tsx new file mode 100644 index 0000000..4cd718c --- /dev/null +++ b/src/mdx/websocket-playground.tsx @@ -0,0 +1,625 @@ +import { Input, Tab, TabGroup, TabList, TabPanel, TabPanels } from '@headlessui/react' +import { CheckIcon } from '@heroicons/react/24/outline' +import classNames from 'classnames' +import { PencilIcon, TrashIcon, XIcon } from 'lucide-react' +import { useCallback, useEffect, useRef, useState } from 'preact/hooks' +import { Highlight, themes } from 'prism-react-renderer' +import Dropdown from '../components/dropdown' +import { useDarkMode } from '../utils/hooks' +import { Fence } from './fence' +import { Tag } from './tag' + +export interface Server { + url: string; + description?: string; +} + +interface WebSocketPlaygroundProps { + url: string + title?: string + description?: string + headers?: Record + protocols?: string[] + servers?: Server[] +} + +interface RequestConfig { + url: string + headers: Record + protocols: string[] +} + +interface WebSocketMessage { + id: string + type: 'sent' | 'received' | 'connection' | 'error' + content: string + timestamp: number + messageType?: 'text' | 'binary' +} + +interface ConnectionState { + status: 'disconnected' | 'connecting' | 'connected' | 'error' + error?: string + connectedAt?: number +} + +export function PlayButton(props: React.ComponentPropsWithoutRef<'svg'>) { + return ( + + ) +} + +export function DisconnectButton(props: React.ComponentPropsWithoutRef<'svg'>) { + return ( + + ) +} + +function LoadingSpinner(props: React.ComponentPropsWithoutRef<'svg'>) { + return ( + + ) +} + +interface HeadersTabProps { + requestConfig: RequestConfig + setRequestConfig: React.Dispatch> +} + +interface HeaderItem { + key: string + value: string + isEditing: boolean + isNew?: boolean +} + +export const HeadersTab = ({ requestConfig, setRequestConfig }: HeadersTabProps) => { + const [headersList, setHeadersList] = useState([]) + const hasInitialized = useRef(false) + + useEffect(() => { + if (!hasInitialized.current) { + const initialHeaders = Object.entries(requestConfig.headers ?? {}).map(([key, value]) => ({ + key, + value, + isEditing: false + })) + setHeadersList(initialHeaders) + hasInitialized.current = true + } + }, [requestConfig.headers]) + + useEffect(() => { + const newHeaders: Record = {} + headersList.forEach(h => { + if (h.key.trim()) newHeaders[h.key] = h.value + }) + setRequestConfig(prev => ({ ...prev, headers: newHeaders })) + }, [headersList, setRequestConfig]) + + const updateHeader = (index: number, field: 'key' | 'value', value: string) => { + setHeadersList(list => + list.map((h, i) => (i === index ? { ...h, [field]: value } : h)) + ) + } + + const saveHeader = (index: number) => { + setHeadersList(list => + list.map((h, i) => (i === index ? { ...h, isEditing: false, isNew: false } : h)) + ) + } + + const cancelEdit = (index: number) => { + setHeadersList(list => + list.reduce((acc, h, i) => { + if (i === index) { + if (h.isNew) return acc + return [...acc, { ...h, isEditing: false }] + } + return [...acc, h] + }, []) + ) + } + + const editHeader = (index: number) => { + setHeadersList(list => + list.map((h, i) => (i === index ? { ...h, isEditing: true } : h)) + ) + } + + const removeHeader = (index: number) => { + setHeadersList(list => list.filter((_, i) => i !== index)) + } + + const addEmptyHeaderRow = () => { + setHeadersList(list => [ + ...list, + { key: '', value: '', isEditing: true, isNew: true } + ]) + } + + return ( +
+
+ Key + Value +
+ {headersList.map((hdr, idx) => ( +
+ {hdr.isEditing ? ( + <> + updateHeader(idx, 'key', (e.target as HTMLInputElement).value)} + placeholder="Header name" + className="w-32 rounded-lg bg-zinc-800 border border-zinc-600 px-2 py-1 text-sm text-white" + /> + updateHeader(idx, 'value', (e.target as HTMLInputElement).value)} + placeholder="Header value" + className="flex-1 rounded-lg bg-zinc-800 border border-zinc-600 px-2 py-1 text-sm text-white" + /> + + + + ) : ( + <> + {hdr.key} + {hdr.value} + + + + )} +
+ ))} + +
+ ) +} + +export function WebSocketPlayground({ + url, + title, + description, + headers: defaultHeaders = {}, + protocols = [], + servers = [] +}: WebSocketPlaygroundProps) { + const [selectedTab, setSelectedTab] = useState(0) + const [connection, setConnection] = useState({ + status: 'disconnected' + }) + const [messages, setMessages] = useState([]) + const [messageInput, setMessageInput] = useState('') + const [messageType, setMessageType] = useState<'text' | 'binary'>('text') + const { isDark } = useDarkMode() + const [selectedServer, setSelectedServer] = useState(servers?.[0] || null) + const websocketRef = useRef(null) + const messageIdCounter = useRef(0) + + // Request configuration state + const [requestConfig, setRequestConfig] = useState({ + url: (selectedServer?.url || '') + url, + headers: defaultHeaders, + protocols: protocols + }) + + const addMessage = useCallback((message: Omit) => { + const newMessage: WebSocketMessage = { + ...message, + id: `msg-${++messageIdCounter.current}`, + } + setMessages(prev => [...prev, newMessage]) + }, []) + + const connectWebSocket = useCallback(() => { + if (websocketRef.current) return + + setConnection({ status: 'connecting' }) + setMessages([]) + + try { + const ws = new WebSocket( + requestConfig.url, + requestConfig.protocols.length > 0 ? requestConfig.protocols : undefined + ) + + // Set custom headers if supported by the browser + // Note: WebSocket API doesn't support custom headers in browsers + // This is a limitation of the WebSocket API itself + + ws.onopen = () => { + setConnection({ status: 'connected', connectedAt: Date.now() }) + addMessage({ + type: 'connection', + content: 'Connected to WebSocket server', + timestamp: Date.now() + }) + } + + ws.onmessage = (event) => { + const isBlob = event.data instanceof Blob + const isBinary = event.data instanceof ArrayBuffer + + if (isBlob || isBinary) { + addMessage({ + type: 'received', + content: `[Binary message: ${isBlob ? 'Blob' : 'ArrayBuffer'} (${event.data.size || event.data.byteLength} bytes)]`, + timestamp: Date.now(), + messageType: 'binary' + }) + } else { + addMessage({ + type: 'received', + content: event.data, + timestamp: Date.now(), + messageType: 'text' + }) + } + } + + ws.onclose = (event) => { + setConnection({ status: 'disconnected' }) + addMessage({ + type: 'connection', + content: `Connection closed (Code: ${event.code}, Reason: ${event.reason || 'None'})`, + timestamp: Date.now() + }) + websocketRef.current = null + } + + ws.onerror = () => { + setConnection({ status: 'error', error: 'WebSocket connection error' }) + addMessage({ + type: 'error', + content: 'WebSocket connection error', + timestamp: Date.now() + }) + } + + websocketRef.current = ws + } catch (error) { + setConnection({ + status: 'error', + error: error instanceof Error ? error.message : 'Failed to connect' + }) + addMessage({ + type: 'error', + content: error instanceof Error ? error.message : 'Failed to connect', + timestamp: Date.now() + }) + } + }, [requestConfig, addMessage]) + + const disconnectWebSocket = useCallback(() => { + if (websocketRef.current) { + websocketRef.current.close() + websocketRef.current = null + } + }, []) + + const sendMessage = useCallback(() => { + if (!websocketRef.current || !messageInput.trim()) return + + try { + if (messageType === 'text') { + websocketRef.current.send(messageInput) + addMessage({ + type: 'sent', + content: messageInput, + timestamp: Date.now(), + messageType: 'text' + }) + } else { + // For binary messages, try to parse as JSON and convert to ArrayBuffer + const encoder = new TextEncoder() + const data = encoder.encode(messageInput) + websocketRef.current.send(data) + addMessage({ + type: 'sent', + content: `[Binary message sent: ${data.byteLength} bytes]`, + timestamp: Date.now(), + messageType: 'binary' + }) + } + + setMessageInput('') + } catch (error) { + addMessage({ + type: 'error', + content: error instanceof Error ? error.message : 'Failed to send message', + timestamp: Date.now() + }) + } + }, [messageInput, messageType, addMessage]) + + const clearMessages = useCallback(() => { + setMessages([]) + }, []) + + useEffect(() => { + return () => { + if (websocketRef.current) { + websocketRef.current.close() + } + } + }, []) + + const tabs = [ + { name: 'Headers', key: 'headers' }, + { name: 'Protocols', key: 'protocols' }, + { name: 'Messages', key: 'messages' } + ] + + return ( +
+ {/* Header */} +
+
+ WS + {title || url} +
+
+ + {connection.status} + +
+
+ +
+ + {description && ( +
+

{description}

+
+ )} + +
+ {/* Server Selection */} + {servers.length > 0 && ( +
+ + ({ + label: server.description || server.url, + onClick: () => { + setSelectedServer(server) + setRequestConfig(prev => ({ ...prev, url: server.url + url })) + } + }))} + className="w-full" + /> +
+ )} + + {/* URL Input */} +
+ + setRequestConfig(prev => ({ ...prev, url: (e.target as HTMLInputElement).value }))} + placeholder="ws://localhost:8080/ws or wss://api.example.com/ws" + className="w-full rounded-lg bg-zinc-800 border border-zinc-600 px-3 py-2 text-sm text-white placeholder-zinc-400 focus:border-sky-500 focus:outline-none font-mono" + disabled={connection.status === 'connected'} + /> +
+ + {/* Configuration Tabs */} +
+ + + {tabs.map((tab) => ( + + classNames( + 'px-4 py-3 text-sm font-medium transition border-b-2', + selected + ? 'border-sky-500 text-sky-400' + : 'border-transparent text-zinc-400 hover:text-zinc-300' + ) + } + > + {tab.name} + + ))} + + + + {/* Headers Tab */} + +
+
+ ⚠️ Note: WebSocket headers are only sent during the initial handshake and cannot be modified after connection. +
+ +
+
+ + {/* Protocols Tab */} + +
+
+ + { + const protocols = (e.target as HTMLInputElement).value + .split(',') + .map(p => p.trim()) + .filter(p => p.length > 0) + setRequestConfig(prev => ({ ...prev, protocols })) + }} + placeholder="chat, v1.0, graphql-ws" + className="w-full rounded-lg bg-zinc-800 border border-zinc-600 px-3 py-2 text-sm text-white placeholder-zinc-400 focus:border-sky-500 focus:outline-none" + disabled={connection.status === 'connected'} + /> +

+ Comma-separated list of WebSocket subprotocols +

+
+
+
+ + {/* Messages Tab */} + +
+ {/* Message Input */} +
+
+ + setMessageType('text') }, + { label: 'Binary', onClick: () => setMessageType('binary') } + ]} + className="w-24" + /> +
+
+