@bielu/scalar-signalr 0.1.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/README.md +85 -0
- package/dist/plugin.js +22 -0
- package/dist/types/auth.d.ts +21 -0
- package/dist/types/components/SignalRConsole.vue.d.ts +7 -0
- package/dist/types/discovery.d.ts +13 -0
- package/dist/types/index.d.ts +3 -0
- package/dist/types/plugin.d.ts +32 -0
- package/dist/types/signalr-bindings.d.ts +11 -0
- package/dist/types/types.d.ts +69 -0
- package/package.json +56 -0
- package/src/auth.ts +94 -0
- package/src/components/SignalRConsole.vue +818 -0
- package/src/discovery.ts +21 -0
- package/src/index.ts +26 -0
- package/src/plugin.ts +39 -0
- package/src/signalr-bindings.ts +114 -0
- package/src/types.ts +75 -0
|
@@ -0,0 +1,818 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import {
|
|
3
|
+
HttpTransportType,
|
|
4
|
+
type HubConnection,
|
|
5
|
+
type IHttpConnectionOptions,
|
|
6
|
+
HubConnectionBuilder,
|
|
7
|
+
HubConnectionState,
|
|
8
|
+
LogLevel,
|
|
9
|
+
} from '@microsoft/signalr'
|
|
10
|
+
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
|
|
11
|
+
import { getAuthState, resolveSignalRAuth } from '../auth'
|
|
12
|
+
import { resolveDocuments } from '../discovery'
|
|
13
|
+
import { loadSignalRHubs } from '../signalr-bindings'
|
|
14
|
+
import type { SignalRDocumentRef, SignalRHubModel, SignalROperationModel } from '../types'
|
|
15
|
+
|
|
16
|
+
// `options` is the Scalar configuration Scalar binds to the element; `documents` is an optional
|
|
17
|
+
// explicit override. Documents are otherwise auto-discovered from `options.sources`.
|
|
18
|
+
const props = defineProps<{ options?: Record<string, any>; documents?: SignalRDocumentRef[] }>()
|
|
19
|
+
|
|
20
|
+
type TransportChoice = 'auto' | 'webSockets' | 'serverSentEvents' | 'longPolling'
|
|
21
|
+
type LogEntry = { time: string; dir: 'in' | 'out' | 'sys'; text: string }
|
|
22
|
+
|
|
23
|
+
const loading = ref(true)
|
|
24
|
+
const hubs = ref<SignalRHubModel[]>([])
|
|
25
|
+
// The console's own root element, used to discover which Scalar document it is rendered under.
|
|
26
|
+
const rootEl = ref<HTMLElement | null>(null)
|
|
27
|
+
// Scalar's active document slug (null on stock Scalar / standalone use, where the picker is shown).
|
|
28
|
+
const activeDocumentSlug = ref<string | null>(null)
|
|
29
|
+
const selectedDocumentName = ref<string | null>(null)
|
|
30
|
+
const selectedKey = ref<string | null>(null)
|
|
31
|
+
const baseUrlOverride = ref('')
|
|
32
|
+
const transport = ref<TransportChoice>('auto')
|
|
33
|
+
const state = ref<HubConnectionState>(HubConnectionState.Disconnected)
|
|
34
|
+
const log = reactive<LogEntry[]>([])
|
|
35
|
+
// Per-operation state survives hub switches, so it is keyed by hub + operation id (operations from
|
|
36
|
+
// different hubs/documents can share an `op.id`, which would otherwise overwrite each other).
|
|
37
|
+
const invokeArgs = reactive<Record<string, string>>({})
|
|
38
|
+
const results = reactive<Record<string, { ok: boolean; text: string }>>({})
|
|
39
|
+
// Which method is selected per hub (keyed by hubKey), and which message of an op is the active example.
|
|
40
|
+
const selectedMethodIds = reactive<Record<string, string>>({})
|
|
41
|
+
const selectedMessageIndex = reactive<Record<string, number>>({})
|
|
42
|
+
|
|
43
|
+
let connection: HubConnection | null = null
|
|
44
|
+
|
|
45
|
+
const hubKey = (hub: SignalRHubModel) => `${hub.documentName}:${hub.channelName}`
|
|
46
|
+
// A hub-scoped key for an operation. All displayed operations belong to the selected hub, so the
|
|
47
|
+
// selected hub key uniquely scopes them.
|
|
48
|
+
const opKey = (op: SignalROperationModel) => `${selectedKey.value}:${op.id}`
|
|
49
|
+
|
|
50
|
+
const uniqueDocuments = computed(() => {
|
|
51
|
+
const seen = new Set<string>()
|
|
52
|
+
const docs: string[] = []
|
|
53
|
+
for (const h of hubs.value) {
|
|
54
|
+
if (!seen.has(h.documentName)) {
|
|
55
|
+
seen.add(h.documentName)
|
|
56
|
+
docs.push(h.documentName)
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return docs
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
// Scope the visible hubs to Scalar's active document when known (so the console reflects the
|
|
63
|
+
// document the user is viewing), otherwise to the manually picked document. Falls back to all hubs
|
|
64
|
+
// only when neither is set.
|
|
65
|
+
const scopedDocumentName = computed(() => activeDocumentSlug.value ?? selectedDocumentName.value)
|
|
66
|
+
const filteredHubs = computed(() =>
|
|
67
|
+
scopedDocumentName.value
|
|
68
|
+
? hubs.value.filter((h) => h.documentName === scopedDocumentName.value)
|
|
69
|
+
: hubs.value,
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
// The document picker only makes sense when the console spans several documents itself. Once it is
|
|
73
|
+
// scoped to Scalar's active document, the active document is authoritative, so the picker is hidden.
|
|
74
|
+
const showDocumentPicker = computed(() => !activeDocumentSlug.value && uniqueDocuments.value.length > 1)
|
|
75
|
+
|
|
76
|
+
const selectedHub = computed(() => filteredHubs.value.find((hub) => hubKey(hub) === selectedKey.value) ?? null)
|
|
77
|
+
const clientToServer = computed(() => selectedHub.value?.operations.filter((op) => op.direction === 'clientToServer') ?? [])
|
|
78
|
+
const serverToClient = computed(() => selectedHub.value?.operations.filter((op) => op.direction === 'serverToClient') ?? [])
|
|
79
|
+
// The selected method id for the current hub, persisted per hub so switching hubs keeps each choice.
|
|
80
|
+
const selectedMethodId = computed<string | null>({
|
|
81
|
+
get: () => (selectedKey.value ? selectedMethodIds[selectedKey.value] ?? null : null),
|
|
82
|
+
set: (value) => {
|
|
83
|
+
if (selectedKey.value && value) {
|
|
84
|
+
selectedMethodIds[selectedKey.value] = value
|
|
85
|
+
}
|
|
86
|
+
},
|
|
87
|
+
})
|
|
88
|
+
const selectedMethod = computed(() => clientToServer.value.find((op) => op.id === selectedMethodId.value) ?? null)
|
|
89
|
+
const isConnected = computed(() => state.value === HubConnectionState.Connected)
|
|
90
|
+
const stateLabel = computed(() => HubConnectionState[state.value] ?? 'Disconnected')
|
|
91
|
+
|
|
92
|
+
const hubUrl = computed(() => {
|
|
93
|
+
const hub = selectedHub.value
|
|
94
|
+
if (!hub) {
|
|
95
|
+
return ''
|
|
96
|
+
}
|
|
97
|
+
const base = (baseUrlOverride.value || hub.baseUrl || '').replace(/\/+$/, '')
|
|
98
|
+
const path = hub.hubPath.startsWith('/') ? hub.hubPath : `/${hub.hubPath}`
|
|
99
|
+
return `${base}${path}`
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
function addLog(dir: LogEntry['dir'], text: string) {
|
|
103
|
+
log.unshift({ time: new Date().toLocaleTimeString(), dir, text })
|
|
104
|
+
if (log.length > 200) {
|
|
105
|
+
log.pop()
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function transportFlag(): HttpTransportType | undefined {
|
|
110
|
+
switch (transport.value) {
|
|
111
|
+
case 'webSockets':
|
|
112
|
+
return HttpTransportType.WebSockets
|
|
113
|
+
case 'serverSentEvents':
|
|
114
|
+
return HttpTransportType.ServerSentEvents
|
|
115
|
+
case 'longPolling':
|
|
116
|
+
return HttpTransportType.LongPolling
|
|
117
|
+
default:
|
|
118
|
+
return undefined
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Parse the editor contents into a positional argument array. Throws on invalid JSON so malformed
|
|
124
|
+
* input surfaces as a validation error instead of being silently sent as a raw string argument.
|
|
125
|
+
*/
|
|
126
|
+
function parseArgs(raw: string): unknown[] {
|
|
127
|
+
const text = (raw ?? '').trim()
|
|
128
|
+
if (!text) {
|
|
129
|
+
return []
|
|
130
|
+
}
|
|
131
|
+
const parsed = JSON.parse(text)
|
|
132
|
+
return Array.isArray(parsed) ? parsed : [parsed]
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function disconnect() {
|
|
136
|
+
if (connection) {
|
|
137
|
+
try {
|
|
138
|
+
await connection.stop()
|
|
139
|
+
} catch {
|
|
140
|
+
// ignore stop errors
|
|
141
|
+
}
|
|
142
|
+
connection = null
|
|
143
|
+
}
|
|
144
|
+
state.value = HubConnectionState.Disconnected
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async function connect() {
|
|
148
|
+
const hub = selectedHub.value
|
|
149
|
+
if (!hub) {
|
|
150
|
+
return
|
|
151
|
+
}
|
|
152
|
+
await disconnect()
|
|
153
|
+
|
|
154
|
+
// Resolve Scalar's current auth state → no-op on stock Scalar or when no scheme is selected.
|
|
155
|
+
const authState = getAuthState()
|
|
156
|
+
const authResult = resolveSignalRAuth(hub.documentName, hub.securitySchemes, authState)
|
|
157
|
+
for (const warn of authResult.warnings) {
|
|
158
|
+
addLog('sys', `Auth warning: ${warn}`)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Warn when the hub declares security schemes but no credentials were resolved.
|
|
162
|
+
const hasSchemes = hub.securitySchemes && Object.keys(hub.securitySchemes).length > 0
|
|
163
|
+
const resolvedCredentials =
|
|
164
|
+
Object.keys(authResult.queryParams).length > 0 || !!authResult.accessToken
|
|
165
|
+
if (hasSchemes && !resolvedCredentials) {
|
|
166
|
+
if (!authState) {
|
|
167
|
+
addLog(
|
|
168
|
+
'sys',
|
|
169
|
+
'Auth: no Scalar auth state available — this Scalar version may predate the plugin auth-state API (Scalar ≥ 2.16.12).',
|
|
170
|
+
)
|
|
171
|
+
} else {
|
|
172
|
+
addLog(
|
|
173
|
+
'sys',
|
|
174
|
+
`Auth: no credentials resolved for document "${hub.documentName}" — select a security scheme in the Authentication panel and enter your credentials.`,
|
|
175
|
+
)
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Append any resolved query params (e.g. api_key) to the hub URL.
|
|
180
|
+
let effectiveUrl = hubUrl.value
|
|
181
|
+
if (Object.keys(authResult.queryParams).length > 0) {
|
|
182
|
+
const qs = new URLSearchParams(authResult.queryParams).toString()
|
|
183
|
+
effectiveUrl += (effectiveUrl.includes('?') ? '&' : '?') + qs
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const flag = transportFlag()
|
|
187
|
+
const withUrlOptions: IHttpConnectionOptions = flag !== undefined ? { transport: flag } : {}
|
|
188
|
+
if (authResult.accessToken) {
|
|
189
|
+
const token = authResult.accessToken
|
|
190
|
+
withUrlOptions.accessTokenFactory = () => token
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
connection = new HubConnectionBuilder()
|
|
194
|
+
.withUrl(effectiveUrl, withUrlOptions)
|
|
195
|
+
.configureLogging(LogLevel.Warning)
|
|
196
|
+
.withAutomaticReconnect()
|
|
197
|
+
.build()
|
|
198
|
+
|
|
199
|
+
for (const op of serverToClient.value) {
|
|
200
|
+
connection.on(op.target, (...args: unknown[]) => {
|
|
201
|
+
addLog('in', `${op.target}(${args.map((arg) => JSON.stringify(arg)).join(', ')})`)
|
|
202
|
+
})
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
connection.onclose((error) => {
|
|
206
|
+
state.value = HubConnectionState.Disconnected
|
|
207
|
+
addLog('sys', error ? `Disconnected: ${error.message}` : 'Disconnected')
|
|
208
|
+
})
|
|
209
|
+
connection.onreconnecting(() => {
|
|
210
|
+
state.value = HubConnectionState.Reconnecting
|
|
211
|
+
addLog('sys', 'Reconnecting…')
|
|
212
|
+
})
|
|
213
|
+
connection.onreconnected(() => {
|
|
214
|
+
state.value = HubConnectionState.Connected
|
|
215
|
+
addLog('sys', 'Reconnected')
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
try {
|
|
219
|
+
addLog('sys', `Connecting to ${effectiveUrl}…`)
|
|
220
|
+
await connection.start()
|
|
221
|
+
state.value = HubConnectionState.Connected
|
|
222
|
+
addLog('sys', 'Connected')
|
|
223
|
+
} catch (error: any) {
|
|
224
|
+
state.value = HubConnectionState.Disconnected
|
|
225
|
+
addLog('sys', `Connection failed: ${error?.message ?? error}`)
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** Drain a SignalR stream into an array, logging each item as it arrives. */
|
|
230
|
+
function streamAll(target: string, args: unknown[]): Promise<unknown[]> {
|
|
231
|
+
return new Promise((resolve, reject) => {
|
|
232
|
+
const items: unknown[] = []
|
|
233
|
+
connection!.stream(target, ...args).subscribe({
|
|
234
|
+
next: (item: unknown) => {
|
|
235
|
+
items.push(item)
|
|
236
|
+
addLog('in', `← ${JSON.stringify(item)}`)
|
|
237
|
+
},
|
|
238
|
+
error: (error: unknown) => reject(error),
|
|
239
|
+
complete: () => resolve(items),
|
|
240
|
+
})
|
|
241
|
+
})
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
async function testRequest(op: SignalROperationModel) {
|
|
245
|
+
const key = opKey(op)
|
|
246
|
+
if (!connection || !isConnected.value) {
|
|
247
|
+
results[key] = { ok: false, text: 'Not connected — press Connect first.' }
|
|
248
|
+
return
|
|
249
|
+
}
|
|
250
|
+
let args: unknown[]
|
|
251
|
+
try {
|
|
252
|
+
args = parseArgs(invokeArgs[key])
|
|
253
|
+
} catch (error: any) {
|
|
254
|
+
results[key] = { ok: false, text: `Invalid JSON arguments: ${error?.message ?? error}` }
|
|
255
|
+
return
|
|
256
|
+
}
|
|
257
|
+
addLog('out', `${op.target}(${args.map((arg) => JSON.stringify(arg)).join(', ')})`)
|
|
258
|
+
try {
|
|
259
|
+
if (op.callType === 'send') {
|
|
260
|
+
await connection.send(op.target, ...args)
|
|
261
|
+
results[key] = { ok: true, text: 'Sent (fire-and-forget, no response).' }
|
|
262
|
+
} else if (op.callType === 'streamInvocation') {
|
|
263
|
+
const items = await streamAll(op.target, args)
|
|
264
|
+
results[key] = { ok: true, text: `Stream completed: ${items.length} item(s).\n${JSON.stringify(items, null, 2)}` }
|
|
265
|
+
} else {
|
|
266
|
+
const result = await connection.invoke(op.target, ...args)
|
|
267
|
+
const text = result === undefined ? 'Completed (no return value).' : JSON.stringify(result, null, 2)
|
|
268
|
+
results[key] = { ok: true, text }
|
|
269
|
+
if (result !== undefined) {
|
|
270
|
+
addLog('in', `← ${JSON.stringify(result)}`)
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
} catch (error: any) {
|
|
274
|
+
results[key] = { ok: false, text: `Error: ${error?.message ?? error}` }
|
|
275
|
+
addLog('sys', `Invoke failed: ${error?.message ?? error}`)
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function methodLabel(op: SignalROperationModel): string {
|
|
280
|
+
return (op.callType ?? 'invoke').toUpperCase()
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** The example string for a given message of an operation (falls back to an empty array). */
|
|
284
|
+
function exampleFor(op: SignalROperationModel | null, index: number): string {
|
|
285
|
+
return op?.messages?.[index]?.example ?? '[]'
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** Select a message and (re)fill the arguments editor with its generated example. */
|
|
289
|
+
function applyExample(op: SignalROperationModel, index: number) {
|
|
290
|
+
const key = opKey(op)
|
|
291
|
+
selectedMessageIndex[key] = index
|
|
292
|
+
invokeArgs[key] = exampleFor(op, index)
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** Prefill an operation's editor with its first example, but only if it is still untouched. */
|
|
296
|
+
function ensurePrefilled(op: SignalROperationModel) {
|
|
297
|
+
const key = opKey(op)
|
|
298
|
+
if (invokeArgs[key] === undefined) {
|
|
299
|
+
applyExample(op, selectedMessageIndex[key] ?? 0)
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// Keep the selected hub within the visible (document-scoped) set. Fires when the document changes
|
|
304
|
+
// (picker or active-document detection) and when hubs finish loading.
|
|
305
|
+
watch(
|
|
306
|
+
filteredHubs,
|
|
307
|
+
(list) => {
|
|
308
|
+
if (!list.some((hub) => hubKey(hub) === selectedKey.value)) {
|
|
309
|
+
selectedKey.value = list[0] ? hubKey(list[0]) : null
|
|
310
|
+
}
|
|
311
|
+
},
|
|
312
|
+
{ immediate: true },
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
watch(selectedHub, (hub) => {
|
|
316
|
+
baseUrlOverride.value = hub?.baseUrl ?? ''
|
|
317
|
+
void disconnect()
|
|
318
|
+
})
|
|
319
|
+
|
|
320
|
+
// Editing the Base URL or Transport changes the endpoint, so drop any live connection — the next
|
|
321
|
+
// Connect rebuilds it from the current hubUrl/transport settings.
|
|
322
|
+
watch([baseUrlOverride, transport], () => {
|
|
323
|
+
void disconnect()
|
|
324
|
+
})
|
|
325
|
+
|
|
326
|
+
// When the available methods change (e.g. a different hub), select the first one and prefill it.
|
|
327
|
+
watch(
|
|
328
|
+
clientToServer,
|
|
329
|
+
(ops) => {
|
|
330
|
+
if (!ops.some((op) => op.id === selectedMethodId.value)) {
|
|
331
|
+
selectedMethodId.value = ops[0]?.id ?? null
|
|
332
|
+
}
|
|
333
|
+
ops.forEach(ensurePrefilled)
|
|
334
|
+
},
|
|
335
|
+
{ immediate: true },
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
// Selecting a different method prefills its editor on first visit.
|
|
339
|
+
watch(selectedMethod, (op) => {
|
|
340
|
+
if (op) {
|
|
341
|
+
ensurePrefilled(op)
|
|
342
|
+
}
|
|
343
|
+
})
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Discover Scalar's active document from the plugin-view wrapper this console is rendered inside.
|
|
347
|
+
* Scalar renders each `content.end` plugin view under a `<div id="{documentSlug}/plugin-view/…">`
|
|
348
|
+
* scoped to the active document, so the slug is the id prefix. Returns null when the console is not
|
|
349
|
+
* inside such a wrapper (stock Scalar / standalone), leaving the multi-document picker in place.
|
|
350
|
+
*/
|
|
351
|
+
function detectActiveDocumentSlug(): string | null {
|
|
352
|
+
const wrapper = rootEl.value?.closest('[id*="/plugin-view/"]')
|
|
353
|
+
const slug = wrapper?.id.split('/plugin-view/')[0]
|
|
354
|
+
return slug || null
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
async function init() {
|
|
358
|
+
loading.value = true
|
|
359
|
+
hubs.value = await loadSignalRHubs(resolveDocuments(props.options, props.documents))
|
|
360
|
+
// Seed a default document for the picker; the active-document scope (when detected) overrides it.
|
|
361
|
+
if (hubs.value.length > 0 && !selectedDocumentName.value) {
|
|
362
|
+
selectedDocumentName.value = hubs.value[0].documentName
|
|
363
|
+
}
|
|
364
|
+
loading.value = false
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
void init()
|
|
368
|
+
onMounted(() => {
|
|
369
|
+
activeDocumentSlug.value = detectActiveDocumentSlug()
|
|
370
|
+
})
|
|
371
|
+
onBeforeUnmount(() => void disconnect())
|
|
372
|
+
</script>
|
|
373
|
+
|
|
374
|
+
<template>
|
|
375
|
+
<section ref="rootEl" class="bsr">
|
|
376
|
+
<header class="bsr__head">
|
|
377
|
+
<h2 class="bsr__title">SignalR</h2>
|
|
378
|
+
<span class="bsr__pill" :data-state="stateLabel.toLowerCase()">{{ stateLabel }}</span>
|
|
379
|
+
</header>
|
|
380
|
+
|
|
381
|
+
<p v-if="loading" class="bsr__muted">Loading SignalR hubs…</p>
|
|
382
|
+
<p v-else-if="filteredHubs.length === 0" class="bsr__muted">
|
|
383
|
+
No SignalR hubs found in {{ activeDocumentSlug ? 'this document' : 'the AsyncAPI document(s)' }}.
|
|
384
|
+
</p>
|
|
385
|
+
|
|
386
|
+
<template v-else>
|
|
387
|
+
<!-- Connection bar -->
|
|
388
|
+
<div class="bsr__card bsr__conn">
|
|
389
|
+
<div class="bsr__conn-row">
|
|
390
|
+
<label v-if="showDocumentPicker" class="bsr__field">
|
|
391
|
+
<span>Document</span>
|
|
392
|
+
<select v-model="selectedDocumentName">
|
|
393
|
+
<option v-for="doc in uniqueDocuments" :key="doc" :value="doc">{{ doc }}</option>
|
|
394
|
+
</select>
|
|
395
|
+
</label>
|
|
396
|
+
<label class="bsr__field">
|
|
397
|
+
<span>Hub</span>
|
|
398
|
+
<select v-model="selectedKey">
|
|
399
|
+
<option v-for="hub in filteredHubs" :key="hubKey(hub)" :value="hubKey(hub)">
|
|
400
|
+
{{ hub.channelName }} ({{ hub.hubPath }})
|
|
401
|
+
</option>
|
|
402
|
+
</select>
|
|
403
|
+
</label>
|
|
404
|
+
<label class="bsr__field bsr__field--grow">
|
|
405
|
+
<span>Base URL</span>
|
|
406
|
+
<input v-model="baseUrlOverride" type="text" spellcheck="false" />
|
|
407
|
+
</label>
|
|
408
|
+
<label class="bsr__field">
|
|
409
|
+
<span>Transport</span>
|
|
410
|
+
<select v-model="transport">
|
|
411
|
+
<option value="auto">Auto</option>
|
|
412
|
+
<option value="webSockets">WebSockets</option>
|
|
413
|
+
<option value="serverSentEvents">Server-Sent Events</option>
|
|
414
|
+
<option value="longPolling">Long Polling</option>
|
|
415
|
+
</select>
|
|
416
|
+
</label>
|
|
417
|
+
</div>
|
|
418
|
+
<div class="bsr__conn-row bsr__conn-row--foot">
|
|
419
|
+
<code class="bsr__url">{{ hubUrl }}</code>
|
|
420
|
+
<button v-if="!isConnected" type="button" class="bsr__btn bsr__btn--primary" @click="connect">Connect</button>
|
|
421
|
+
<button v-else type="button" class="bsr__btn" @click="disconnect">Disconnect</button>
|
|
422
|
+
</div>
|
|
423
|
+
</div>
|
|
424
|
+
|
|
425
|
+
<!-- Two columns on wide screens (interactive methods | events + log); stacked on narrow. -->
|
|
426
|
+
<div class="bsr__grid">
|
|
427
|
+
<!-- Left column: client -> server methods. Pick one, edit the prefilled example, send. -->
|
|
428
|
+
<div class="bsr__col">
|
|
429
|
+
<h3 class="bsr__section">Methods</h3>
|
|
430
|
+
<p v-if="clientToServer.length === 0" class="bsr__muted">No invocable hub methods.</p>
|
|
431
|
+
<article v-else class="bsr__card bsr__op">
|
|
432
|
+
<div class="bsr__conn-row">
|
|
433
|
+
<label class="bsr__field bsr__field--grow">
|
|
434
|
+
<span>Method</span>
|
|
435
|
+
<select v-model="selectedMethodId">
|
|
436
|
+
<option v-for="op in clientToServer" :key="op.id" :value="op.id">
|
|
437
|
+
{{ op.target }} · {{ methodLabel(op) }}
|
|
438
|
+
</option>
|
|
439
|
+
</select>
|
|
440
|
+
</label>
|
|
441
|
+
<label v-if="selectedMethod && selectedMethod.messages.length > 1" class="bsr__field bsr__field--grow">
|
|
442
|
+
<span>Message</span>
|
|
443
|
+
<select
|
|
444
|
+
:value="selectedMessageIndex[opKey(selectedMethod)] ?? 0"
|
|
445
|
+
@change="applyExample(selectedMethod, Number(($event.target as HTMLSelectElement).value))"
|
|
446
|
+
>
|
|
447
|
+
<option v-for="(message, index) in selectedMethod.messages" :key="message.name" :value="index">
|
|
448
|
+
{{ message.title || message.name }}
|
|
449
|
+
</option>
|
|
450
|
+
</select>
|
|
451
|
+
</label>
|
|
452
|
+
</div>
|
|
453
|
+
|
|
454
|
+
<template v-if="selectedMethod">
|
|
455
|
+
<div class="bsr__op-head bsr__op-head--spaced">
|
|
456
|
+
<span class="bsr__method" :data-kind="selectedMethod.callType ?? 'invoke'">{{ methodLabel(selectedMethod) }}</span>
|
|
457
|
+
<span class="bsr__op-target">{{ selectedMethod.target }}</span>
|
|
458
|
+
<span class="bsr__hint">client → server</span>
|
|
459
|
+
</div>
|
|
460
|
+
<p v-if="selectedMethod.summary" class="bsr__muted bsr__op-summary">{{ selectedMethod.summary }}</p>
|
|
461
|
+
|
|
462
|
+
<div class="bsr__code-head">
|
|
463
|
+
<span class="bsr__hint">Arguments (JSON array)</span>
|
|
464
|
+
<button
|
|
465
|
+
type="button"
|
|
466
|
+
class="bsr__link"
|
|
467
|
+
:disabled="selectedMethod.messages.length === 0"
|
|
468
|
+
@click="applyExample(selectedMethod, selectedMessageIndex[opKey(selectedMethod)] ?? 0)"
|
|
469
|
+
>
|
|
470
|
+
↺ Reset to example
|
|
471
|
+
</button>
|
|
472
|
+
</div>
|
|
473
|
+
<textarea
|
|
474
|
+
v-model="invokeArgs[opKey(selectedMethod)]"
|
|
475
|
+
class="bsr__code"
|
|
476
|
+
rows="6"
|
|
477
|
+
spellcheck="false"
|
|
478
|
+
placeholder='Arguments as a JSON array, e.g. ["alice", "hello"]'
|
|
479
|
+
/>
|
|
480
|
+
<div class="bsr__op-foot">
|
|
481
|
+
<span class="bsr__hint">{{ hubUrl }}</span>
|
|
482
|
+
<button type="button" class="bsr__btn bsr__btn--primary" :disabled="!isConnected" @click="testRequest(selectedMethod)">
|
|
483
|
+
▶ Test Request
|
|
484
|
+
</button>
|
|
485
|
+
</div>
|
|
486
|
+
<pre v-if="results[opKey(selectedMethod)]" class="bsr__result" :data-ok="results[opKey(selectedMethod)].ok">{{ results[opKey(selectedMethod)].text }}</pre>
|
|
487
|
+
</template>
|
|
488
|
+
</article>
|
|
489
|
+
</div>
|
|
490
|
+
|
|
491
|
+
<!-- Right column: the live message log (the useful part) above the events reference. -->
|
|
492
|
+
<div class="bsr__col">
|
|
493
|
+
<!-- Live message log -->
|
|
494
|
+
<h3 class="bsr__section">Messages</h3>
|
|
495
|
+
<ul class="bsr__log">
|
|
496
|
+
<li v-for="(entry, index) in log" :key="index" :class="`bsr__log-${entry.dir}`">
|
|
497
|
+
<span class="bsr__time">{{ entry.time }}</span>
|
|
498
|
+
<span class="bsr__badge">{{ entry.dir }}</span>
|
|
499
|
+
<span>{{ entry.text }}</span>
|
|
500
|
+
</li>
|
|
501
|
+
<li v-if="log.length === 0" class="bsr__muted">No messages yet.</li>
|
|
502
|
+
</ul>
|
|
503
|
+
|
|
504
|
+
<!-- Server -> client events reference -->
|
|
505
|
+
<h3 class="bsr__section">Events</h3>
|
|
506
|
+
<p v-if="serverToClient.length === 0" class="bsr__muted">No server-to-client events.</p>
|
|
507
|
+
<article v-for="op in serverToClient" :key="op.id" class="bsr__card bsr__op">
|
|
508
|
+
<div class="bsr__op-head">
|
|
509
|
+
<span class="bsr__method" data-kind="event">EVENT</span>
|
|
510
|
+
<span class="bsr__op-target">{{ op.target }}</span>
|
|
511
|
+
<span class="bsr__hint">server → client</span>
|
|
512
|
+
</div>
|
|
513
|
+
<p v-if="op.summary" class="bsr__muted bsr__op-summary">{{ op.summary }}</p>
|
|
514
|
+
<pre v-if="op.messages.length > 0" class="bsr__result bsr__result--sample">{{ op.messages[0].example }}</pre>
|
|
515
|
+
</article>
|
|
516
|
+
</div>
|
|
517
|
+
</div>
|
|
518
|
+
</template>
|
|
519
|
+
</section>
|
|
520
|
+
</template>
|
|
521
|
+
|
|
522
|
+
<style scoped>
|
|
523
|
+
.bsr {
|
|
524
|
+
font-family: var(--scalar-font, system-ui, sans-serif);
|
|
525
|
+
color: var(--scalar-color-1, inherit);
|
|
526
|
+
font-size: var(--scalar-font-size-3, 0.9rem);
|
|
527
|
+
/* Render as its own Scalar-style section: a clear top divider, generous breathing room above,
|
|
528
|
+
and horizontal insets so it lines up with the operations/models instead of sitting flush
|
|
529
|
+
against the viewport edge. `--bsr-content-padding` lets hosts fine-tune the inset. */
|
|
530
|
+
padding: 2.5rem var(--bsr-content-padding, var(--scalar-content-padding, 24px)) 3rem;
|
|
531
|
+
margin-top: 2rem;
|
|
532
|
+
border-top: 1px solid var(--scalar-border-color, #e3e3e3);
|
|
533
|
+
max-width: var(--bsr-content-max-width, none);
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
.bsr__head {
|
|
537
|
+
display: flex;
|
|
538
|
+
align-items: center;
|
|
539
|
+
gap: 0.75rem;
|
|
540
|
+
margin-bottom: 1.25rem;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
/* Two-column layout on wide screens (methods | events + log); single column when narrow.
|
|
544
|
+
The connection bar stays full width above this grid. */
|
|
545
|
+
.bsr__grid {
|
|
546
|
+
display: grid;
|
|
547
|
+
grid-template-columns: 1fr;
|
|
548
|
+
gap: 0 2rem;
|
|
549
|
+
align-items: start;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
.bsr__col {
|
|
553
|
+
min-width: 0;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
@media (min-width: 60rem) {
|
|
557
|
+
.bsr__grid {
|
|
558
|
+
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
.bsr__title {
|
|
563
|
+
margin: 0;
|
|
564
|
+
font-size: 1.25rem;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
.bsr__pill {
|
|
568
|
+
font-size: 0.7rem;
|
|
569
|
+
text-transform: uppercase;
|
|
570
|
+
letter-spacing: 0.03em;
|
|
571
|
+
padding: 0.15rem 0.5rem;
|
|
572
|
+
border-radius: 999px;
|
|
573
|
+
background: var(--scalar-background-2, #eee);
|
|
574
|
+
color: var(--scalar-color-2, #555);
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
.bsr__pill[data-state='connected'] {
|
|
578
|
+
background: color-mix(in srgb, #2e7d32 18%, transparent);
|
|
579
|
+
color: #2e7d32;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
.bsr__card {
|
|
583
|
+
border: 1px solid var(--scalar-border-color, #e3e3e3);
|
|
584
|
+
border-radius: var(--scalar-radius, 8px);
|
|
585
|
+
background: var(--scalar-background-1, transparent);
|
|
586
|
+
padding: 0.85rem;
|
|
587
|
+
margin-bottom: 0.85rem;
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
.bsr__conn-row {
|
|
591
|
+
display: flex;
|
|
592
|
+
flex-wrap: wrap;
|
|
593
|
+
gap: 0.75rem;
|
|
594
|
+
align-items: flex-end;
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
.bsr__conn-row--foot {
|
|
598
|
+
margin-top: 0.75rem;
|
|
599
|
+
align-items: center;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
.bsr__field {
|
|
603
|
+
display: flex;
|
|
604
|
+
flex-direction: column;
|
|
605
|
+
gap: 0.25rem;
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
.bsr__field > span {
|
|
609
|
+
font-size: 0.72rem;
|
|
610
|
+
text-transform: uppercase;
|
|
611
|
+
letter-spacing: 0.03em;
|
|
612
|
+
color: var(--scalar-color-3, #777);
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
.bsr__field--grow {
|
|
616
|
+
flex: 1;
|
|
617
|
+
min-width: 12rem;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
.bsr__field input,
|
|
621
|
+
.bsr__field select {
|
|
622
|
+
padding: 0.35rem 0.5rem;
|
|
623
|
+
border: 1px solid var(--scalar-border-color, #ddd);
|
|
624
|
+
border-radius: var(--scalar-radius, 6px);
|
|
625
|
+
background: var(--scalar-background-2, #fff);
|
|
626
|
+
color: inherit;
|
|
627
|
+
font: inherit;
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
.bsr__section {
|
|
631
|
+
margin: 1.25rem 0 0.5rem;
|
|
632
|
+
font-size: 0.95rem;
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
.bsr__op-head {
|
|
636
|
+
display: flex;
|
|
637
|
+
align-items: center;
|
|
638
|
+
gap: 0.6rem;
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
.bsr__op-head--spaced {
|
|
642
|
+
margin-top: 0.85rem;
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
.bsr__code-head {
|
|
646
|
+
display: flex;
|
|
647
|
+
align-items: center;
|
|
648
|
+
justify-content: space-between;
|
|
649
|
+
margin-top: 0.6rem;
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
.bsr__link {
|
|
653
|
+
font: inherit;
|
|
654
|
+
font-size: 0.72rem;
|
|
655
|
+
background: none;
|
|
656
|
+
border: 0;
|
|
657
|
+
padding: 0;
|
|
658
|
+
cursor: pointer;
|
|
659
|
+
color: var(--scalar-color-blue, #2563eb);
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
.bsr__link:disabled {
|
|
663
|
+
opacity: 0.5;
|
|
664
|
+
cursor: not-allowed;
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
.bsr__result--sample {
|
|
668
|
+
border-left-color: var(--scalar-color-3, #888);
|
|
669
|
+
margin-top: 0.6rem;
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
.bsr__method {
|
|
673
|
+
font-family: var(--scalar-font-code, monospace);
|
|
674
|
+
font-size: 0.7rem;
|
|
675
|
+
font-weight: 700;
|
|
676
|
+
letter-spacing: 0.03em;
|
|
677
|
+
padding: 0.15rem 0.45rem;
|
|
678
|
+
border-radius: var(--scalar-radius, 5px);
|
|
679
|
+
background: color-mix(in srgb, var(--scalar-color-blue, #2563eb) 16%, transparent);
|
|
680
|
+
color: var(--scalar-color-blue, #2563eb);
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
.bsr__method[data-kind='send'] {
|
|
684
|
+
background: color-mix(in srgb, var(--scalar-color-orange, #d97706) 16%, transparent);
|
|
685
|
+
color: var(--scalar-color-orange, #d97706);
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
.bsr__method[data-kind='event'] {
|
|
689
|
+
background: color-mix(in srgb, var(--scalar-color-green, #16a34a) 16%, transparent);
|
|
690
|
+
color: var(--scalar-color-green, #16a34a);
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
.bsr__op-target {
|
|
694
|
+
font-family: var(--scalar-font-code, monospace);
|
|
695
|
+
font-weight: 600;
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
.bsr__op-summary {
|
|
699
|
+
margin: 0.4rem 0 0;
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
.bsr__code {
|
|
703
|
+
width: 100%;
|
|
704
|
+
margin-top: 0.6rem;
|
|
705
|
+
padding: 0.6rem;
|
|
706
|
+
border: 1px solid var(--scalar-border-color, #ddd);
|
|
707
|
+
border-radius: var(--scalar-radius, 6px);
|
|
708
|
+
background: var(--scalar-background-2, #1e1e1e);
|
|
709
|
+
color: inherit;
|
|
710
|
+
font-family: var(--scalar-font-code, monospace);
|
|
711
|
+
font-size: 0.82rem;
|
|
712
|
+
resize: vertical;
|
|
713
|
+
box-sizing: border-box;
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
.bsr__op-foot {
|
|
717
|
+
display: flex;
|
|
718
|
+
align-items: center;
|
|
719
|
+
justify-content: space-between;
|
|
720
|
+
margin-top: 0.6rem;
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
.bsr__hint {
|
|
724
|
+
font-size: 0.72rem;
|
|
725
|
+
color: var(--scalar-color-3, #888);
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
.bsr__url {
|
|
729
|
+
flex: 1;
|
|
730
|
+
min-width: 10rem;
|
|
731
|
+
font-family: var(--scalar-font-code, monospace);
|
|
732
|
+
font-size: 0.8rem;
|
|
733
|
+
color: var(--scalar-color-2, #555);
|
|
734
|
+
overflow-wrap: anywhere;
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
.bsr__btn {
|
|
738
|
+
font: inherit;
|
|
739
|
+
font-weight: 600;
|
|
740
|
+
padding: 0.4rem 0.8rem;
|
|
741
|
+
border-radius: var(--scalar-radius, 6px);
|
|
742
|
+
border: 1px solid var(--scalar-border-color, #ccc);
|
|
743
|
+
background: var(--scalar-background-2, #f4f4f4);
|
|
744
|
+
color: inherit;
|
|
745
|
+
cursor: pointer;
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
.bsr__btn--primary {
|
|
749
|
+
background: var(--scalar-button-1, #111);
|
|
750
|
+
color: var(--scalar-button-1-color, #fff);
|
|
751
|
+
border-color: transparent;
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
.bsr__btn:disabled {
|
|
755
|
+
opacity: 0.5;
|
|
756
|
+
cursor: not-allowed;
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
.bsr__result {
|
|
760
|
+
margin: 0.6rem 0 0;
|
|
761
|
+
padding: 0.6rem;
|
|
762
|
+
border-radius: var(--scalar-radius, 6px);
|
|
763
|
+
background: var(--scalar-background-2, #f6f6f6);
|
|
764
|
+
border-left: 3px solid var(--scalar-color-green, #16a34a);
|
|
765
|
+
font-family: var(--scalar-font-code, monospace);
|
|
766
|
+
font-size: 0.8rem;
|
|
767
|
+
white-space: pre-wrap;
|
|
768
|
+
overflow-wrap: anywhere;
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
.bsr__result[data-ok='false'] {
|
|
772
|
+
border-left-color: var(--scalar-color-red, #dc2626);
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
.bsr__log {
|
|
776
|
+
list-style: none;
|
|
777
|
+
margin: 0;
|
|
778
|
+
padding: 0.5rem;
|
|
779
|
+
max-height: 16rem;
|
|
780
|
+
overflow: auto;
|
|
781
|
+
border: 1px solid var(--scalar-border-color, #e3e3e3);
|
|
782
|
+
border-radius: var(--scalar-radius, 8px);
|
|
783
|
+
background: var(--scalar-background-1, transparent);
|
|
784
|
+
font-family: var(--scalar-font-code, monospace);
|
|
785
|
+
font-size: 0.78rem;
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
.bsr__log li {
|
|
789
|
+
display: flex;
|
|
790
|
+
gap: 0.5rem;
|
|
791
|
+
padding: 0.12rem 0;
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
.bsr__time {
|
|
795
|
+
opacity: 0.55;
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
.bsr__badge {
|
|
799
|
+
text-transform: uppercase;
|
|
800
|
+
font-size: 0.62rem;
|
|
801
|
+
align-self: center;
|
|
802
|
+
border-radius: 3px;
|
|
803
|
+
padding: 0 0.25rem;
|
|
804
|
+
background: var(--scalar-background-2, #eee);
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
.bsr__log-in .bsr__badge {
|
|
808
|
+
background: color-mix(in srgb, var(--scalar-color-blue, #2563eb) 20%, transparent);
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
.bsr__log-out .bsr__badge {
|
|
812
|
+
background: color-mix(in srgb, var(--scalar-color-orange, #d97706) 20%, transparent);
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
.bsr__muted {
|
|
816
|
+
color: var(--scalar-color-3, #888);
|
|
817
|
+
}
|
|
818
|
+
</style>
|