@clidey/dory 0.9.0 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/settings.json +29 -0
- package/CLAUDE.md +161 -0
- package/docs/api-reference/asyncapi.json +324 -0
- package/docs/api-reference/websocket-operations/receive-chat-message.mdx +5 -0
- package/docs/api-reference/websocket-operations/receive-notifications.mdx +5 -0
- package/docs/api-reference/websocket-operations/receive-presence-update.mdx +5 -0
- package/docs/api-reference/websocket-operations/send-chat-message.mdx +5 -0
- package/docs/api-reference/websocket-operations/send-presence-update.mdx +5 -0
- package/docs/dory.json +16 -0
- package/docs/websocket-playground-demo.mdx +135 -0
- package/k8s/Dockerfile +3 -0
- package/package.json +1 -1
- package/src/components/store.ts +4 -0
- package/src/mdx/async-api.tsx +369 -0
- package/src/mdx/mdx.tsx +5 -1
- package/src/mdx/websocket-playground.tsx +755 -0
- package/src/ui/layout.tsx +21 -3
|
@@ -0,0 +1,755 @@
|
|
|
1
|
+
import { Input, Tab, TabGroup, TabList, TabPanel, TabPanels } from '@headlessui/react'
|
|
2
|
+
import { CheckIcon } from '@heroicons/react/24/outline'
|
|
3
|
+
import classNames from 'classnames'
|
|
4
|
+
import { PencilIcon, TrashIcon, XIcon } from 'lucide-react'
|
|
5
|
+
import { useCallback, useEffect, useRef, useState } from 'preact/hooks'
|
|
6
|
+
import Dropdown from '../components/dropdown'
|
|
7
|
+
import { Tag } from './tag'
|
|
8
|
+
import { PlayButton } from './api-playground'
|
|
9
|
+
|
|
10
|
+
export interface WebSocketServer {
|
|
11
|
+
url: string;
|
|
12
|
+
description?: string;
|
|
13
|
+
protocol?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface WebSocketPlaygroundProps {
|
|
17
|
+
url: string
|
|
18
|
+
title?: string
|
|
19
|
+
description?: string
|
|
20
|
+
subprotocols?: string[]
|
|
21
|
+
headers?: Record<string, string>
|
|
22
|
+
servers?: WebSocketServer[]
|
|
23
|
+
authType?: 'bearer' | 'apiKey' | 'basic' | 'none'
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface WebSocketConfig {
|
|
27
|
+
url: string
|
|
28
|
+
headers: Record<string, string>
|
|
29
|
+
subprotocols: string[]
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface WebSocketMessage {
|
|
33
|
+
id: string
|
|
34
|
+
type: 'sent' | 'received' | 'error' | 'system'
|
|
35
|
+
data: string
|
|
36
|
+
timestamp: number
|
|
37
|
+
binary?: boolean
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function DisconnectButton(props: React.ComponentPropsWithoutRef<'svg'>) {
|
|
41
|
+
return (
|
|
42
|
+
<svg viewBox="0 0 20 20" aria-hidden="true" {...props}>
|
|
43
|
+
<path
|
|
44
|
+
fill="currentColor"
|
|
45
|
+
d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z"
|
|
46
|
+
/>
|
|
47
|
+
</svg>
|
|
48
|
+
)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function LoadingSpinner(props: React.ComponentPropsWithoutRef<'svg'>) {
|
|
52
|
+
return (
|
|
53
|
+
<svg viewBox="0 0 20 20" aria-hidden="true" className="animate-spin" {...props}>
|
|
54
|
+
<path
|
|
55
|
+
fill="currentColor"
|
|
56
|
+
d="M10 2a8 8 0 0 1 8 8c0 .3-.02.59-.06.88l-1.99-.22A6 6 0 1 0 4 10c0-.34.03-.67.09-1L2.1 8.78A8 8 0 0 1 10 2Z"
|
|
57
|
+
/>
|
|
58
|
+
</svg>
|
|
59
|
+
)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
interface WebSocketConfig {
|
|
63
|
+
headers: Record<string, string>
|
|
64
|
+
subprotocols: string[]
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
interface HeadersTabProps {
|
|
68
|
+
wsConfig: WebSocketConfig
|
|
69
|
+
setWsConfig: React.Dispatch<React.SetStateAction<WebSocketConfig>>
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
interface HeaderItem {
|
|
73
|
+
key: string
|
|
74
|
+
value: string
|
|
75
|
+
isEditing: boolean
|
|
76
|
+
isNew?: boolean
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export const HeadersTab = ({ wsConfig, setWsConfig }: HeadersTabProps) => {
|
|
80
|
+
const [headersList, setHeadersList] = useState<HeaderItem[]>([])
|
|
81
|
+
const hasInitialized = useRef(false)
|
|
82
|
+
|
|
83
|
+
useEffect(() => {
|
|
84
|
+
if (!hasInitialized.current) {
|
|
85
|
+
const initialHeaders = Object.entries(wsConfig.headers ?? {}).map(([key, value]) => ({
|
|
86
|
+
key,
|
|
87
|
+
value,
|
|
88
|
+
isEditing: false
|
|
89
|
+
}))
|
|
90
|
+
setHeadersList(initialHeaders)
|
|
91
|
+
hasInitialized.current = true
|
|
92
|
+
}
|
|
93
|
+
}, [wsConfig.headers])
|
|
94
|
+
|
|
95
|
+
useEffect(() => {
|
|
96
|
+
const newHeaders: Record<string, string> = {}
|
|
97
|
+
headersList.forEach(h => {
|
|
98
|
+
if (h.key.trim()) newHeaders[h.key] = h.value
|
|
99
|
+
})
|
|
100
|
+
setWsConfig(prev => ({ ...prev, headers: newHeaders }))
|
|
101
|
+
}, [headersList, setWsConfig])
|
|
102
|
+
|
|
103
|
+
const updateHeader = (index: number, field: 'key' | 'value', value: string) => {
|
|
104
|
+
setHeadersList(list =>
|
|
105
|
+
list.map((h, i) => (i === index ? { ...h, [field]: value } : h))
|
|
106
|
+
)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const saveHeader = (index: number) => {
|
|
110
|
+
setHeadersList(list =>
|
|
111
|
+
list.map((h, i) => (i === index ? { ...h, isEditing: false, isNew: false } : h))
|
|
112
|
+
)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const cancelEdit = (index: number) => {
|
|
116
|
+
setHeadersList(list =>
|
|
117
|
+
list.reduce<HeaderItem[]>((acc, h, i) => {
|
|
118
|
+
if (i === index) {
|
|
119
|
+
if (h.isNew) return acc // remove new row if cancelled
|
|
120
|
+
return [...acc, { ...h, isEditing: false }]
|
|
121
|
+
}
|
|
122
|
+
return [...acc, h]
|
|
123
|
+
}, [])
|
|
124
|
+
)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const editHeader = (index: number) => {
|
|
128
|
+
setHeadersList(list =>
|
|
129
|
+
list.map((h, i) => (i === index ? { ...h, isEditing: true } : h))
|
|
130
|
+
)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const removeHeader = (index: number) => {
|
|
134
|
+
setHeadersList(list => list.filter((_, i) => i !== index))
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const addEmptyHeaderRow = () => {
|
|
138
|
+
setHeadersList(list => [
|
|
139
|
+
...list,
|
|
140
|
+
{ key: '', value: '', isEditing: true, isNew: true }
|
|
141
|
+
])
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return (
|
|
145
|
+
<div className="flex flex-col gap-2 px-2 py-4">
|
|
146
|
+
<div className="flex items-center gap-2 px-2">
|
|
147
|
+
<span className="text-sm font-medium text-zinc-400 w-32">Key</span>
|
|
148
|
+
<span className="text-sm font-medium text-zinc-400 flex-1">Value</span>
|
|
149
|
+
</div>
|
|
150
|
+
{headersList.map((hdr, idx) => (
|
|
151
|
+
<div key={idx} className="flex items-center gap-2">
|
|
152
|
+
{hdr.isEditing ? (
|
|
153
|
+
<>
|
|
154
|
+
<Input
|
|
155
|
+
type="text"
|
|
156
|
+
value={hdr.key}
|
|
157
|
+
onChange={e => updateHeader(idx, 'key', (e.target as HTMLInputElement).value)}
|
|
158
|
+
placeholder="Header name"
|
|
159
|
+
className="w-32 rounded-lg bg-zinc-800 border border-zinc-600 px-2 py-1 text-sm text-white"
|
|
160
|
+
/>
|
|
161
|
+
<Input
|
|
162
|
+
type="text"
|
|
163
|
+
value={hdr.value}
|
|
164
|
+
onChange={e => updateHeader(idx, 'value', (e.target as HTMLInputElement).value)}
|
|
165
|
+
placeholder="Header value"
|
|
166
|
+
className="flex-1 rounded-lg bg-zinc-800 border border-zinc-600 px-2 py-1 text-sm text-white"
|
|
167
|
+
/>
|
|
168
|
+
<button onClick={() => saveHeader(idx)} className="text-white">
|
|
169
|
+
<CheckIcon className="w-4 h-4" />
|
|
170
|
+
</button>
|
|
171
|
+
<button onClick={() => cancelEdit(idx)} className="text-white">
|
|
172
|
+
<XIcon className="w-4 h-4" />
|
|
173
|
+
</button>
|
|
174
|
+
</>
|
|
175
|
+
) : (
|
|
176
|
+
<>
|
|
177
|
+
<span className="text-sm text-white w-32 truncate">{hdr.key}</span>
|
|
178
|
+
<span className="text-sm text-white flex-1 truncate">{hdr.value}</span>
|
|
179
|
+
<button onClick={() => editHeader(idx)} className="text-white">
|
|
180
|
+
<PencilIcon className="w-4 h-4" />
|
|
181
|
+
</button>
|
|
182
|
+
<button onClick={() => removeHeader(idx)} className="text-red-400">
|
|
183
|
+
<TrashIcon className="w-4 h-4" />
|
|
184
|
+
</button>
|
|
185
|
+
</>
|
|
186
|
+
)}
|
|
187
|
+
</div>
|
|
188
|
+
))}
|
|
189
|
+
<button onClick={addEmptyHeaderRow} className="text-sm text-sky-400 hover:text-sky-300 mt-2 w-fit">
|
|
190
|
+
+ Add Header
|
|
191
|
+
</button>
|
|
192
|
+
</div>
|
|
193
|
+
)
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
interface SubprotocolItem {
|
|
197
|
+
value: string
|
|
198
|
+
isEditing: boolean
|
|
199
|
+
isNew?: boolean
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
interface SubprotocolsTabProps {
|
|
203
|
+
subprotocols: string[]
|
|
204
|
+
setWsConfig: React.Dispatch<React.SetStateAction<WebSocketConfig>>
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export const SubprotocolsTab = ({ subprotocols, setWsConfig }: SubprotocolsTabProps) => {
|
|
208
|
+
const [subprotocolsList, setSubprotocolsList] = useState<SubprotocolItem[]>([])
|
|
209
|
+
const hasInitialized = useRef(false)
|
|
210
|
+
|
|
211
|
+
useEffect(() => {
|
|
212
|
+
if (!hasInitialized.current) {
|
|
213
|
+
const initial = subprotocols.map(value => ({
|
|
214
|
+
value,
|
|
215
|
+
isEditing: false
|
|
216
|
+
}))
|
|
217
|
+
setSubprotocolsList(initial)
|
|
218
|
+
hasInitialized.current = true
|
|
219
|
+
}
|
|
220
|
+
}, [subprotocols])
|
|
221
|
+
|
|
222
|
+
useEffect(() => {
|
|
223
|
+
const newSubprotocols = subprotocolsList
|
|
224
|
+
.filter(s => s.value.trim())
|
|
225
|
+
.map(s => s.value)
|
|
226
|
+
setWsConfig(prev => ({ ...prev, subprotocols: newSubprotocols }))
|
|
227
|
+
}, [subprotocolsList, setWsConfig])
|
|
228
|
+
|
|
229
|
+
const updateSubprotocol = (idx: number, val: string) =>
|
|
230
|
+
setSubprotocolsList(list => list.map((s, i) =>
|
|
231
|
+
i === idx ? { ...s, value: val } : s
|
|
232
|
+
))
|
|
233
|
+
|
|
234
|
+
const saveSubprotocol = (idx: number) =>
|
|
235
|
+
setSubprotocolsList(list => list.map((s, i) =>
|
|
236
|
+
i === idx ? { ...s, isEditing: false, isNew: false } : s
|
|
237
|
+
))
|
|
238
|
+
|
|
239
|
+
const cancelEdit = (idx: number) =>
|
|
240
|
+
setSubprotocolsList(list =>
|
|
241
|
+
list.reduce<SubprotocolItem[]>((acc, s, i) => {
|
|
242
|
+
if (i === idx) {
|
|
243
|
+
if (s.isNew) return acc
|
|
244
|
+
return [...acc, { ...s, isEditing: false }]
|
|
245
|
+
}
|
|
246
|
+
return [...acc, s]
|
|
247
|
+
}, [])
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
const editSubprotocol = (idx: number) =>
|
|
251
|
+
setSubprotocolsList(list =>
|
|
252
|
+
list.map((s, i) => (i === idx ? { ...s, isEditing: true } : s))
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
const removeSubprotocol = (idx: number) =>
|
|
256
|
+
setSubprotocolsList(list => list.filter((_, i) => i !== idx))
|
|
257
|
+
|
|
258
|
+
const addSubprotocolRow = () =>
|
|
259
|
+
setSubprotocolsList(list => [
|
|
260
|
+
...list, { value: '', isEditing: true, isNew: true }
|
|
261
|
+
])
|
|
262
|
+
|
|
263
|
+
return (
|
|
264
|
+
<div className="flex flex-col gap-2 px-2 py-4">
|
|
265
|
+
<div className="flex items-center gap-2">
|
|
266
|
+
<span className="text-sm font-medium text-zinc-400 flex-1">Subprotocol</span>
|
|
267
|
+
</div>
|
|
268
|
+
{subprotocolsList.map((s, i) => (
|
|
269
|
+
<div key={i} className="flex items-center gap-2">
|
|
270
|
+
{s.isEditing ? (
|
|
271
|
+
<>
|
|
272
|
+
<Input
|
|
273
|
+
value={s.value}
|
|
274
|
+
onChange={e => updateSubprotocol(i, (e.target as HTMLInputElement).value)}
|
|
275
|
+
placeholder="Subprotocol name"
|
|
276
|
+
className="flex-1 rounded-lg bg-zinc-800 border border-zinc-600 px-2 py-1 text-sm text-white"
|
|
277
|
+
/>
|
|
278
|
+
<button onClick={() => saveSubprotocol(i)} className="text-white">
|
|
279
|
+
<CheckIcon className="w-4 h-4" />
|
|
280
|
+
</button>
|
|
281
|
+
<button onClick={() => cancelEdit(i)} className="text-white">
|
|
282
|
+
<XIcon className="w-4 h-4" />
|
|
283
|
+
</button>
|
|
284
|
+
</>
|
|
285
|
+
) : (
|
|
286
|
+
<>
|
|
287
|
+
<span className="flex-1 truncate text-sm text-white">{s.value}</span>
|
|
288
|
+
<button onClick={() => editSubprotocol(i)} className="text-white">
|
|
289
|
+
<PencilIcon className="w-4 h-4" />
|
|
290
|
+
</button>
|
|
291
|
+
<button onClick={() => removeSubprotocol(i)} className="text-red-400">
|
|
292
|
+
<TrashIcon className="w-4 h-4" />
|
|
293
|
+
</button>
|
|
294
|
+
</>
|
|
295
|
+
)}
|
|
296
|
+
</div>
|
|
297
|
+
))}
|
|
298
|
+
<button onClick={addSubprotocolRow} className="mt-2 text-sm text-sky-400 hover:text-sky-300 self-start">
|
|
299
|
+
+ Add Subprotocol
|
|
300
|
+
</button>
|
|
301
|
+
</div>
|
|
302
|
+
)
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
type ConnectionState = 'disconnected' | 'connecting' | 'connected' | 'error'
|
|
306
|
+
|
|
307
|
+
export function WebSocketPlayground({
|
|
308
|
+
url,
|
|
309
|
+
title,
|
|
310
|
+
description,
|
|
311
|
+
subprotocols: defaultSubprotocols = [],
|
|
312
|
+
headers: defaultHeaders = {},
|
|
313
|
+
servers = [],
|
|
314
|
+
authType = 'none'
|
|
315
|
+
}: WebSocketPlaygroundProps) {
|
|
316
|
+
const [selectedTab, setSelectedTab] = useState(0)
|
|
317
|
+
const [connectionState, setConnectionState] = useState<ConnectionState>('disconnected')
|
|
318
|
+
const [messages, setMessages] = useState<WebSocketMessage[]>([])
|
|
319
|
+
const [messageInput, setMessageInput] = useState('')
|
|
320
|
+
const [error, setError] = useState<string | null>(null)
|
|
321
|
+
const [selectedServer, setSelectedServer] = useState<WebSocketServer | null>(servers?.[0] || null)
|
|
322
|
+
const wsRef = useRef<WebSocket | null>(null)
|
|
323
|
+
const messageIdRef = useRef(0)
|
|
324
|
+
|
|
325
|
+
// WebSocket configuration state
|
|
326
|
+
const [wsConfig, setWsConfig] = useState<WebSocketConfig>({
|
|
327
|
+
url: (selectedServer?.url || '') + url,
|
|
328
|
+
headers: { ...defaultHeaders },
|
|
329
|
+
subprotocols: [...defaultSubprotocols]
|
|
330
|
+
})
|
|
331
|
+
|
|
332
|
+
// Auth state
|
|
333
|
+
const [auth, setAuth] = useState({
|
|
334
|
+
type: authType,
|
|
335
|
+
token: '',
|
|
336
|
+
apiKey: '',
|
|
337
|
+
username: '',
|
|
338
|
+
password: ''
|
|
339
|
+
})
|
|
340
|
+
|
|
341
|
+
// Update URL when server changes
|
|
342
|
+
useEffect(() => {
|
|
343
|
+
if (selectedServer) {
|
|
344
|
+
setWsConfig(prev => ({ ...prev, url: selectedServer.url + url }))
|
|
345
|
+
}
|
|
346
|
+
}, [selectedServer, url])
|
|
347
|
+
|
|
348
|
+
const addMessage = useCallback((type: WebSocketMessage['type'], data: string, binary = false) => {
|
|
349
|
+
const message: WebSocketMessage = {
|
|
350
|
+
id: (++messageIdRef.current).toString(),
|
|
351
|
+
type,
|
|
352
|
+
data,
|
|
353
|
+
timestamp: Date.now(),
|
|
354
|
+
binary
|
|
355
|
+
}
|
|
356
|
+
setMessages(prev => [...prev, message])
|
|
357
|
+
}, [])
|
|
358
|
+
|
|
359
|
+
const connectWebSocket = useCallback(() => {
|
|
360
|
+
if (wsRef.current) {
|
|
361
|
+
wsRef.current.close()
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
setConnectionState('connecting')
|
|
365
|
+
setError(null)
|
|
366
|
+
|
|
367
|
+
try {
|
|
368
|
+
// Prepare headers based on auth type
|
|
369
|
+
const headers = { ...wsConfig.headers }
|
|
370
|
+
|
|
371
|
+
switch (auth.type) {
|
|
372
|
+
case 'bearer':
|
|
373
|
+
if (auth.token) {
|
|
374
|
+
headers['Authorization'] = `Bearer ${auth.token}`
|
|
375
|
+
}
|
|
376
|
+
break
|
|
377
|
+
case 'apiKey':
|
|
378
|
+
if (auth.apiKey) {
|
|
379
|
+
headers['X-API-Key'] = auth.apiKey
|
|
380
|
+
}
|
|
381
|
+
break
|
|
382
|
+
case 'basic':
|
|
383
|
+
if (auth.username && auth.password) {
|
|
384
|
+
headers['Authorization'] = `Basic ${btoa(`${auth.username}:${auth.password}`)}`
|
|
385
|
+
}
|
|
386
|
+
break
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
const ws = new WebSocket(wsConfig.url, wsConfig.subprotocols)
|
|
390
|
+
|
|
391
|
+
ws.onopen = () => {
|
|
392
|
+
setConnectionState('connected')
|
|
393
|
+
addMessage('system', 'Connected to WebSocket server')
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
ws.onmessage = (event) => {
|
|
397
|
+
const isBinary = event.data instanceof ArrayBuffer || event.data instanceof Blob
|
|
398
|
+
const data = isBinary ?
|
|
399
|
+
`[Binary data: ${event.data instanceof Blob ? event.data.size : event.data.byteLength} bytes]` :
|
|
400
|
+
event.data
|
|
401
|
+
addMessage('received', data, isBinary)
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
ws.onerror = () => {
|
|
405
|
+
setConnectionState('error')
|
|
406
|
+
setError('WebSocket connection error')
|
|
407
|
+
addMessage('error', 'Connection error occurred')
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
ws.onclose = (event) => {
|
|
411
|
+
setConnectionState('disconnected')
|
|
412
|
+
const reason = event.reason || 'Connection closed'
|
|
413
|
+
addMessage('system', `Disconnected: ${reason} (Code: ${event.code})`)
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
wsRef.current = ws
|
|
417
|
+
} catch (err) {
|
|
418
|
+
setConnectionState('error')
|
|
419
|
+
setError(err instanceof Error ? err.message : 'Failed to connect')
|
|
420
|
+
}
|
|
421
|
+
}, [wsConfig, auth, addMessage])
|
|
422
|
+
|
|
423
|
+
const disconnectWebSocket = useCallback(() => {
|
|
424
|
+
if (wsRef.current) {
|
|
425
|
+
wsRef.current.close()
|
|
426
|
+
wsRef.current = null
|
|
427
|
+
}
|
|
428
|
+
}, [])
|
|
429
|
+
|
|
430
|
+
const sendMessage = useCallback(() => {
|
|
431
|
+
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN && messageInput.trim()) {
|
|
432
|
+
try {
|
|
433
|
+
wsRef.current.send(messageInput)
|
|
434
|
+
addMessage('sent', messageInput)
|
|
435
|
+
setMessageInput('')
|
|
436
|
+
} catch (err) {
|
|
437
|
+
addMessage('error', `Failed to send message: ${err}`)
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
}, [messageInput, addMessage])
|
|
441
|
+
|
|
442
|
+
const clearMessages = useCallback(() => {
|
|
443
|
+
setMessages([])
|
|
444
|
+
}, [])
|
|
445
|
+
|
|
446
|
+
const tabs = [
|
|
447
|
+
{ name: 'Headers', key: 'headers' },
|
|
448
|
+
{ name: 'Subprotocols', key: 'subprotocols' },
|
|
449
|
+
{ name: 'Auth', key: 'auth' }
|
|
450
|
+
]
|
|
451
|
+
|
|
452
|
+
const getConnectionStatusColor = () => {
|
|
453
|
+
switch (connectionState) {
|
|
454
|
+
case 'connected':
|
|
455
|
+
return 'text-green-400'
|
|
456
|
+
case 'connecting':
|
|
457
|
+
return 'text-yellow-400'
|
|
458
|
+
case 'error':
|
|
459
|
+
return 'text-red-400'
|
|
460
|
+
default:
|
|
461
|
+
return 'text-zinc-400'
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
const getConnectionStatusText = () => {
|
|
466
|
+
switch (connectionState) {
|
|
467
|
+
case 'connected':
|
|
468
|
+
return 'Connected'
|
|
469
|
+
case 'connecting':
|
|
470
|
+
return 'Connecting...'
|
|
471
|
+
case 'error':
|
|
472
|
+
return 'Error'
|
|
473
|
+
default:
|
|
474
|
+
return 'Disconnected'
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
return (
|
|
479
|
+
<div className="my-6 overflow-hidden rounded-2xl">
|
|
480
|
+
{/* Header */}
|
|
481
|
+
<div className="flex min-h-[calc(--spacing(12)+1px)] flex-wrap items-start gap-x-4 border-b border-zinc-700 bg-zinc-800 px-4 dark:border-zinc-800 dark:bg-transparent pb-4">
|
|
482
|
+
<div className="flex items-center gap-3 pt-3">
|
|
483
|
+
<Tag variant="small">WS</Tag>
|
|
484
|
+
<span className="font-mono text-sm text-white">{title || url}</span>
|
|
485
|
+
<div className="flex items-center gap-2">
|
|
486
|
+
<div className={`w-2 h-2 rounded-full ${connectionState === 'connected' ? 'bg-green-400' : connectionState === 'connecting' ? 'bg-yellow-400' : connectionState === 'error' ? 'bg-red-400' : 'bg-zinc-400'}`} />
|
|
487
|
+
<span className={`text-xs ${getConnectionStatusColor()}`}>
|
|
488
|
+
{getConnectionStatusText()}
|
|
489
|
+
</span>
|
|
490
|
+
</div>
|
|
491
|
+
</div>
|
|
492
|
+
<div className="flex gap-2 ml-auto pt-3">
|
|
493
|
+
<button
|
|
494
|
+
onClick={connectWebSocket}
|
|
495
|
+
disabled={connectionState === 'connecting' || connectionState === 'connected'}
|
|
496
|
+
className={classNames(
|
|
497
|
+
'flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium transition',
|
|
498
|
+
connectionState === 'connecting' || connectionState === 'connected'
|
|
499
|
+
? 'bg-zinc-600 text-zinc-400 cursor-not-allowed'
|
|
500
|
+
: 'bg-green-600 text-white hover:bg-green-700'
|
|
501
|
+
)}
|
|
502
|
+
>
|
|
503
|
+
{connectionState === 'connecting' ? (
|
|
504
|
+
<LoadingSpinner className="h-4 w-4" />
|
|
505
|
+
) : (
|
|
506
|
+
<PlayButton className="h-4 w-4" />
|
|
507
|
+
)}
|
|
508
|
+
{connectionState === 'connecting' ? 'Connecting...' : 'Connect'}
|
|
509
|
+
</button>
|
|
510
|
+
<button
|
|
511
|
+
onClick={disconnectWebSocket}
|
|
512
|
+
disabled={connectionState === 'disconnected'}
|
|
513
|
+
className={classNames(
|
|
514
|
+
'flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium transition',
|
|
515
|
+
connectionState === 'disconnected'
|
|
516
|
+
? 'bg-zinc-600 text-zinc-400 cursor-not-allowed'
|
|
517
|
+
: 'bg-red-600 text-white hover:bg-red-700'
|
|
518
|
+
)}
|
|
519
|
+
>
|
|
520
|
+
<DisconnectButton className="h-4 w-4" />
|
|
521
|
+
Disconnect
|
|
522
|
+
</button>
|
|
523
|
+
</div>
|
|
524
|
+
</div>
|
|
525
|
+
|
|
526
|
+
{description && (
|
|
527
|
+
<div className="px-4 py-3 border-b border-zinc-700 dark:border-zinc-800">
|
|
528
|
+
<p className="text-sm text-zinc-300">{description}</p>
|
|
529
|
+
</div>
|
|
530
|
+
)}
|
|
531
|
+
|
|
532
|
+
<div className="flex flex-col lg:flex-row gap-4">
|
|
533
|
+
{/* Configuration */}
|
|
534
|
+
<div className="flex-1 min-w-0">
|
|
535
|
+
<div className="flex flex-col gap-2 p-4">
|
|
536
|
+
<label className="text-sm font-medium text-white">Server</label>
|
|
537
|
+
<Dropdown
|
|
538
|
+
buttonLabel={selectedServer?.description || selectedServer?.url || 'Select Server'}
|
|
539
|
+
items={servers.map(server => ({
|
|
540
|
+
label: server.description || server.url,
|
|
541
|
+
onClick: () => setSelectedServer(server)
|
|
542
|
+
}))}
|
|
543
|
+
className="w-full"
|
|
544
|
+
/>
|
|
545
|
+
</div>
|
|
546
|
+
|
|
547
|
+
<div className="border-t border-zinc-700 dark:border-zinc-800">
|
|
548
|
+
<TabGroup selectedIndex={selectedTab} onChange={setSelectedTab}>
|
|
549
|
+
<TabList className="flex border-b border-zinc-700 dark:border-zinc-800">
|
|
550
|
+
{tabs.map((tab) => (
|
|
551
|
+
<Tab
|
|
552
|
+
key={tab.key}
|
|
553
|
+
className={({ selected }) =>
|
|
554
|
+
classNames(
|
|
555
|
+
'px-4 py-3 text-sm font-medium transition border-b-2',
|
|
556
|
+
selected
|
|
557
|
+
? 'border-sky-500 text-sky-400'
|
|
558
|
+
: 'border-transparent text-zinc-400 hover:text-zinc-300'
|
|
559
|
+
)
|
|
560
|
+
}
|
|
561
|
+
>
|
|
562
|
+
{tab.name}
|
|
563
|
+
</Tab>
|
|
564
|
+
))}
|
|
565
|
+
</TabList>
|
|
566
|
+
|
|
567
|
+
<TabPanels className="min-h-[250px]">
|
|
568
|
+
<TabPanel className="p-4">
|
|
569
|
+
<HeadersTab
|
|
570
|
+
wsConfig={wsConfig}
|
|
571
|
+
setWsConfig={setWsConfig}
|
|
572
|
+
/>
|
|
573
|
+
</TabPanel>
|
|
574
|
+
|
|
575
|
+
<TabPanel className="p-4">
|
|
576
|
+
<SubprotocolsTab
|
|
577
|
+
subprotocols={wsConfig.subprotocols}
|
|
578
|
+
setWsConfig={setWsConfig}
|
|
579
|
+
/>
|
|
580
|
+
</TabPanel>
|
|
581
|
+
|
|
582
|
+
{/* Auth Tab */}
|
|
583
|
+
<TabPanel className="p-4 space-y-4">
|
|
584
|
+
<div className="flex flex-col space-y-2">
|
|
585
|
+
<label className="text-sm font-medium text-white">Authentication Type</label>
|
|
586
|
+
<Dropdown
|
|
587
|
+
buttonLabel={auth.type === 'none' ? 'Select Auth Type' : auth.type.charAt(0).toUpperCase() + auth.type.slice(1)}
|
|
588
|
+
items={[
|
|
589
|
+
{ label: 'None', onClick: () => setAuth(prev => ({ ...prev, type: 'none' })) },
|
|
590
|
+
{ label: 'Bearer Token', onClick: () => setAuth(prev => ({ ...prev, type: 'bearer' })) },
|
|
591
|
+
{ label: 'API Key', onClick: () => setAuth(prev => ({ ...prev, type: 'apiKey' })) },
|
|
592
|
+
{ label: 'Basic Auth', onClick: () => setAuth(prev => ({ ...prev, type: 'basic' })) }
|
|
593
|
+
]}
|
|
594
|
+
className="w-full"
|
|
595
|
+
/>
|
|
596
|
+
</div>
|
|
597
|
+
|
|
598
|
+
{auth.type === 'bearer' && (
|
|
599
|
+
<div className="space-y-2">
|
|
600
|
+
<label className="text-sm font-medium text-white">Bearer Token</label>
|
|
601
|
+
<input
|
|
602
|
+
type="password"
|
|
603
|
+
value={auth.token}
|
|
604
|
+
onChange={(e) => setAuth(prev => ({ ...prev, token: (e.target as HTMLInputElement).value }))}
|
|
605
|
+
placeholder="Enter bearer token..."
|
|
606
|
+
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"
|
|
607
|
+
/>
|
|
608
|
+
</div>
|
|
609
|
+
)}
|
|
610
|
+
|
|
611
|
+
{auth.type === 'apiKey' && (
|
|
612
|
+
<div className="space-y-2">
|
|
613
|
+
<label className="text-sm font-medium text-white">API Key</label>
|
|
614
|
+
<input
|
|
615
|
+
type="password"
|
|
616
|
+
value={auth.apiKey}
|
|
617
|
+
onChange={(e) => setAuth(prev => ({ ...prev, apiKey: (e.target as HTMLInputElement).value }))}
|
|
618
|
+
placeholder="Enter API key..."
|
|
619
|
+
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"
|
|
620
|
+
/>
|
|
621
|
+
</div>
|
|
622
|
+
)}
|
|
623
|
+
|
|
624
|
+
{auth.type === 'basic' && (
|
|
625
|
+
<div className="space-y-4">
|
|
626
|
+
<div className="space-y-2">
|
|
627
|
+
<label className="text-sm font-medium text-white">Username</label>
|
|
628
|
+
<input
|
|
629
|
+
type="text"
|
|
630
|
+
value={auth.username}
|
|
631
|
+
onChange={(e) => setAuth(prev => ({ ...prev, username: (e.target as HTMLInputElement).value }))}
|
|
632
|
+
placeholder="Enter username..."
|
|
633
|
+
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"
|
|
634
|
+
/>
|
|
635
|
+
</div>
|
|
636
|
+
<div className="space-y-2">
|
|
637
|
+
<label className="text-sm font-medium text-white">Password</label>
|
|
638
|
+
<input
|
|
639
|
+
type="password"
|
|
640
|
+
value={auth.password}
|
|
641
|
+
onChange={(e) => setAuth(prev => ({ ...prev, password: (e.target as HTMLInputElement).value }))}
|
|
642
|
+
placeholder="Enter password..."
|
|
643
|
+
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"
|
|
644
|
+
/>
|
|
645
|
+
</div>
|
|
646
|
+
</div>
|
|
647
|
+
)}
|
|
648
|
+
</TabPanel>
|
|
649
|
+
</TabPanels>
|
|
650
|
+
</TabGroup>
|
|
651
|
+
</div>
|
|
652
|
+
</div>
|
|
653
|
+
|
|
654
|
+
{/* Messages */}
|
|
655
|
+
<div className="flex-1 min-w-0">
|
|
656
|
+
<div className="border-t lg:border-t-0 lg:border-l border-zinc-700 dark:border-zinc-800">
|
|
657
|
+
<div className="flex items-center justify-between px-4 py-3 border-b border-zinc-700 dark:border-zinc-800">
|
|
658
|
+
<h3 className="text-sm font-semibold text-white">Messages</h3>
|
|
659
|
+
<button
|
|
660
|
+
onClick={clearMessages}
|
|
661
|
+
className="text-xs text-zinc-400 hover:text-zinc-300"
|
|
662
|
+
>
|
|
663
|
+
Clear
|
|
664
|
+
</button>
|
|
665
|
+
</div>
|
|
666
|
+
|
|
667
|
+
<div className="flex flex-col h-64 overflow-y-auto p-4 space-y-2">
|
|
668
|
+
{messages.length === 0 ? (
|
|
669
|
+
<div className="text-center py-8">
|
|
670
|
+
<div className="text-zinc-400 text-sm">
|
|
671
|
+
Connect to start receiving messages
|
|
672
|
+
</div>
|
|
673
|
+
</div>
|
|
674
|
+
) : (
|
|
675
|
+
messages.map((msg) => (
|
|
676
|
+
<div
|
|
677
|
+
key={msg.id}
|
|
678
|
+
className={classNames(
|
|
679
|
+
'flex flex-col gap-1 p-3 rounded-lg text-sm',
|
|
680
|
+
msg.type === 'sent' && 'bg-sky-500/10 border border-sky-500/20 ml-8',
|
|
681
|
+
msg.type === 'received' && 'bg-green-500/10 border border-green-500/20 mr-8',
|
|
682
|
+
msg.type === 'error' && 'bg-red-500/10 border border-red-500/20',
|
|
683
|
+
msg.type === 'system' && 'bg-zinc-500/10 border border-zinc-500/20'
|
|
684
|
+
)}
|
|
685
|
+
>
|
|
686
|
+
<div className="flex items-center justify-between">
|
|
687
|
+
<span className={classNames(
|
|
688
|
+
'text-xs font-medium',
|
|
689
|
+
msg.type === 'sent' && 'text-sky-400',
|
|
690
|
+
msg.type === 'received' && 'text-green-400',
|
|
691
|
+
msg.type === 'error' && 'text-red-400',
|
|
692
|
+
msg.type === 'system' && 'text-zinc-400'
|
|
693
|
+
)}>
|
|
694
|
+
{msg.type === 'sent' ? 'Sent' :
|
|
695
|
+
msg.type === 'received' ? 'Received' :
|
|
696
|
+
msg.type === 'error' ? 'Error' : 'System'}
|
|
697
|
+
{msg.binary && ' (Binary)'}
|
|
698
|
+
</span>
|
|
699
|
+
<span className="text-xs text-zinc-500">
|
|
700
|
+
{new Date(msg.timestamp).toLocaleTimeString()}
|
|
701
|
+
</span>
|
|
702
|
+
</div>
|
|
703
|
+
<div className="text-white break-words font-mono text-xs">
|
|
704
|
+
{msg.data}
|
|
705
|
+
</div>
|
|
706
|
+
</div>
|
|
707
|
+
))
|
|
708
|
+
)}
|
|
709
|
+
</div>
|
|
710
|
+
|
|
711
|
+
{/* Send Message */}
|
|
712
|
+
<div className="border-t border-zinc-700 dark:border-zinc-800 p-4">
|
|
713
|
+
<div className="flex gap-2">
|
|
714
|
+
<input
|
|
715
|
+
type="text"
|
|
716
|
+
value={messageInput}
|
|
717
|
+
onChange={(e) => setMessageInput((e.target as HTMLInputElement).value)}
|
|
718
|
+
onKeyPress={(e) => e.key === 'Enter' && sendMessage()}
|
|
719
|
+
placeholder="Enter message to send..."
|
|
720
|
+
disabled={connectionState !== 'connected'}
|
|
721
|
+
className="flex-1 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:bg-zinc-900 disabled:text-zinc-500"
|
|
722
|
+
/>
|
|
723
|
+
<button
|
|
724
|
+
onClick={sendMessage}
|
|
725
|
+
disabled={connectionState !== 'connected' || !messageInput.trim()}
|
|
726
|
+
className={classNames(
|
|
727
|
+
'px-4 py-2 rounded-lg text-sm font-medium transition',
|
|
728
|
+
connectionState === 'connected' && messageInput.trim()
|
|
729
|
+
? 'bg-sky-600 text-white hover:bg-sky-700'
|
|
730
|
+
: 'bg-zinc-600 text-zinc-400 cursor-not-allowed'
|
|
731
|
+
)}
|
|
732
|
+
>
|
|
733
|
+
Send
|
|
734
|
+
</button>
|
|
735
|
+
</div>
|
|
736
|
+
</div>
|
|
737
|
+
</div>
|
|
738
|
+
</div>
|
|
739
|
+
</div>
|
|
740
|
+
|
|
741
|
+
{error && (
|
|
742
|
+
<div className="border-t border-zinc-700 dark:border-zinc-800 p-4">
|
|
743
|
+
<div className="flex items-center gap-2">
|
|
744
|
+
<span className="inline-flex items-center rounded-full bg-rose-500/10 px-2 py-1 text-xs font-medium text-rose-400 ring-1 ring-rose-500/20">
|
|
745
|
+
Error
|
|
746
|
+
</span>
|
|
747
|
+
</div>
|
|
748
|
+
<pre className="text-sm text-rose-400 bg-rose-500/5 p-3 rounded-lg border border-rose-500/20 mt-2">
|
|
749
|
+
{error}
|
|
750
|
+
</pre>
|
|
751
|
+
</div>
|
|
752
|
+
)}
|
|
753
|
+
</div>
|
|
754
|
+
)
|
|
755
|
+
}
|