@clidey/dory 1.0.5 → 1.0.7
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/bin/dist/dory.js +36 -2
- package/bin/dist/dory.js.map +1 -1
- package/index.html +0 -1
- package/package.json +16 -22
- package/src/components/hooks.tsx +2 -1
- package/src/components/store.ts +5 -4
- package/src/config/mdx.js +8 -3
- package/src/config/mdx.ts +8 -3
- package/src/embed/widget-iframe.ts +10 -3
- package/src/entry-server.tsx +11 -7
- package/src/main.tsx +5 -1
- package/src/mdx/accordion.tsx +11 -8
- package/src/mdx/api.tsx +2 -352
- package/src/mdx/async-api.tsx +124 -295
- package/src/mdx/callout.tsx +22 -24
- package/src/mdx/card.tsx +20 -22
- package/src/mdx/chip.tsx +14 -0
- package/src/mdx/code.tsx +133 -110
- package/src/mdx/expandable.tsx +8 -8
- package/src/mdx/fence-meta.test.ts +39 -0
- package/src/mdx/fence-meta.ts +61 -0
- package/src/mdx/fence.tsx +155 -44
- package/src/mdx/icon-grid.tsx +29 -0
- package/src/mdx/icon.tsx +2 -2
- package/src/mdx/latex.tsx +14 -7
- package/src/mdx/list.tsx +3 -3
- package/src/mdx/mdx.tsx +7 -1
- package/src/mdx/mermaid.tsx +81 -11
- package/src/mdx/open-api.tsx +396 -239
- package/src/mdx/source.tsx +148 -40
- package/src/mdx/specimen.tsx +69 -0
- package/src/mdx/step.tsx +22 -30
- package/src/mdx/table.tsx +4 -9
- package/src/mdx/tabs.tsx +39 -0
- package/src/mdx/tag.tsx +13 -21
- package/src/mdx/text.tsx +17 -9
- package/src/mdx/type-scale.tsx +28 -0
- package/src/mdx/websocket-playground.tsx +221 -704
- package/src/plugins/code-meta.ts +23 -0
- package/src/plugins/frontmatter-dev-server.ts +6 -2
- package/src/plugins/html-files-middleware.ts +9 -2
- package/src/plugins/html-metadata-injector.ts +4 -3
- package/src/plugins/llm-txt-dev-server.ts +5 -2
- package/src/plugins/llm-txt-generator.ts +2 -1
- package/src/plugins/prerender.ts +7 -4
- package/src/plugins/relative-assets.test.ts +26 -0
- package/src/plugins/relative-assets.ts +80 -0
- package/src/plugins/robots-generator.ts +6 -4
- package/src/plugins/sanitize.test.ts +5 -0
- package/src/plugins/sanitize.ts +2 -0
- package/src/plugins/shared.ts +17 -0
- package/src/plugins/sitemap-generator.ts +2 -1
- package/src/theme.scss +282 -3
- package/src/types/config.ts +3 -0
- package/src/ui/header.tsx +4 -3
- package/src/ui/layout.tsx +22 -4
- package/src/ui/page-meta.tsx +37 -0
- package/src/ui/prev-next-link.tsx +4 -3
- package/src/ui/table-of-content.tsx +11 -3
- package/src/utils/base-path.test.ts +46 -0
- package/src/utils/base-path.ts +41 -0
- package/vite.config.ssr.ts +3 -0
- package/vite.config.ts +3 -0
|
@@ -1,12 +1,7 @@
|
|
|
1
|
-
import { Tab, TabGroup, TabList, TabPanel, TabPanels } from '@headlessui/react'
|
|
2
|
-
import { CheckIcon } from '@heroicons/react/24/outline'
|
|
3
1
|
import classNames from 'classnames'
|
|
4
|
-
import {
|
|
2
|
+
import { ChevronsUpDown, X } from 'lucide-react'
|
|
5
3
|
import { useCallback, useEffect, useRef, useState } from 'preact/hooks'
|
|
6
|
-
import Dropdown from '../components/dropdown'
|
|
7
4
|
import { Tag } from './tag'
|
|
8
|
-
import { PlayButton } from './api-playground'
|
|
9
|
-
import { Badge, Button, Card, EmptyState, Sheet, SheetContent, SheetHeader, SheetTitle, Input } from '@clidey/ux'
|
|
10
5
|
|
|
11
6
|
export interface WebSocketServer {
|
|
12
7
|
url: string;
|
|
@@ -38,789 +33,311 @@ interface WebSocketMessage {
|
|
|
38
33
|
binary?: boolean
|
|
39
34
|
}
|
|
40
35
|
|
|
41
|
-
|
|
42
|
-
return (
|
|
43
|
-
<svg viewBox="0 0 20 20" aria-hidden="true" {...props}>
|
|
44
|
-
<path
|
|
45
|
-
fill="currentColor"
|
|
46
|
-
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"
|
|
47
|
-
/>
|
|
48
|
-
</svg>
|
|
49
|
-
)
|
|
50
|
-
}
|
|
36
|
+
type ConnectionState = 'disconnected' | 'connecting' | 'connected' | 'error'
|
|
51
37
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
<svg viewBox="0 0 20 20" aria-hidden="true" className="animate-spin" {...props}>
|
|
55
|
-
<path
|
|
56
|
-
fill="currentColor"
|
|
57
|
-
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"
|
|
58
|
-
/>
|
|
59
|
-
</svg>
|
|
60
|
-
)
|
|
61
|
-
}
|
|
38
|
+
const inputClass = 'min-w-0 flex-1 rounded-md border border-[var(--border)] bg-muted px-3 py-2 font-mono text-xs text-foreground placeholder:text-muted-foreground focus:border-[var(--primary)] focus:outline-none'
|
|
39
|
+
const labelClass = 'font-mono text-[11px] uppercase tracking-[0.16em] text-muted-foreground'
|
|
62
40
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
subprotocols: string[]
|
|
41
|
+
function MonoLabel({ children }: { children: string }) {
|
|
42
|
+
return <p className={labelClass}>{children}</p>
|
|
66
43
|
}
|
|
67
44
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
45
|
+
function RemoveButton({ onClick, label }: { onClick: () => void; label: string }) {
|
|
46
|
+
return (
|
|
47
|
+
<button type="button" onClick={onClick} aria-label={label} className="rounded-md p-1.5 text-muted-foreground transition hover:bg-muted hover:text-foreground">
|
|
48
|
+
<X className="h-3.5 w-3.5" />
|
|
49
|
+
</button>
|
|
50
|
+
)
|
|
71
51
|
}
|
|
72
52
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
53
|
+
function AddLink({ onClick, children }: { onClick: () => void; children: string }) {
|
|
54
|
+
return (
|
|
55
|
+
<button type="button" onClick={onClick} className="mt-1 w-fit text-sm font-medium text-[var(--primary)] hover:underline">
|
|
56
|
+
+ {children}
|
|
57
|
+
</button>
|
|
58
|
+
)
|
|
78
59
|
}
|
|
79
60
|
|
|
80
|
-
|
|
81
|
-
const [headersList, setHeadersList] = useState<HeaderItem[]>([])
|
|
82
|
-
const hasInitialized = useRef(false)
|
|
61
|
+
interface KeyValue { key: string; value: string }
|
|
83
62
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
key,
|
|
88
|
-
value,
|
|
89
|
-
isEditing: false
|
|
90
|
-
}))
|
|
91
|
-
setHeadersList(initialHeaders)
|
|
92
|
-
hasInitialized.current = true
|
|
93
|
-
}
|
|
94
|
-
}, [wsConfig.headers])
|
|
95
|
-
|
|
96
|
-
useEffect(() => {
|
|
97
|
-
const newHeaders: Record<string, string> = {}
|
|
98
|
-
headersList.forEach(h => {
|
|
99
|
-
if (h.key.trim()) newHeaders[h.key] = h.value
|
|
100
|
-
})
|
|
101
|
-
setWsConfig(prev => ({ ...prev, headers: newHeaders }))
|
|
102
|
-
}, [headersList, setWsConfig])
|
|
103
|
-
|
|
104
|
-
const updateHeader = (index: number, field: 'key' | 'value', value: string) => {
|
|
105
|
-
setHeadersList(list =>
|
|
106
|
-
list.map((h, i) => (i === index ? { ...h, [field]: value } : h))
|
|
107
|
-
)
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
const saveHeader = (index: number) => {
|
|
111
|
-
setHeadersList(list =>
|
|
112
|
-
list.map((h, i) => (i === index ? { ...h, isEditing: false, isNew: false } : h))
|
|
113
|
-
)
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
const cancelEdit = (index: number) => {
|
|
117
|
-
setHeadersList(list =>
|
|
118
|
-
list.reduce<HeaderItem[]>((acc, h, i) => {
|
|
119
|
-
if (i === index) {
|
|
120
|
-
if (h.isNew) return acc // remove new row if cancelled
|
|
121
|
-
return [...acc, { ...h, isEditing: false }]
|
|
122
|
-
}
|
|
123
|
-
return [...acc, h]
|
|
124
|
-
}, [])
|
|
125
|
-
)
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
const editHeader = (index: number) => {
|
|
129
|
-
setHeadersList(list =>
|
|
130
|
-
list.map((h, i) => (i === index ? { ...h, isEditing: true } : h))
|
|
131
|
-
)
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
const removeHeader = (index: number) => {
|
|
135
|
-
setHeadersList(list => list.filter((_, i) => i !== index))
|
|
136
|
-
}
|
|
63
|
+
/** Key/value rows for request headers. Changes are pushed into the connection config. */
|
|
64
|
+
export function HeadersTab({ headers, onChange }: { headers: Record<string, string>; onChange: (headers: Record<string, string>) => void }) {
|
|
65
|
+
const [rows, setRows] = useState<KeyValue[]>(() => Object.entries(headers).map(([key, value]) => ({ key, value })))
|
|
137
66
|
|
|
138
|
-
const
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
67
|
+
const update = (next: KeyValue[]) => {
|
|
68
|
+
setRows(next)
|
|
69
|
+
const out: Record<string, string> = {}
|
|
70
|
+
for (const row of next) if (row.key.trim()) out[row.key.trim()] = row.value
|
|
71
|
+
onChange(out)
|
|
143
72
|
}
|
|
144
73
|
|
|
145
74
|
return (
|
|
146
|
-
<div className="flex flex-col gap-2
|
|
147
|
-
|
|
148
|
-
<
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
{
|
|
154
|
-
<>
|
|
155
|
-
<Input
|
|
156
|
-
type="text"
|
|
157
|
-
value={hdr.key}
|
|
158
|
-
onChange={e => updateHeader(idx, 'key', (e.target as HTMLInputElement).value)}
|
|
159
|
-
placeholder="Header name"
|
|
160
|
-
className="w-32 text-sm"
|
|
161
|
-
/>
|
|
162
|
-
<Input
|
|
163
|
-
type="text"
|
|
164
|
-
value={hdr.value}
|
|
165
|
-
onChange={e => updateHeader(idx, 'value', (e.target as HTMLInputElement).value)}
|
|
166
|
-
placeholder="Header value"
|
|
167
|
-
className="flex-1 text-sm"
|
|
168
|
-
/>
|
|
169
|
-
<Button onClick={() => saveHeader(idx)} variant="ghost" size="icon" className="text-white h-8 w-8">
|
|
170
|
-
<CheckIcon className="w-4 h-4" />
|
|
171
|
-
</Button>
|
|
172
|
-
<Button onClick={() => cancelEdit(idx)} variant="ghost" size="icon" className="text-white h-8 w-8">
|
|
173
|
-
<XIcon className="w-4 h-4" />
|
|
174
|
-
</Button>
|
|
175
|
-
</>
|
|
176
|
-
) : (
|
|
177
|
-
<>
|
|
178
|
-
<span className="text-sm text-white w-32 truncate">{hdr.key}</span>
|
|
179
|
-
<span className="text-sm text-white flex-1 truncate">{hdr.value}</span>
|
|
180
|
-
<Button onClick={() => editHeader(idx)} variant="ghost" size="icon" className="text-white h-8 w-8">
|
|
181
|
-
<PencilIcon className="w-4 h-4" />
|
|
182
|
-
</Button>
|
|
183
|
-
<Button onClick={() => removeHeader(idx)} variant="ghost" size="icon" className="text-red-400 h-8 w-8">
|
|
184
|
-
<TrashIcon className="w-4 h-4" />
|
|
185
|
-
</Button>
|
|
186
|
-
</>
|
|
187
|
-
)}
|
|
75
|
+
<div className="flex flex-col gap-2">
|
|
76
|
+
{rows.map((row, index) => (
|
|
77
|
+
<div key={index} className="flex items-center gap-2">
|
|
78
|
+
<input className={inputClass} value={row.key} placeholder="Header" aria-label="Header name"
|
|
79
|
+
onInput={(e) => update(rows.map((r, i) => i === index ? { ...r, key: (e.target as HTMLInputElement).value } : r))} />
|
|
80
|
+
<input className={inputClass} value={row.value} placeholder="Value" aria-label="Header value"
|
|
81
|
+
onInput={(e) => update(rows.map((r, i) => i === index ? { ...r, value: (e.target as HTMLInputElement).value } : r))} />
|
|
82
|
+
<RemoveButton label="Remove header" onClick={() => update(rows.filter((_, i) => i !== index))} />
|
|
188
83
|
</div>
|
|
189
84
|
))}
|
|
190
|
-
<
|
|
191
|
-
<PlusIcon className="w-4 h-4" /> Add Header
|
|
192
|
-
</Button>
|
|
85
|
+
<AddLink onClick={() => update([...rows, { key: '', value: '' }])}>Add header</AddLink>
|
|
193
86
|
</div>
|
|
194
87
|
)
|
|
195
88
|
}
|
|
196
89
|
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
}
|
|
90
|
+
/** Editable list of subprotocols offered during the handshake. */
|
|
91
|
+
export function SubprotocolsTab({ subprotocols, onChange }: { subprotocols: string[]; onChange: (subprotocols: string[]) => void }) {
|
|
92
|
+
const [rows, setRows] = useState<string[]>(subprotocols)
|
|
93
|
+
const update = (next: string[]) => { setRows(next); onChange(next.map((s) => s.trim()).filter(Boolean)) }
|
|
202
94
|
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
95
|
+
return (
|
|
96
|
+
<div className="flex flex-col gap-2">
|
|
97
|
+
{rows.map((value, index) => (
|
|
98
|
+
<div key={index} className="flex items-center gap-2">
|
|
99
|
+
<input className={inputClass} value={value} placeholder="Subprotocol" aria-label="Subprotocol"
|
|
100
|
+
onInput={(e) => update(rows.map((r, i) => i === index ? (e.target as HTMLInputElement).value : r))} />
|
|
101
|
+
<RemoveButton label="Remove subprotocol" onClick={() => update(rows.filter((_, i) => i !== index))} />
|
|
102
|
+
</div>
|
|
103
|
+
))}
|
|
104
|
+
<AddLink onClick={() => update([...rows, ''])}>Add subprotocol</AddLink>
|
|
105
|
+
</div>
|
|
106
|
+
)
|
|
206
107
|
}
|
|
207
108
|
|
|
208
|
-
|
|
209
|
-
const [subprotocolsList, setSubprotocolsList] = useState<SubprotocolItem[]>([])
|
|
210
|
-
const hasInitialized = useRef(false)
|
|
211
|
-
|
|
212
|
-
useEffect(() => {
|
|
213
|
-
if (!hasInitialized.current) {
|
|
214
|
-
const initial = subprotocols.map(value => ({
|
|
215
|
-
value,
|
|
216
|
-
isEditing: false
|
|
217
|
-
}))
|
|
218
|
-
setSubprotocolsList(initial)
|
|
219
|
-
hasInitialized.current = true
|
|
220
|
-
}
|
|
221
|
-
}, [subprotocols])
|
|
109
|
+
interface AuthState { type: 'none' | 'bearer' | 'apiKey' | 'basic'; token: string; apiKey: string; username: string; password: string }
|
|
222
110
|
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
))
|
|
239
|
-
|
|
240
|
-
const cancelEdit = (idx: number) =>
|
|
241
|
-
setSubprotocolsList(list =>
|
|
242
|
-
list.reduce<SubprotocolItem[]>((acc, s, i) => {
|
|
243
|
-
if (i === idx) {
|
|
244
|
-
if (s.isNew) return acc
|
|
245
|
-
return [...acc, { ...s, isEditing: false }]
|
|
246
|
-
}
|
|
247
|
-
return [...acc, s]
|
|
248
|
-
}, [])
|
|
249
|
-
)
|
|
250
|
-
|
|
251
|
-
const editSubprotocol = (idx: number) =>
|
|
252
|
-
setSubprotocolsList(list =>
|
|
253
|
-
list.map((s, i) => (i === idx ? { ...s, isEditing: true } : s))
|
|
254
|
-
)
|
|
255
|
-
|
|
256
|
-
const removeSubprotocol = (idx: number) =>
|
|
257
|
-
setSubprotocolsList(list => list.filter((_, i) => i !== idx))
|
|
258
|
-
|
|
259
|
-
const addSubprotocolRow = () =>
|
|
260
|
-
setSubprotocolsList(list => [
|
|
261
|
-
...list, { value: '', isEditing: true, isNew: true }
|
|
262
|
-
])
|
|
111
|
+
function Select({ value, onChange, children, label }: { value: string; onChange: (value: string) => void; children: preact.ComponentChildren; label: string }) {
|
|
112
|
+
return (
|
|
113
|
+
<div className="relative">
|
|
114
|
+
<select
|
|
115
|
+
aria-label={label}
|
|
116
|
+
value={value}
|
|
117
|
+
onChange={(e) => onChange((e.target as HTMLSelectElement).value)}
|
|
118
|
+
className="w-full appearance-none rounded-md border border-[var(--border)] bg-card py-2 pl-3 pr-9 text-sm text-foreground focus:border-[var(--primary)] focus:outline-none"
|
|
119
|
+
>
|
|
120
|
+
{children}
|
|
121
|
+
</select>
|
|
122
|
+
<ChevronsUpDown aria-hidden="true" className="pointer-events-none absolute right-3 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
|
|
123
|
+
</div>
|
|
124
|
+
)
|
|
125
|
+
}
|
|
263
126
|
|
|
127
|
+
function AuthTab({ auth, setAuth }: { auth: AuthState; setAuth: (auth: AuthState) => void }) {
|
|
128
|
+
const field = (key: keyof AuthState, placeholder: string, type = 'text') => (
|
|
129
|
+
<input className={inputClass} type={type} value={auth[key] as string} placeholder={placeholder} aria-label={placeholder}
|
|
130
|
+
onInput={(e) => setAuth({ ...auth, [key]: (e.target as HTMLInputElement).value })} />
|
|
131
|
+
)
|
|
264
132
|
return (
|
|
265
|
-
<div className="flex flex-col gap-
|
|
266
|
-
<
|
|
267
|
-
<
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
<
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
onChange={e => updateSubprotocol(i, (e.target as HTMLInputElement).value)}
|
|
276
|
-
placeholder="Subprotocol name"
|
|
277
|
-
className="flex-1 text-sm"
|
|
278
|
-
/>
|
|
279
|
-
<Button onClick={() => saveSubprotocol(i)} variant="ghost" size="icon" className="text-white h-8 w-8">
|
|
280
|
-
<CheckIcon className="w-4 h-4" />
|
|
281
|
-
</Button>
|
|
282
|
-
<Button onClick={() => cancelEdit(i)} variant="ghost" size="icon" className="text-white h-8 w-8">
|
|
283
|
-
<XIcon className="w-4 h-4" />
|
|
284
|
-
</Button>
|
|
285
|
-
</>
|
|
286
|
-
) : (
|
|
287
|
-
<>
|
|
288
|
-
<span className="flex-1 truncate text-sm">{s.value}</span>
|
|
289
|
-
<Button onClick={() => editSubprotocol(i)} variant="ghost" size="icon" className="text-white h-8 w-8">
|
|
290
|
-
<PencilIcon className="w-4 h-4" />
|
|
291
|
-
</Button>
|
|
292
|
-
<Button onClick={() => removeSubprotocol(i)} variant="ghost" size="icon" className="text-red-400 h-8 w-8">
|
|
293
|
-
<TrashIcon className="w-4 h-4" />
|
|
294
|
-
</Button>
|
|
295
|
-
</>
|
|
296
|
-
)}
|
|
297
|
-
</div>
|
|
298
|
-
))}
|
|
299
|
-
<Button onClick={addSubprotocolRow} variant="ghost" size="sm" className="text-sky-400 hover:text-sky-300 mt-2 self-start">
|
|
300
|
-
+ Add Subprotocol
|
|
301
|
-
</Button>
|
|
133
|
+
<div className="flex flex-col gap-3">
|
|
134
|
+
<Select label="Authentication type" value={auth.type} onChange={(type) => setAuth({ ...auth, type: type as AuthState['type'] })}>
|
|
135
|
+
<option value="none">No authentication</option>
|
|
136
|
+
<option value="bearer">Bearer token</option>
|
|
137
|
+
<option value="apiKey">API key</option>
|
|
138
|
+
<option value="basic">Basic auth</option>
|
|
139
|
+
</Select>
|
|
140
|
+
{auth.type === 'bearer' && <div className="flex">{field('token', 'Bearer token', 'password')}</div>}
|
|
141
|
+
{auth.type === 'apiKey' && <div className="flex">{field('apiKey', 'API key', 'password')}</div>}
|
|
142
|
+
{auth.type === 'basic' && <div className="flex gap-2">{field('username', 'Username')}{field('password', 'Password', 'password')}</div>}
|
|
302
143
|
</div>
|
|
303
144
|
)
|
|
304
145
|
}
|
|
305
146
|
|
|
306
|
-
|
|
147
|
+
const statusDot: Record<ConnectionState, string> = {
|
|
148
|
+
connected: 'bg-green-600',
|
|
149
|
+
connecting: 'bg-[var(--highlight)]',
|
|
150
|
+
error: 'bg-[var(--destructive)]',
|
|
151
|
+
disconnected: 'bg-muted-foreground',
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const statusText: Record<ConnectionState, string> = {
|
|
155
|
+
connected: 'Connected',
|
|
156
|
+
connecting: 'Connecting…',
|
|
157
|
+
error: 'Error',
|
|
158
|
+
disconnected: 'Disconnected',
|
|
159
|
+
}
|
|
307
160
|
|
|
308
|
-
|
|
309
|
-
|
|
161
|
+
/**
|
|
162
|
+
* Inline WebSocket client: connection bar, server picker, Headers/Subprotocols/Auth
|
|
163
|
+
* tabs, message log and composer. Browsers cannot send custom headers on a
|
|
164
|
+
* WebSocket handshake, so headers and auth are kept for documentation and for
|
|
165
|
+
* servers that read them from the first message.
|
|
166
|
+
*/
|
|
167
|
+
export function WebSocketPanel({
|
|
310
168
|
url,
|
|
311
169
|
title,
|
|
312
|
-
description,
|
|
313
170
|
subprotocols: defaultSubprotocols = [],
|
|
314
171
|
headers: defaultHeaders = {},
|
|
315
172
|
servers = [],
|
|
316
|
-
authType = 'none'
|
|
173
|
+
authType = 'none',
|
|
317
174
|
}: WebSocketPlaygroundProps) {
|
|
318
|
-
const [
|
|
175
|
+
const [tab, setTab] = useState<'headers' | 'subprotocols' | 'auth'>('headers')
|
|
319
176
|
const [connectionState, setConnectionState] = useState<ConnectionState>('disconnected')
|
|
320
177
|
const [messages, setMessages] = useState<WebSocketMessage[]>([])
|
|
321
178
|
const [messageInput, setMessageInput] = useState('')
|
|
322
179
|
const [error, setError] = useState<string | null>(null)
|
|
323
|
-
const [
|
|
180
|
+
const [serverIndex, setServerIndex] = useState(0)
|
|
324
181
|
const wsRef = useRef<WebSocket | null>(null)
|
|
325
182
|
const messageIdRef = useRef(0)
|
|
326
|
-
|
|
327
|
-
// WebSocket configuration state
|
|
328
183
|
const [wsConfig, setWsConfig] = useState<WebSocketConfig>({
|
|
329
|
-
url: (
|
|
184
|
+
url: (servers[0]?.url ?? '') + url,
|
|
330
185
|
headers: { ...defaultHeaders },
|
|
331
|
-
subprotocols: [...defaultSubprotocols]
|
|
186
|
+
subprotocols: [...defaultSubprotocols],
|
|
332
187
|
})
|
|
188
|
+
const [auth, setAuth] = useState<AuthState>({ type: authType, token: '', apiKey: '', username: '', password: '' })
|
|
333
189
|
|
|
334
|
-
// Auth state
|
|
335
|
-
const [auth, setAuth] = useState({
|
|
336
|
-
type: authType,
|
|
337
|
-
token: '',
|
|
338
|
-
apiKey: '',
|
|
339
|
-
username: '',
|
|
340
|
-
password: ''
|
|
341
|
-
})
|
|
342
|
-
|
|
343
|
-
// Update URL when server changes
|
|
344
190
|
useEffect(() => {
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
191
|
+
const server = servers[serverIndex]
|
|
192
|
+
if (server) setWsConfig((prev) => ({ ...prev, url: server.url + url }))
|
|
193
|
+
}, [serverIndex, url])
|
|
194
|
+
|
|
195
|
+
useEffect(() => () => wsRef.current?.close(), [])
|
|
349
196
|
|
|
350
197
|
const addMessage = useCallback((type: WebSocketMessage['type'], data: string, binary = false) => {
|
|
351
|
-
|
|
352
|
-
id: (++messageIdRef.current).toString(),
|
|
353
|
-
type,
|
|
354
|
-
data,
|
|
355
|
-
timestamp: Date.now(),
|
|
356
|
-
binary
|
|
357
|
-
}
|
|
358
|
-
setMessages(prev => [...prev, message])
|
|
198
|
+
setMessages((prev) => [...prev, { id: String(++messageIdRef.current), type, data, timestamp: Date.now(), binary }])
|
|
359
199
|
}, [])
|
|
360
200
|
|
|
361
|
-
const
|
|
362
|
-
|
|
363
|
-
wsRef.current.close()
|
|
364
|
-
}
|
|
365
|
-
|
|
201
|
+
const connect = useCallback(() => {
|
|
202
|
+
wsRef.current?.close()
|
|
366
203
|
setConnectionState('connecting')
|
|
367
204
|
setError(null)
|
|
368
|
-
|
|
369
205
|
try {
|
|
370
|
-
// Prepare headers based on auth type
|
|
371
|
-
const headers = { ...wsConfig.headers }
|
|
372
|
-
|
|
373
|
-
switch (auth.type) {
|
|
374
|
-
case 'bearer':
|
|
375
|
-
if (auth.token) {
|
|
376
|
-
headers['Authorization'] = `Bearer ${auth.token}`
|
|
377
|
-
}
|
|
378
|
-
break
|
|
379
|
-
case 'apiKey':
|
|
380
|
-
if (auth.apiKey) {
|
|
381
|
-
headers['X-API-Key'] = auth.apiKey
|
|
382
|
-
}
|
|
383
|
-
break
|
|
384
|
-
case 'basic':
|
|
385
|
-
if (auth.username && auth.password) {
|
|
386
|
-
headers['Authorization'] = `Basic ${btoa(`${auth.username}:${auth.password}`)}`
|
|
387
|
-
}
|
|
388
|
-
break
|
|
389
|
-
}
|
|
390
|
-
|
|
391
206
|
const ws = new WebSocket(wsConfig.url, wsConfig.subprotocols)
|
|
392
|
-
|
|
393
|
-
ws.onopen = () => {
|
|
394
|
-
setConnectionState('connected')
|
|
395
|
-
addMessage('system', 'Connected to WebSocket server')
|
|
396
|
-
}
|
|
397
|
-
|
|
207
|
+
ws.onopen = () => { setConnectionState('connected'); addMessage('system', `Connected to ${wsConfig.url}`) }
|
|
398
208
|
ws.onmessage = (event) => {
|
|
399
209
|
const isBinary = event.data instanceof ArrayBuffer || event.data instanceof Blob
|
|
400
|
-
const
|
|
401
|
-
|
|
402
|
-
event.data
|
|
403
|
-
addMessage('received', data, isBinary)
|
|
404
|
-
}
|
|
405
|
-
|
|
406
|
-
ws.onerror = () => {
|
|
407
|
-
setConnectionState('error')
|
|
408
|
-
setError('WebSocket connection error')
|
|
409
|
-
addMessage('error', 'Connection error occurred')
|
|
210
|
+
const size = event.data instanceof Blob ? event.data.size : event.data instanceof ArrayBuffer ? event.data.byteLength : 0
|
|
211
|
+
addMessage('received', isBinary ? `[Binary data: ${size} bytes]` : String(event.data), isBinary)
|
|
410
212
|
}
|
|
411
|
-
|
|
412
|
-
ws.onclose = (event) => {
|
|
413
|
-
setConnectionState('disconnected')
|
|
414
|
-
const reason = event.reason || 'Connection closed'
|
|
415
|
-
addMessage('system', `Disconnected: ${reason} (Code: ${event.code})`)
|
|
416
|
-
}
|
|
417
|
-
|
|
213
|
+
ws.onerror = () => { setConnectionState('error'); setError('WebSocket connection error'); addMessage('error', 'Connection error') }
|
|
214
|
+
ws.onclose = (event) => { setConnectionState('disconnected'); addMessage('system', `Disconnected: ${event.reason || 'connection closed'} (code ${event.code})`) }
|
|
418
215
|
wsRef.current = ws
|
|
419
216
|
} catch (err) {
|
|
420
217
|
setConnectionState('error')
|
|
421
218
|
setError(err instanceof Error ? err.message : 'Failed to connect')
|
|
422
219
|
}
|
|
423
|
-
}, [wsConfig,
|
|
220
|
+
}, [wsConfig, addMessage])
|
|
424
221
|
|
|
425
|
-
const
|
|
426
|
-
if (wsRef.current) {
|
|
427
|
-
wsRef.current.close()
|
|
428
|
-
wsRef.current = null
|
|
429
|
-
}
|
|
430
|
-
}, [])
|
|
222
|
+
const disconnect = useCallback(() => { wsRef.current?.close(); wsRef.current = null }, [])
|
|
431
223
|
|
|
432
|
-
const
|
|
433
|
-
if (wsRef.current
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
setMessageInput('')
|
|
438
|
-
} catch (err) {
|
|
439
|
-
addMessage('error', `Failed to send message: ${err}`)
|
|
440
|
-
}
|
|
224
|
+
const send = useCallback(() => {
|
|
225
|
+
if (wsRef.current?.readyState === WebSocket.OPEN && messageInput.trim()) {
|
|
226
|
+
wsRef.current.send(messageInput)
|
|
227
|
+
addMessage('sent', messageInput)
|
|
228
|
+
setMessageInput('')
|
|
441
229
|
}
|
|
442
230
|
}, [messageInput, addMessage])
|
|
443
231
|
|
|
444
|
-
const
|
|
445
|
-
|
|
446
|
-
}, [])
|
|
447
|
-
|
|
448
|
-
const tabs = [
|
|
449
|
-
{ name: 'Headers', key: 'headers' },
|
|
450
|
-
{ name: 'Subprotocols', key: 'subprotocols' },
|
|
451
|
-
{ name: 'Auth', key: 'auth' }
|
|
452
|
-
]
|
|
453
|
-
|
|
454
|
-
const getConnectionStatusColor = () => {
|
|
455
|
-
switch (connectionState) {
|
|
456
|
-
case 'connected':
|
|
457
|
-
return 'text-green-400'
|
|
458
|
-
case 'connecting':
|
|
459
|
-
return 'text-yellow-400'
|
|
460
|
-
case 'error':
|
|
461
|
-
return 'text-red-400'
|
|
462
|
-
default:
|
|
463
|
-
return 'text-muted-foreground'
|
|
464
|
-
}
|
|
465
|
-
}
|
|
466
|
-
|
|
467
|
-
const getConnectionStatusText = () => {
|
|
468
|
-
switch (connectionState) {
|
|
469
|
-
case 'connected':
|
|
470
|
-
return 'Connected'
|
|
471
|
-
case 'connecting':
|
|
472
|
-
return 'Connecting...'
|
|
473
|
-
case 'error':
|
|
474
|
-
return 'Error'
|
|
475
|
-
default:
|
|
476
|
-
return 'Disconnected'
|
|
477
|
-
}
|
|
478
|
-
}
|
|
232
|
+
const connected = connectionState === 'connected'
|
|
233
|
+
const tabs: Array<[typeof tab, string]> = [['headers', 'Headers'], ['subprotocols', 'Subprotocols'], ['auth', 'Auth']]
|
|
479
234
|
|
|
480
235
|
return (
|
|
481
|
-
<div className="my-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
<
|
|
485
|
-
|
|
486
|
-
<span className=
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
connectionState === 'connecting' || connectionState === 'connected'
|
|
501
|
-
? 'bg-zinc-600 text-muted-foreground cursor-not-allowed'
|
|
502
|
-
: 'bg-green-600 text-white hover:bg-green-700'
|
|
503
|
-
)}
|
|
504
|
-
>
|
|
505
|
-
{connectionState === 'connecting' ? (
|
|
506
|
-
<LoadingSpinner className="h-4 w-4" />
|
|
507
|
-
) : (
|
|
508
|
-
<PlayButton className="h-4 w-4" />
|
|
509
|
-
)}
|
|
510
|
-
{connectionState === 'connecting' ? 'Connecting...' : 'Connect'}
|
|
511
|
-
</Button>
|
|
512
|
-
<Button
|
|
513
|
-
onClick={disconnectWebSocket}
|
|
514
|
-
disabled={connectionState === 'disconnected'}
|
|
515
|
-
variant="destructive"
|
|
516
|
-
className={classNames(
|
|
517
|
-
'flex items-center gap-2',
|
|
518
|
-
connectionState === 'disconnected' && 'bg-zinc-600 text-muted-foreground cursor-not-allowed'
|
|
519
|
-
)}
|
|
520
|
-
>
|
|
521
|
-
<DisconnectButton className="h-4 w-4" />
|
|
522
|
-
Disconnect
|
|
523
|
-
</Button>
|
|
524
|
-
</div>
|
|
236
|
+
<div className="dory-ws not-prose my-5 overflow-hidden rounded-lg border border-[var(--border)] bg-card">
|
|
237
|
+
<div className="flex items-center gap-3 border-b border-[var(--border)] py-2 pl-3 pr-2">
|
|
238
|
+
<Tag color="sky">WS</Tag>
|
|
239
|
+
<span className="font-mono text-sm font-semibold text-foreground">{title || url}</span>
|
|
240
|
+
<span className="flex items-center gap-1.5 text-sm text-muted-foreground">
|
|
241
|
+
<span aria-hidden="true" className={classNames('size-1.5 rounded-full', statusDot[connectionState])} />
|
|
242
|
+
{statusText[connectionState]}
|
|
243
|
+
</span>
|
|
244
|
+
<button
|
|
245
|
+
type="button"
|
|
246
|
+
onClick={connected ? disconnect : connect}
|
|
247
|
+
disabled={connectionState === 'connecting'}
|
|
248
|
+
className={classNames('ml-auto rounded-md px-4 py-1.5 text-sm font-semibold transition disabled:opacity-60', {
|
|
249
|
+
'bg-[var(--primary)] text-white hover:opacity-90': !connected,
|
|
250
|
+
'border border-[var(--border)] bg-muted text-foreground hover:bg-card': connected,
|
|
251
|
+
})}
|
|
252
|
+
>
|
|
253
|
+
{connected ? 'Disconnect' : 'Connect'}
|
|
254
|
+
</button>
|
|
525
255
|
</div>
|
|
526
256
|
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
<Dropdown
|
|
537
|
-
buttonLabel={selectedServer?.description || selectedServer?.url || 'Select Server'}
|
|
538
|
-
items={servers.map(server => ({
|
|
539
|
-
label: server.description || server.url,
|
|
540
|
-
onClick: () => setSelectedServer(server)
|
|
541
|
-
}))}
|
|
542
|
-
className="w-full"
|
|
543
|
-
/>
|
|
257
|
+
<div className="flex flex-col gap-4 px-4 pt-4 pb-5">
|
|
258
|
+
{servers.length > 0 && (
|
|
259
|
+
<div className="flex flex-col gap-2">
|
|
260
|
+
<MonoLabel>Server</MonoLabel>
|
|
261
|
+
<Select label="Server" value={String(serverIndex)} onChange={(v) => setServerIndex(Number(v))}>
|
|
262
|
+
{servers.map((server, index) => (
|
|
263
|
+
<option key={server.url} value={index}>{server.description ? `${server.description} · ${server.url}` : server.url}</option>
|
|
264
|
+
))}
|
|
265
|
+
</Select>
|
|
544
266
|
</div>
|
|
267
|
+
)}
|
|
545
268
|
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
: 'border-transparent text-muted-foreground hover:text-zinc-300'
|
|
558
|
-
)
|
|
559
|
-
}
|
|
560
|
-
>
|
|
561
|
-
{tab.name}
|
|
562
|
-
</Tab>
|
|
563
|
-
))}
|
|
564
|
-
</TabList>
|
|
565
|
-
|
|
566
|
-
<TabPanels className="min-h-[250px]">
|
|
567
|
-
<TabPanel className="p-4">
|
|
568
|
-
<HeadersTab
|
|
569
|
-
wsConfig={wsConfig}
|
|
570
|
-
setWsConfig={setWsConfig}
|
|
571
|
-
/>
|
|
572
|
-
</TabPanel>
|
|
573
|
-
|
|
574
|
-
<TabPanel className="p-4">
|
|
575
|
-
<SubprotocolsTab
|
|
576
|
-
subprotocols={wsConfig.subprotocols}
|
|
577
|
-
setWsConfig={setWsConfig}
|
|
578
|
-
/>
|
|
579
|
-
</TabPanel>
|
|
580
|
-
|
|
581
|
-
{/* Auth Tab */}
|
|
582
|
-
<TabPanel className="p-4 space-y-4">
|
|
583
|
-
<div className="flex flex-col space-y-2">
|
|
584
|
-
<label className="text-sm font-medium">Authentication Type</label>
|
|
585
|
-
<Dropdown
|
|
586
|
-
buttonLabel={auth.type === 'none' ? 'Select Auth Type' : auth.type.charAt(0).toUpperCase() + auth.type.slice(1)}
|
|
587
|
-
items={[
|
|
588
|
-
{ label: 'None', onClick: () => setAuth(prev => ({ ...prev, type: 'none' })) },
|
|
589
|
-
{ label: 'Bearer Token', onClick: () => setAuth(prev => ({ ...prev, type: 'bearer' })) },
|
|
590
|
-
{ label: 'API Key', onClick: () => setAuth(prev => ({ ...prev, type: 'apiKey' })) },
|
|
591
|
-
{ label: 'Basic Auth', onClick: () => setAuth(prev => ({ ...prev, type: 'basic' })) }
|
|
592
|
-
]}
|
|
593
|
-
className="w-full"
|
|
594
|
-
/>
|
|
595
|
-
</div>
|
|
596
|
-
|
|
597
|
-
{auth.type === 'bearer' && (
|
|
598
|
-
<div className="space-y-2">
|
|
599
|
-
<label className="text-sm font-medium">Bearer Token</label>
|
|
600
|
-
<Input
|
|
601
|
-
type="password"
|
|
602
|
-
value={auth.token}
|
|
603
|
-
onChange={(e) => setAuth(prev => ({ ...prev, token: (e.target as HTMLInputElement).value }))}
|
|
604
|
-
placeholder="Enter bearer token..."
|
|
605
|
-
className="w-full text-sm"
|
|
606
|
-
/>
|
|
607
|
-
</div>
|
|
608
|
-
)}
|
|
609
|
-
|
|
610
|
-
{auth.type === 'apiKey' && (
|
|
611
|
-
<div className="space-y-2">
|
|
612
|
-
<label className="text-sm font-medium">API Key</label>
|
|
613
|
-
<Input
|
|
614
|
-
type="password"
|
|
615
|
-
value={auth.apiKey}
|
|
616
|
-
onChange={(e) => setAuth(prev => ({ ...prev, apiKey: (e.target as HTMLInputElement).value }))}
|
|
617
|
-
placeholder="Enter API key..."
|
|
618
|
-
className="w-full text-sm"
|
|
619
|
-
/>
|
|
620
|
-
</div>
|
|
621
|
-
)}
|
|
622
|
-
|
|
623
|
-
{auth.type === 'basic' && (
|
|
624
|
-
<div className="space-y-4">
|
|
625
|
-
<div className="space-y-2">
|
|
626
|
-
<label className="text-sm font-medium">Username</label>
|
|
627
|
-
<Input
|
|
628
|
-
type="text"
|
|
629
|
-
value={auth.username}
|
|
630
|
-
onChange={(e) => setAuth(prev => ({ ...prev, username: (e.target as HTMLInputElement).value }))}
|
|
631
|
-
placeholder="Enter username..."
|
|
632
|
-
className="w-full text-sm"
|
|
633
|
-
/>
|
|
634
|
-
</div>
|
|
635
|
-
<div className="space-y-2">
|
|
636
|
-
<label className="text-sm font-medium">Password</label>
|
|
637
|
-
<Input
|
|
638
|
-
type="password"
|
|
639
|
-
value={auth.password}
|
|
640
|
-
onChange={(e) => setAuth(prev => ({ ...prev, password: (e.target as HTMLInputElement).value }))}
|
|
641
|
-
placeholder="Enter password..."
|
|
642
|
-
className="w-full text-sm"
|
|
643
|
-
/>
|
|
644
|
-
</div>
|
|
645
|
-
</div>
|
|
646
|
-
)}
|
|
647
|
-
</TabPanel>
|
|
648
|
-
</TabPanels>
|
|
649
|
-
</TabGroup>
|
|
269
|
+
<div>
|
|
270
|
+
<div role="tablist" className="flex gap-5 border-b border-[var(--border)]">
|
|
271
|
+
{tabs.map(([key, label]) => (
|
|
272
|
+
<button key={key} type="button" role="tab" aria-selected={tab === key} onClick={() => setTab(key)}
|
|
273
|
+
className={classNames('-mb-px border-b-2 pb-2.5 pt-1 text-sm font-medium transition', {
|
|
274
|
+
'border-[var(--primary)] text-foreground': tab === key,
|
|
275
|
+
'border-transparent text-muted-foreground hover:text-foreground': tab !== key,
|
|
276
|
+
})}>
|
|
277
|
+
{label}
|
|
278
|
+
</button>
|
|
279
|
+
))}
|
|
650
280
|
</div>
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
<div>
|
|
656
|
-
<div className="flex items-center justify-between px-4 py-3">
|
|
657
|
-
<h3 className="text-sm font-semibold">Messages</h3>
|
|
658
|
-
<Button
|
|
659
|
-
onClick={clearMessages}
|
|
660
|
-
variant="ghost"
|
|
661
|
-
size="sm"
|
|
662
|
-
className="text-xs text-muted-foreground hover:text-zinc-300"
|
|
663
|
-
>
|
|
664
|
-
Clear
|
|
665
|
-
</Button>
|
|
666
|
-
</div>
|
|
667
|
-
|
|
668
|
-
<div className="flex flex-col h-64 overflow-y-auto p-4 space-y-2">
|
|
669
|
-
{messages.length === 0 ? (
|
|
670
|
-
<EmptyState icon={<MessageCircleIcon className="h-5 w-5" />} title="No messages yet" description="Connect to start receiving messages" />
|
|
671
|
-
) : (
|
|
672
|
-
messages.map((msg) => (
|
|
673
|
-
<div
|
|
674
|
-
key={msg.id}
|
|
675
|
-
className={classNames(
|
|
676
|
-
'flex flex-col gap-1 p-3 rounded-lg text-sm',
|
|
677
|
-
msg.type === 'sent' && 'bg-sky-500/10 border border-sky-500/20 ml-8',
|
|
678
|
-
msg.type === 'received' && 'bg-green-500/10 border border-green-500/20 mr-8',
|
|
679
|
-
msg.type === 'error' && 'bg-red-500/10 border border-red-500/20',
|
|
680
|
-
msg.type === 'system' && 'bg-zinc-500/10 border border-zinc-500/20'
|
|
681
|
-
)}
|
|
682
|
-
>
|
|
683
|
-
<div className="flex items-center justify-between">
|
|
684
|
-
<span className={classNames(
|
|
685
|
-
'text-xs font-medium',
|
|
686
|
-
msg.type === 'sent' && 'text-sky-400',
|
|
687
|
-
msg.type === 'received' && 'text-green-400',
|
|
688
|
-
msg.type === 'error' && 'text-red-400',
|
|
689
|
-
msg.type === 'system' && 'text-muted-foreground'
|
|
690
|
-
)}>
|
|
691
|
-
{msg.type === 'sent' ? 'Sent' :
|
|
692
|
-
msg.type === 'received' ? 'Received' :
|
|
693
|
-
msg.type === 'error' ? 'Error' : 'System'}
|
|
694
|
-
{msg.binary && ' (Binary)'}
|
|
695
|
-
</span>
|
|
696
|
-
<span className="text-xs text-muted-foreground">
|
|
697
|
-
{new Date(msg.timestamp).toLocaleTimeString()}
|
|
698
|
-
</span>
|
|
699
|
-
</div>
|
|
700
|
-
<div className="text-white break-words font-mono text-xs">
|
|
701
|
-
{msg.data}
|
|
702
|
-
</div>
|
|
703
|
-
</div>
|
|
704
|
-
))
|
|
705
|
-
)}
|
|
706
|
-
</div>
|
|
707
|
-
|
|
708
|
-
{/* Send Message */}
|
|
709
|
-
<div className="p-4">
|
|
710
|
-
<div className="flex gap-2">
|
|
711
|
-
<Input
|
|
712
|
-
type="text"
|
|
713
|
-
value={messageInput}
|
|
714
|
-
onChange={(e) => setMessageInput((e.target as HTMLInputElement).value)}
|
|
715
|
-
onKeyPress={(e) => e.key === 'Enter' && sendMessage()}
|
|
716
|
-
placeholder="Enter message to send..."
|
|
717
|
-
disabled={connectionState !== 'connected'}
|
|
718
|
-
className="flex-1 text-sm"
|
|
719
|
-
/>
|
|
720
|
-
<Button
|
|
721
|
-
onClick={sendMessage}
|
|
722
|
-
disabled={connectionState !== 'connected' || !messageInput.trim()}
|
|
723
|
-
className={classNames({
|
|
724
|
-
'bg-sky-600 text-white hover:bg-sky-700': connectionState === 'connected' && messageInput.trim()
|
|
725
|
-
})}
|
|
726
|
-
>
|
|
727
|
-
Send
|
|
728
|
-
</Button>
|
|
729
|
-
</div>
|
|
730
|
-
</div>
|
|
281
|
+
<div className="pt-4">
|
|
282
|
+
{tab === 'headers' && <HeadersTab headers={wsConfig.headers} onChange={(headers) => setWsConfig((prev) => ({ ...prev, headers }))} />}
|
|
283
|
+
{tab === 'subprotocols' && <SubprotocolsTab subprotocols={wsConfig.subprotocols} onChange={(subprotocols) => setWsConfig((prev) => ({ ...prev, subprotocols }))} />}
|
|
284
|
+
{tab === 'auth' && <AuthTab auth={auth} setAuth={setAuth} />}
|
|
731
285
|
</div>
|
|
732
286
|
</div>
|
|
733
287
|
</div>
|
|
734
288
|
|
|
735
|
-
|
|
736
|
-
<
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
</
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
289
|
+
<div className="flex items-center justify-between border-t border-[var(--border)] px-4 py-2.5">
|
|
290
|
+
<MonoLabel>Messages</MonoLabel>
|
|
291
|
+
<button type="button" onClick={() => setMessages([])} className="text-sm text-muted-foreground hover:text-foreground">Clear</button>
|
|
292
|
+
</div>
|
|
293
|
+
<div className="max-h-72 min-h-56 overflow-y-auto border-t border-[var(--border)] bg-background px-4 py-3">
|
|
294
|
+
{messages.length === 0 ? (
|
|
295
|
+
<p className="flex h-48 items-center justify-center text-sm text-muted-foreground">Connect to start receiving messages</p>
|
|
296
|
+
) : (
|
|
297
|
+
<ul className="flex flex-col gap-1.5">
|
|
298
|
+
{messages.map((message) => (
|
|
299
|
+
<li key={message.id} className="flex items-baseline gap-3 font-mono text-xs">
|
|
300
|
+
<span className={classNames('w-10 shrink-0 font-semibold uppercase tracking-[0.08em]', {
|
|
301
|
+
'text-[var(--brand-readable,#b65401)]': message.type === 'sent',
|
|
302
|
+
'text-[var(--primary)]': message.type === 'received',
|
|
303
|
+
'text-[var(--destructive)]': message.type === 'error',
|
|
304
|
+
'text-muted-foreground': message.type === 'system',
|
|
305
|
+
})}>
|
|
306
|
+
{message.type === 'sent' ? 'send' : message.type === 'received' ? 'recv' : message.type === 'error' ? 'err' : 'sys'}
|
|
307
|
+
</span>
|
|
308
|
+
<span className="min-w-0 flex-1 break-words text-foreground">{message.data}</span>
|
|
309
|
+
<span className="shrink-0 text-muted-foreground">{new Date(message.timestamp).toLocaleTimeString()}</span>
|
|
310
|
+
</li>
|
|
311
|
+
))}
|
|
312
|
+
</ul>
|
|
313
|
+
)}
|
|
314
|
+
{error && <p className="mt-2 font-mono text-xs text-[var(--destructive)]">{error}</p>}
|
|
315
|
+
</div>
|
|
316
|
+
<div className="flex items-center gap-2 border-t border-[var(--border)] bg-muted px-4 py-3">
|
|
317
|
+
<input
|
|
318
|
+
className={classNames(inputClass, 'bg-card')}
|
|
319
|
+
value={messageInput}
|
|
320
|
+
onInput={(e) => setMessageInput((e.target as HTMLInputElement).value)}
|
|
321
|
+
onKeyDown={(e) => { if (e.key === 'Enter') send() }}
|
|
322
|
+
placeholder={'{"type":"query.start","sql":"select * from orders limit 3"}'}
|
|
323
|
+
aria-label="Message to send"
|
|
324
|
+
/>
|
|
325
|
+
<button type="button" onClick={send} disabled={!connected || !messageInput.trim()}
|
|
326
|
+
className="rounded-md border border-[var(--border)] bg-card px-4 py-2 text-sm font-semibold text-foreground transition disabled:text-muted-foreground disabled:opacity-70">
|
|
327
|
+
Send
|
|
328
|
+
</button>
|
|
329
|
+
</div>
|
|
747
330
|
</div>
|
|
748
331
|
)
|
|
749
332
|
}
|
|
750
333
|
|
|
751
|
-
|
|
334
|
+
/** MDX component: optional description, then the inline WebSocket panel. */
|
|
752
335
|
export function WebSocketPlayground(props: WebSocketPlaygroundProps) {
|
|
753
336
|
if (typeof window === 'undefined') return null
|
|
754
|
-
|
|
755
|
-
const [showPlayground, setShowPlayground] = useState(false)
|
|
756
|
-
|
|
757
|
-
const handleShowPlayground = () => {
|
|
758
|
-
setShowPlayground(true)
|
|
759
|
-
}
|
|
760
|
-
|
|
761
337
|
return (
|
|
762
|
-
<div className="
|
|
763
|
-
<
|
|
764
|
-
|
|
765
|
-
<SheetHeader className="p-0">
|
|
766
|
-
<SheetTitle>WebSocket Playground</SheetTitle>
|
|
767
|
-
</SheetHeader>
|
|
768
|
-
<WebSocketPlaygroundInternal {...props} />
|
|
769
|
-
</SheetContent>
|
|
770
|
-
</Sheet>
|
|
771
|
-
|
|
772
|
-
<Card className="overflow-hidden py-0 border-1 border-black/10">
|
|
773
|
-
<div className="flex items-center justify-between px-4 py-3">
|
|
774
|
-
<div className="flex items-center gap-3">
|
|
775
|
-
<Tag variant="small">WS</Tag>
|
|
776
|
-
<span className="font-mono text-sm">{props.title || props.url}</span>
|
|
777
|
-
</div>
|
|
778
|
-
<Button
|
|
779
|
-
onClick={handleShowPlayground}
|
|
780
|
-
className="flex items-center gap-2"
|
|
781
|
-
>
|
|
782
|
-
Try
|
|
783
|
-
<PlayButton className="h-4 w-4" />
|
|
784
|
-
</Button>
|
|
785
|
-
</div>
|
|
786
|
-
|
|
787
|
-
{props.description && (
|
|
788
|
-
<p className="text-sm px-4 py-3">{props.description}</p>
|
|
789
|
-
)}
|
|
790
|
-
|
|
791
|
-
<div className="px-4 py-3">
|
|
792
|
-
<div className="flex flex-col gap-2">
|
|
793
|
-
<div className="flex items-center gap-2">
|
|
794
|
-
<span className="text-xs font-medium text-muted-foreground">Endpoint:</span>
|
|
795
|
-
<Badge>
|
|
796
|
-
{props.servers?.[0]?.url || 'ws://localhost'}{props.url}
|
|
797
|
-
</Badge>
|
|
798
|
-
</div>
|
|
799
|
-
|
|
800
|
-
{props.subprotocols && props.subprotocols.length > 0 && (
|
|
801
|
-
<div className="flex items-center gap-2">
|
|
802
|
-
<span className="text-xs font-medium text-muted-foreground">Subprotocols:</span>
|
|
803
|
-
<div className="flex gap-1">
|
|
804
|
-
{props.subprotocols.map((protocol, idx) => (
|
|
805
|
-
<Badge key={idx}>
|
|
806
|
-
{protocol}
|
|
807
|
-
</Badge>
|
|
808
|
-
))}
|
|
809
|
-
</div>
|
|
810
|
-
</div>
|
|
811
|
-
)}
|
|
812
|
-
|
|
813
|
-
{props.authType && props.authType !== 'none' && (
|
|
814
|
-
<div className="flex items-center gap-2">
|
|
815
|
-
<span className="text-xs font-medium text-muted-foreground">Authentication:</span>
|
|
816
|
-
<Badge>
|
|
817
|
-
{props.authType}
|
|
818
|
-
</Badge>
|
|
819
|
-
</div>
|
|
820
|
-
)}
|
|
821
|
-
</div>
|
|
822
|
-
</div>
|
|
823
|
-
</Card>
|
|
338
|
+
<div className="my-5">
|
|
339
|
+
{props.description && <p className="mb-3 text-sm text-muted-foreground">{props.description}</p>}
|
|
340
|
+
<WebSocketPanel {...props} />
|
|
824
341
|
</div>
|
|
825
342
|
)
|
|
826
|
-
}
|
|
343
|
+
}
|