Skip to content

Commit a7d7c16

Browse files
LemonNekoGHgemini-code-assist[bot]autofix-ci[bot]
authored
feat(stage-*): devtool for websocket (#932)
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
1 parent a7d5793 commit a7d7c16

File tree

10 files changed

+282
-12
lines changed

10 files changed

+282
-12
lines changed

apps/stage-pocket/src/pages/settings/system/developer.vue

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,12 @@ const menu = computed(() => [
8080
icon: 'i-solar:sledgehammer-bold-duotone',
8181
to: '/devtools/notifications',
8282
},
83+
{
84+
title: 'WebSocket Inspector',
85+
description: 'Inspect raw WebSocket traffic',
86+
icon: 'i-solar:transfer-horizontal-bold-duotone',
87+
to: '/devtools/websocket-inspector',
88+
},
8389
])
8490
</script>
8591

apps/stage-tamagotchi/src/renderer/pages/settings/system/developer.vue

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,12 @@ const menu = computed(() => [
5959
icon: 'i-solar:chart-bold-duotone',
6060
to: '/devtools/beat-sync',
6161
},
62+
{
63+
title: 'WebSocket Inspector',
64+
description: 'Inspect raw WebSocket traffic',
65+
icon: 'i-solar:transfer-horizontal-bold-duotone',
66+
to: '/devtools/websocket-inspector',
67+
},
6268
])
6369
6470
const openDevTools = useElectronEventaInvoke(electronOpenMainDevtools)

apps/stage-web/src/pages/settings/system/developer.vue

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,12 @@ const menu = computed(() => [
6868
icon: 'i-solar:chat-square-call-bold-duotone',
6969
to: '/devtools/context-flow',
7070
},
71+
{
72+
title: 'WebSocket Inspector',
73+
description: 'Inspect raw WebSocket traffic',
74+
icon: 'i-solar:transfer-horizontal-bold-duotone',
75+
to: '/devtools/websocket-inspector',
76+
},
7177
{
7278
title: t('settings.pages.system.sections.section.developer.sections.section.use-magic-keys.title'),
7379
description: t('settings.pages.system.sections.section.developer.sections.section.use-magic-keys.description'),

packages/server-sdk/src/client.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ export interface ClientOptions<C = undefined> {
3030
autoConnect?: boolean
3131
autoReconnect?: boolean
3232
maxReconnectAttempts?: number
33+
onAnyMessage?: (data: WebSocketEvent<C>) => void
34+
onAnySend?: (data: WebSocketEvent<C>) => void
3335
}
3436

3537
function createInstanceId() {
@@ -60,6 +62,8 @@ export class Client<C = undefined> {
6062

6163
this.opts = {
6264
url: 'ws://localhost:6121/ws',
65+
onAnyMessage: () => {},
66+
onAnySend: () => {},
6367
possibleEvents: [],
6468
onError: () => {},
6569
onClose: () => {},
@@ -246,6 +250,7 @@ export class Client<C = undefined> {
246250
private async handleMessage(event: MessageEvent) {
247251
try {
248252
const data = JSON.parse(event.data as string) as WebSocketEvent<C>
253+
this.opts.onAnyMessage?.(data)
249254
const listeners = this.eventListeners.get(data.type)
250255
if (!listeners?.size) {
251256
return
@@ -299,11 +304,15 @@ export class Client<C = undefined> {
299304

300305
send(data: WebSocketEventOptionalSource<C>): void {
301306
if (this.websocket && this.connected) {
302-
this.websocket.send(JSON.stringify({
307+
const payload = {
303308
source: this.opts.name as WebSocketEventSource | string,
304309
metadata: { source: this.identity },
305310
...data,
306-
} as WebSocketEvent<C>))
311+
} as WebSocketEvent<C>
312+
313+
this.opts.onAnySend?.(payload)
314+
315+
this.websocket.send(JSON.stringify(payload))
307316
}
308317
}
309318

packages/stage-layouts/src/layouts/settings.vue

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,10 @@ const routeHeaderMetadataMap = computed(() => {
114114
subtitle: t('tamagotchi.settings.devtools.title'),
115115
title: t('tamagotchi.settings.devtools.pages.context-flow.title'),
116116
},
117+
'/devtools/websocket-inspector': {
118+
subtitle: t('tamagotchi.settings.devtools.title'),
119+
title: 'WebSocket Inspector',
120+
},
117121
'/devtools/performance-visualizer': {
118122
subtitle: t('settings.title'),
119123
title: t('settings.pages.system.sections.section.developer.sections.section.performance-visualizer.title'),
Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
<script setup lang="ts">
2+
import { useWebSocketInspectorStore } from '@proj-airi/stage-ui/stores/devtools/websocket-inspector'
3+
import { Button, FieldCheckbox, Input } from '@proj-airi/ui'
4+
import { computed, nextTick, ref, watch } from 'vue'
5+
6+
const store = useWebSocketInspectorStore()
7+
8+
const filter = ref('')
9+
const showIncoming = ref(true)
10+
const showOutgoing = ref(true)
11+
const showHeartbeats = ref(true)
12+
const streamContainer = ref<HTMLDivElement>()
13+
const showingDetails = ref<string>('')
14+
const filteredHistory = computed(() => {
15+
return store.history.filter((item) => {
16+
if (!showIncoming.value && item.direction === 'incoming')
17+
return false
18+
if (!showOutgoing.value && item.direction === 'outgoing')
19+
return false
20+
if (!showHeartbeats.value && item.event.type === 'transport:connection:heartbeat')
21+
return false
22+
if (filter.value && !JSON.stringify(item.event).toLowerCase().includes(filter.value.toLowerCase()))
23+
return false
24+
return true
25+
})
26+
})
27+
28+
function formatTime(ts: number) {
29+
return new Date(ts).toLocaleTimeString()
30+
}
31+
32+
async function scrollToTop() {
33+
await nextTick()
34+
if (streamContainer.value)
35+
streamContainer.value.scrollTop = 0
36+
}
37+
38+
watch(() => filteredHistory.value.length, scrollToTop)
39+
40+
const directionBadgeClassMap: Record<'incoming' | 'outgoing', string[]> = {
41+
incoming: [
42+
'bg-pink-100 dark:bg-pink-900',
43+
'text-pink-600',
44+
'dark:text-pink-300',
45+
'border-pink-300 dark:border-pink-900',
46+
],
47+
outgoing: [
48+
'bg-blue-100 dark:bg-blue-900',
49+
'text-blue-600',
50+
'dark:text-blue-300',
51+
'border-blue-300 dark:border-blue-900',
52+
],
53+
}
54+
55+
const directionIconClassMap: Record<'incoming' | 'outgoing', string> = {
56+
incoming: 'i-solar:arrow-down-linear',
57+
outgoing: 'i-solar:arrow-up-linear',
58+
}
59+
60+
function directionBadgeClasses(direction: 'incoming' | 'outgoing') {
61+
return directionBadgeClassMap[direction]
62+
}
63+
64+
function directionIconClass(direction: 'incoming' | 'outgoing') {
65+
return directionIconClassMap[direction]
66+
}
67+
68+
const cardClassMap: Record<'incoming' | 'outgoing', string[]> = {
69+
incoming: [
70+
'bg-pink-50',
71+
'dark:bg-pink-950',
72+
'border-pink-300',
73+
'text-pink-700',
74+
'dark:border-pink-800',
75+
'dark:text-pink-100',
76+
],
77+
outgoing: [
78+
'bg-blue-50',
79+
'dark:bg-blue-950',
80+
'border-blue-300',
81+
'text-blue-700',
82+
'dark:border-blue-800',
83+
'dark:text-blue-100',
84+
],
85+
}
86+
87+
function cardClasses(direction: 'incoming' | 'outgoing') {
88+
return cardClassMap[direction]
89+
}
90+
91+
const payloadClassMap: Record<'incoming' | 'outgoing', string[]> = {
92+
incoming: [
93+
'bg-pink-100',
94+
'border-pink-300 border-1 border-solid',
95+
'dark:bg-pink-900',
96+
'dark:border-pink-700',
97+
],
98+
outgoing: [
99+
'bg-blue-100',
100+
'border-blue-300 border-1 border-solid',
101+
'dark:bg-blue-900',
102+
'dark:border-blue-700',
103+
],
104+
}
105+
106+
function payloadClasses(direction: 'incoming' | 'outgoing') {
107+
return payloadClassMap[direction]
108+
}
109+
</script>
110+
111+
<template>
112+
<div class="h-full flex flex-col gap-4 overflow-hidden p-4">
113+
<!-- Header / Filters -->
114+
<div class="flex flex-col gap-4 rounded-xl bg-neutral-50 p-4 dark:bg-[rgba(0,0,0,0.3)]">
115+
<div class="flex items-center gap-2">
116+
<FieldCheckbox v-model="showIncoming" label="Incoming" />
117+
<FieldCheckbox v-model="showOutgoing" label="Outgoing" />
118+
<FieldCheckbox v-model="showHeartbeats" label="Heartbeats" />
119+
</div>
120+
121+
<div class="flex gap-2">
122+
<Input
123+
v-model="filter"
124+
placeholder="Filter payload..."
125+
class="w-64"
126+
/>
127+
<Button
128+
label="Clear"
129+
icon="i-solar:trash-bin-trash-bold-duotone"
130+
size="sm"
131+
variant="ghost"
132+
@click="store.clear()"
133+
/>
134+
<div class="flex flex-shrink-0 items-center text-xs">
135+
{{ filteredHistory.length }} / {{ store.history.length }}
136+
</div>
137+
</div>
138+
</div>
139+
140+
<!-- Stream -->
141+
<div
142+
ref="streamContainer"
143+
class="flex-1 overflow-y-auto rounded-xl bg-white/70 dark:bg-neutral-950/50"
144+
>
145+
<div
146+
v-if="filteredHistory.length === 0"
147+
class="h-full w-full flex justify-center p-3 text-sm"
148+
>
149+
No messages found.
150+
</div>
151+
<div v-else class="grid gap-3">
152+
<div
153+
v-for="item in filteredHistory"
154+
:key="item.id"
155+
class="border rounded-xl p-4 transition-colors"
156+
:class="cardClasses(item.direction)"
157+
>
158+
<div class="flex items-start justify-between gap-3">
159+
<div class="flex flex-wrap items-center gap-2 text-sm">
160+
<span :class="['rounded-full', 'border', 'px-2', 'py-0.5', 'text-xs', 'flex', 'items-center', 'justify-center', ...directionBadgeClasses(item.direction)]">
161+
<span :class="['size-3.5', directionIconClass(item.direction)]" :aria-label="item.direction" />
162+
<span class="ml-1 font-bold tracking-wider uppercase">{{ item.direction }}</span>
163+
</span>
164+
<span class="font-semibold">
165+
{{ item.event.type }}
166+
</span>
167+
</div>
168+
<span class="text-sm font-mono">
169+
{{ formatTime(item.timestamp) }}
170+
</span>
171+
</div>
172+
173+
<details class="group mt-2" :open="showingDetails === item.id">
174+
<summary class="cursor-pointer select-none text-sm font-medium" @click="showingDetails = showingDetails === item.id ? '' : item.id">
175+
Payload
176+
</summary>
177+
<pre
178+
class="mt-2 w-full overflow-auto whitespace-pre-wrap rounded-lg p-3 text-sm"
179+
:class="payloadClasses(item.direction)"
180+
>{{ JSON.stringify(item.event, null, 2) }}</pre>
181+
</details>
182+
</div>
183+
</div>
184+
</div>
185+
</div>
186+
</template>
187+
188+
<route lang="yaml">
189+
meta:
190+
layout: settings
191+
</route>
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import type { WebSocketEvent } from '@proj-airi/server-sdk'
2+
3+
import { nanoid } from 'nanoid'
4+
import { defineStore } from 'pinia'
5+
import { ref } from 'vue'
6+
7+
export interface WebSocketHistoryItem {
8+
id: string
9+
timestamp: number
10+
direction: 'incoming' | 'outgoing'
11+
event: WebSocketEvent
12+
}
13+
14+
export const useWebSocketInspectorStore = defineStore('devtools:websocket-inspector', () => {
15+
const history = ref<WebSocketHistoryItem[]>([])
16+
const isEnabled = ref(true)
17+
const maxHistory = ref(1000)
18+
19+
function add(direction: 'incoming' | 'outgoing', event: WebSocketEvent) {
20+
if (!isEnabled.value)
21+
return
22+
23+
history.value.unshift({
24+
id: nanoid(),
25+
timestamp: Date.now(),
26+
direction,
27+
event,
28+
})
29+
30+
if (history.value.length > maxHistory.value) {
31+
history.value.pop()
32+
}
33+
}
34+
35+
function clear() {
36+
history.value = []
37+
}
38+
39+
return {
40+
history,
41+
isEnabled,
42+
maxHistory,
43+
add,
44+
clear,
45+
}
46+
})

packages/stage-ui/src/stores/mods/api/channel-server.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import { nanoid } from 'nanoid'
66
import { defineStore } from 'pinia'
77
import { ref } from 'vue'
88

9+
import { useWebSocketInspectorStore } from '../../devtools/websocket-inspector'
10+
911
export const useModsServerChannelStore = defineStore('mods:channels:proj-airi:server', () => {
1012
const connected = ref(false)
1113
const client = ref<Client>()
@@ -48,6 +50,12 @@ export const useModsServerChannelStore = defineStore('mods:channels:proj-airi:se
4850
url: import.meta.env.VITE_AIRI_WS_URL || 'ws://localhost:6121/ws',
4951
token: options?.token,
5052
possibleEvents,
53+
onAnyMessage: (event) => {
54+
useWebSocketInspectorStore().add('incoming', event)
55+
},
56+
onAnySend: (event) => {
57+
useWebSocketInspectorStore().add('outgoing', event)
58+
},
5159
onError: (error) => {
5260
connected.value = false
5361
initializing.value = null

packages/stage-ui/src/stores/mods/api/context-bridge.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { isStageTamagotchi, isStageWeb } from '@proj-airi/stage-shared'
66
import { useBroadcastChannel } from '@vueuse/core'
77
import { Mutex } from 'es-toolkit'
88
import { defineStore } from 'pinia'
9-
import { ref, watch, toRaw } from 'vue'
9+
import { ref, toRaw, watch } from 'vue'
1010

1111
import { CHAT_STREAM_CHANNEL_NAME, CONTEXT_CHANNEL_NAME, useChatStore } from '../../chat'
1212
import { useModsServerChannelStore } from './channel-server'

pnpm-lock.yaml

Lines changed: 3 additions & 9 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)