Skip to content

Commit d49688f

Browse files
committed
docs: integrate demo
1 parent 37b653e commit d49688f

File tree

6 files changed

+309
-0
lines changed

6 files changed

+309
-0
lines changed

docs/app/app.vue

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,13 @@
11
<script setup lang="ts">
2+
import { withoutLeadingSlash } from 'ufo'
3+
import { normalizeKey } from 'unstorage'
4+
import {
5+
useParentElement,
6+
useElementSize,
7+
useMouse,
8+
watchThrottled,
9+
} from '@vueuse/core'
10+
211
const { seo } = useAppConfig()
312
413
const { data: navigation } = await useAsyncData('navigation', () => queryCollectionNavigation('docs'))
@@ -25,13 +34,61 @@ useSeoMeta({
2534
})
2635
2736
provide('navigation', navigation)
37+
38+
const topics = computed(() => {
39+
const r = useRoute().path
40+
const _route = r !== '/'
41+
? normalizeKey(withoutLeadingSlash(r))
42+
: 'index'
43+
44+
return [`page:${_route}`]
45+
})
46+
47+
type WSStates = {
48+
[key: string]: {
49+
x: number
50+
y: number
51+
peerId?: string
52+
}[]
53+
} & {
54+
_internal: {
55+
connectionId: string
56+
}
57+
session: {
58+
users: number
59+
}
60+
}
61+
62+
const states = useWSState<WSStates>(topics)
63+
64+
const el = useParentElement()
65+
const { width, height } = useElementSize(el)
66+
67+
onMounted(() => {
68+
const { send } = useWS<WSStates>(topics)
69+
const { x, y, sourceType } = useMouse({ touch: false })
70+
71+
watchThrottled([x, y], () => {
72+
if (sourceType.value === 'mouse') {
73+
send('publish', topics.value[0]!, {
74+
x: Math.round(x.value / width.value * 1000) / 1000,
75+
y: Math.round(y.value / height.value * 1000) / 1000,
76+
})
77+
}
78+
}, { throttle: 33 }) // 30fps
79+
})
2880
</script>
2981

3082
<template>
3183
<UApp>
3284
<NuxtLoadingIndicator />
3385

3486
<AppHeader />
87+
<DevOnly>
88+
<pre>
89+
{{ states }}
90+
</pre>
91+
</DevOnly>
3592

3693
<UMain>
3794
<NuxtLayout>
@@ -47,5 +104,36 @@ provide('navigation', navigation)
47104
:navigation="navigation"
48105
/>
49106
</ClientOnly>
107+
108+
<ClientOnly>
109+
<span
110+
v-for="c of states[topics[0]!]"
111+
:key="c.peerId"
112+
class="fixed z-50 group/cursor"
113+
:style="{
114+
left: `${c.x * width}px`,
115+
top: `${c.y * height}px`,
116+
pointerEvents: 'none',
117+
transform: 'translate(-50%, -50%)',
118+
transition: 'left 0.1s ease-out, top 0.1s ease-out',
119+
}"
120+
>
121+
<div v-if="c.peerId !== states['_internal'].connectionId" class="relative">
122+
<UIcon
123+
name="i-lucide-mouse-pointer-2"
124+
/>
125+
</div>
126+
</span>
127+
128+
<UBadge
129+
class="fixed bottom-4 right-4 rounded-full px-3 py-2"
130+
:icon=" states['session']?.users > 1 ? 'i-lucide-users' : 'i-lucide-user'"
131+
size="lg"
132+
color="primary"
133+
variant="outline"
134+
>
135+
{{ states['session']?.users || 0 }}
136+
</UBadge>
137+
</ClientOnly>
50138
</UApp>
51139
</template>

docs/content/2.usage/2.defineWSHandler.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,3 +79,5 @@ This is also used under the hood by [validation utilities](/usage/validation).
7979
## Authentication
8080

8181
You can easily validate user's sessions with modules like `nuxt-auth-utils` as described in [the documentation](https://github.com/atinux/nuxt-auth-utils?tab=readme-ov-file#websocket-support).
82+
83+
Please refer to the [`reactive-ws` demo](https://github.com/sandros94/nuxt-reactive-ws) for a complete example.

docs/nuxt.config.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,4 +85,12 @@ export default defineNuxtConfig({
8585
plausible: {
8686
apiHost: 'https://plausible.digitoolmedia.com',
8787
},
88+
89+
ws: {
90+
route: '/_ws',
91+
topics: {
92+
internals: ['_internal', 'notifications'],
93+
defaults: ['session'],
94+
},
95+
},
8896
})

docs/server/routes/_ws.ts

Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
import type { PublicRuntimeConfig } from 'nuxt/schema'
2+
import type { NitroApp } from 'nitropack/types'
3+
import { consola } from 'consola'
4+
import * as v from 'valibot'
5+
6+
interface Cursor {
7+
x: number
8+
y: number
9+
peerId: string
10+
avatar_url?: string
11+
login?: string
12+
}
13+
14+
const logger = consola.create({}).withTag('WS')
15+
const topicPageSchema = v.pipe(v.string(), v.trim(), v.nonEmpty(), v.startsWith('page:'))
16+
17+
function myPerformantWS() {
18+
let nitroApp: NitroApp
19+
let config: PublicRuntimeConfig['ws']
20+
21+
function getConfig() {
22+
if (config) return config
23+
return config = useRuntimeConfig().public.ws as PublicRuntimeConfig['ws']
24+
}
25+
function getNitroApp() {
26+
if (nitroApp) return nitroApp
27+
return nitroApp = useNitroApp()
28+
}
29+
30+
return defineWebSocketHandler({
31+
async open(peer) {
32+
const config = getConfig()
33+
const nitroHooks = getNitroApp().hooks
34+
35+
// Automatically subscribe to internal and default topics
36+
config.topics!.internals!.forEach(topic => peer.subscribe(topic))
37+
config.topics!.defaults!.forEach(topic => peer.subscribe(topic))
38+
39+
// Setup notification hooks
40+
nitroHooks.hook('ws:publish', (...messages) => {
41+
for (const { topic, payload } of messages) {
42+
if (!topic || !payload) continue
43+
peer.publish(topic, JSON.stringify({ topic, payload }), { compress: true })
44+
}
45+
})
46+
47+
logger.log('New connection:', peer.id)
48+
// Update peer with data from storage
49+
peer.topics.forEach(async (topic) => {
50+
const payload = await useStorage('ws').getItem(topic)
51+
if (payload)
52+
peer.send(JSON.stringify({ topic, payload }), { compress: true })
53+
})
54+
55+
// Send `_internal` communications
56+
peer.send(JSON.stringify({
57+
topic: '_internal',
58+
payload: {
59+
connectionId: peer.id,
60+
},
61+
}), { compress: true })
62+
63+
// Update everyone's session metadata
64+
const payload = JSON.stringify({ topic: 'session', payload: { users: peer.peers.size } })
65+
peer.send(payload, { compress: true })
66+
peer.publish('session', payload, { compress: true })
67+
},
68+
69+
async message(peer, message) {
70+
// Validate channel subscription/unsubscription or cursor update
71+
const validated = await wsSafeValidateMessage(
72+
v.union([
73+
topicSubUnsub(topicPageSchema),
74+
topicPublish(
75+
v.object({ x: v.number(), y: v.number() }),
76+
topicPageSchema,
77+
),
78+
]),
79+
message,
80+
)
81+
// If validation failed, fail silently
82+
if (validated.issues) return
83+
const parsedMessage = validated.value
84+
85+
const storage = useStorage('ws')
86+
87+
if (parsedMessage.type === 'subscribe') {
88+
peer.subscribe(parsedMessage.topic)
89+
const cursors = await storage.getItem<Cursor[]>(parsedMessage.topic) || []
90+
91+
if (cursors)
92+
peer.send(JSON.stringify({
93+
topic: parsedMessage.topic,
94+
payload: cursors,
95+
}), { compress: true })
96+
97+
return
98+
}
99+
else if (parsedMessage.type === 'unsubscribe') {
100+
peer.unsubscribe(parsedMessage.topic)
101+
const cursors = await storage.getItem<Cursor[]>(parsedMessage.topic)
102+
103+
if (cursors) {
104+
// Remove this peer's cursor from the array
105+
const updatedCursors = cursors.filter(cursor => cursor.peerId !== peer.id)
106+
107+
// Update storage and notify other peers
108+
await storage.setItem(parsedMessage.topic, updatedCursors)
109+
peer.publish(parsedMessage.topic, JSON.stringify({
110+
topic: parsedMessage.topic,
111+
payload: updatedCursors,
112+
}), { compress: true })
113+
}
114+
115+
return
116+
}
117+
else if (parsedMessage.type === 'publish') {
118+
const cursors: Cursor[] = await storage.getItem<Cursor[]>(parsedMessage.topic) || []
119+
120+
// Find and update existing cursor or add new one
121+
const cursorIndex = cursors.findIndex(cursor => cursor.peerId === peer.id)
122+
const updatedCursor: Cursor = {
123+
x: parsedMessage.payload.x,
124+
y: parsedMessage.payload.y,
125+
peerId: peer.id,
126+
// TODO: Add avatar_url and login
127+
}
128+
129+
if (cursorIndex >= 0) {
130+
cursors[cursorIndex] = updatedCursor
131+
}
132+
else {
133+
cursors.push(updatedCursor)
134+
}
135+
136+
await storage.setItem(parsedMessage.topic, cursors)
137+
peer.publish(parsedMessage.topic, JSON.stringify({
138+
topic: parsedMessage.topic,
139+
payload: cursors,
140+
}), { compress: true })
141+
}
142+
},
143+
144+
async close(peer, details) {
145+
logger.log('Connection closed:', peer.id, details.code, details.reason)
146+
147+
peer.publish(
148+
'session',
149+
JSON.stringify({ topic: 'session', payload: { users: peer.peers.size } }),
150+
{ compress: true },
151+
)
152+
153+
// Remove the user from all active cursors
154+
const storage = useStorage('ws')
155+
156+
// Clean up cursor data from all topics this peer was subscribed to
157+
for (const topic of peer.topics) {
158+
if (topic.startsWith('page:')) {
159+
const cursors = await storage.getItem<Cursor[]>(topic)
160+
if (cursors) {
161+
const updatedCursors = cursors.filter(cursor => cursor.peerId !== peer.id)
162+
await storage.setItem(topic, updatedCursors)
163+
164+
// Notify remaining peers
165+
peer.publish(topic, JSON.stringify({
166+
topic,
167+
payload: updatedCursors,
168+
}), { compress: true })
169+
}
170+
}
171+
}
172+
},
173+
174+
error(peer, error) {
175+
logger.error('Peer', peer.id, 'connection error:', error)
176+
},
177+
})
178+
}
179+
180+
export default myPerformantWS()

docs/shared/utils/schema.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import * as v from 'valibot'
2+
3+
export function topicSubUnsub<
4+
TInput,
5+
TOutput,
6+
TIssue extends v.BaseIssue<TInput>,
7+
TSchema extends v.BaseSchema<TInput, TOutput, TIssue>,
8+
>(topics?: TSchema) {
9+
return v.object({
10+
type: v.picklist(['subscribe', 'unsubscribe']),
11+
topic: !topics ? v.string() : topics,
12+
})
13+
}
14+
15+
export function topicPublish<
16+
PInput,
17+
POutput,
18+
PIssue extends v.BaseIssue<PInput>,
19+
PSchema extends v.BaseSchema<PInput, POutput, PIssue>,
20+
TInput,
21+
TOutput,
22+
TIssue extends v.BaseIssue<TInput>,
23+
TSchema extends v.BaseSchema<TInput, TOutput, TIssue>,
24+
>(payload: PSchema, topics?: TSchema) {
25+
return v.object({
26+
type: v.literal('publish'),
27+
topic: !topics ? v.string() : topics,
28+
payload,
29+
})
30+
}

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
"dev:prepare": "nuxt-module-build build --stub && nuxt-module-build prepare && nuxi prepare playground",
2929
"docs": "nuxt dev docs",
3030
"docs:build": "nuxt build docs",
31+
"docs:prepare": "nuxt prepare docs",
3132
"docs:preview": "nuxt preview docs",
3233
"release": "pnpm lint && pnpm prepack && changelogen --release --push --publish",
3334
"lint": "eslint .",

0 commit comments

Comments
 (0)