From fb1b8a8d45f05cdda90736f58245c50dd940eb28 Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Tue, 29 Sep 2026 04:44:15 +0100 Subject: [PATCH] feat: add live cursors + touchpad page (#1977) --- app/app.vue | 2 +- app/pages/touchpad.vue | 416 +++++++++++++++++++++++++++++++++++++++ budget.json | 2 +- nuxt.config.ts | 1 + partykit/cursors.ts | 262 ++++++++++++++++++++++++ partykit/server.ts | 37 +++- test/unit/bundle.spec.ts | 2 +- 7 files changed, 717 insertions(+), 5 deletions(-) create mode 100644 app/pages/touchpad.vue create mode 100644 partykit/cursors.ts diff --git a/app/app.vue b/app/app.vue index a9616413..7db759f9 100644 --- a/app/app.vue +++ b/app/app.vue @@ -30,7 +30,7 @@ defineOgImage('DefaultImage', { tags: [], }) -const isAdmin = computed(() => route.path === '/admin' || route.path.startsWith('/admin/')) +const isAdmin = computed(() => route.path === '/admin' || route.path.startsWith('/admin/') || route.path === '/touchpad') // TODO: interactive components within server components const highlightIslands = ref(false) diff --git a/app/pages/touchpad.vue b/app/pages/touchpad.vue new file mode 100644 index 00000000..50b67a78 --- /dev/null +++ b/app/pages/touchpad.vue @@ -0,0 +1,416 @@ + + + + + + + diff --git a/budget.json b/budget.json index ede86610..9627e1a0 100644 --- a/budget.json +++ b/budget.json @@ -8,7 +8,7 @@ }, { "resourceType": "script", - "budget": 111 + "budget": 113 }, { "resourceType": "stylesheet", diff --git a/nuxt.config.ts b/nuxt.config.ts index d2f68cee..e1405335 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -192,6 +192,7 @@ export default defineNuxtConfig({ // redirects '/feed.xml': { redirect: '/rss.xml' }, '/work': { redirect: '/projects' }, + '/pad': { redirect: '/touchpad' }, '/thumbnail/**': { cache: { maxAge: 60 * 60 * 24 * 365 } }, '/blog/a-virtuous-cycle': { redirect: '/blog/virtuous-circle' }, '/blog/ai-writes-my-code': { redirect: 'https://www.youtube.com/watch?v=Zfs3BJZxKkc' }, diff --git a/partykit/cursors.ts b/partykit/cursors.ts new file mode 100644 index 00000000..18669bfd --- /dev/null +++ b/partykit/cursors.ts @@ -0,0 +1,262 @@ +import type * as Party from 'partykit/server' + +export interface Cursor { + id: string + name: string + color: string + x: number + y: number +} + +export interface Zone { + id: string + label: string + color: string + x: number + y: number + w: number + h: number +} + +export interface Scene { + title?: string + zones: Zone[] +} + +interface ConnState { + role: 'host' | 'phone' + id?: string +} + +const ADJECTIVES = ['Brave', 'Sleepy', 'Jolly', 'Sneaky', 'Fuzzy', 'Clever', 'Bouncy', 'Cosy', 'Zippy', 'Plucky', 'Witty', 'Sunny', 'Mighty', 'Curious', 'Dapper', 'Nimble', 'Gentle', 'Merry', 'Bold', 'Quirky'] +const NOUNS = ['Knedlík', 'Trdelník', 'Golem', 'Krtek', 'Pivo', 'Koláček', 'Rohlík', 'Vltava', 'Klobása', 'Švejk', 'Beaver', 'Otter', 'Lynx', 'Heron', 'Badger', 'Hedgehog', 'Stork', 'Marten', 'Owl', 'Carp'] +const COLORS = ['#ff5d8f', '#ffb347', '#ffe156', '#8ce99a', '#38d9a9', '#4dabf7', '#748ffc', '#b197fc', '#f783ac', '#ff8787', '#63e6be', '#74c0fc', '#ffa94d', '#d0bfff', '#a9e34b', '#66d9e8'] + +const BROADCAST_INTERVAL = 33 +const MAX_CURSORS = 500 + +const clamp = (n: unknown) => typeof n === 'number' && Number.isFinite(n) ? Math.min(1, Math.max(0, n)) : 0.5 + +export default class CursorsParty implements Party.Server { + cursors = new Map() + online = new Map>() + scene: Scene | null = null + pending = new Map() + timer: ReturnType | undefined + assigned = 0 + + constructor (readonly room: Party.Room) {} + + async onStart () { + this.scene = await this.room.storage.get('scene') ?? null + const stored = await this.room.storage.get('cursors') + for (const c of stored || []) this.cursors.set(c.id, c) + this.assigned = this.cursors.size + } + + onConnect (conn: Party.Connection, ctx: Party.ConnectionContext) { + const params = new URL(ctx.request.url).searchParams + const token = this.room.env.CURSORS_HOST_TOKEN as string | undefined + const role = params.get('role') === 'host' && (!token || params.get('token') === token) ? 'host' : 'phone' + conn.setState({ role }) + if (role === 'host') { + conn.send(JSON.stringify({ t: 'sync', cursors: this.onlineCursors() })) + conn.send(JSON.stringify({ t: 'scene', scene: this.scene })) + } + else { + conn.send(JSON.stringify({ t: 'scene', scene: this.scene })) + } + } + + onMessage (raw: string | ArrayBuffer | ArrayBufferView, conn: Party.Connection) { + if (typeof raw !== 'string' || raw.length > 20_000) return + let msg: Record + try { + msg = JSON.parse(raw) + } + catch { + return + } + if (!msg || typeof msg !== 'object') return + const state = conn.state + if (!state) return + + if (state.role === 'host') { + this.onHostMessage(msg) + return + } + + switch (msg.t) { + case 'hello': { + const id = typeof msg.id === 'string' && msg.id.length >= 16 && msg.id.length <= 64 ? msg.id : conn.id + if (state.id && state.id !== id) this.detach(state.id, conn.id) + conn.setState({ role: 'phone', id }) + let cursor = this.cursors.get(id) + if (!cursor) { + const n = this.assigned++ + cursor = { + id, + name: this.makeName(n), + color: COLORS[n % COLORS.length]!, + x: 0.5, + y: 0.5, + } + this.cursors.set(id, cursor) + this.evict() + this.persist() + } + if (typeof msg.name === 'string' && msg.name.trim()) { + cursor.name = msg.name.trim().slice(0, 32) + this.persist() + } + const conns = this.online.get(id) ?? new Set() + const wasOnline = conns.size > 0 + conns.add(conn.id) + this.online.set(id, conns) + conn.send(JSON.stringify({ t: 'you', ...cursor })) + if (!wasOnline) this.toHosts({ t: 'join', ...cursor }) + break + } + case 'p': { + const cursor = state.id && this.cursors.get(state.id) + if (!cursor) return + cursor.x = clamp(msg.x) + cursor.y = clamp(msg.y) + this.pending.set(cursor.id, { x: cursor.x, y: cursor.y }) + this.schedule() + break + } + case 'tap': { + const cursor = state.id && this.cursors.get(state.id) + if (!cursor) return + cursor.x = clamp(msg.x) + cursor.y = clamp(msg.y) + this.pending.delete(cursor.id) + this.toHosts({ t: 'tap', id: cursor.id, x: cursor.x, y: cursor.y }) + break + } + } + } + + onClose (conn: Party.Connection) { + const id = conn.state?.role === 'phone' ? conn.state.id : undefined + if (id) this.detach(id, conn.id) + } + + onError (conn: Party.Connection) { + this.onClose(conn) + } + + onHostMessage (msg: Record) { + switch (msg.t) { + case 'scene': { + this.scene = sanitiseScene(msg.scene) + this.room.storage.put('scene', this.scene) + const payload = JSON.stringify({ t: 'scene', scene: this.scene }) + this.room.broadcast(payload) + break + } + case 'result': + this.toPhones({ t: 'result', zoneId: typeof msg.zoneId === 'string' ? msg.zoneId : null }) + break + case 'reset': + for (const [id, cursor] of this.cursors) { + cursor.x = 0.5 + cursor.y = 0.5 + if (this.online.get(id)?.size) this.pending.set(id, { x: 0.5, y: 0.5 }) + } + this.schedule() + this.toPhones({ t: 'reset' }) + break + case 'clear': + // forget all names/colours, e.g. between rehearsal and the real talk + this.cursors.clear() + this.assigned = 0 + this.room.storage.delete('cursors') + for (const conn of this.room.getConnections()) { + if (conn.state?.role === 'phone') conn.close(4000, 'reset') + } + this.online.clear() + this.toHosts({ t: 'sync', cursors: [] }) + break + } + } + + detach (id: string, connId: string) { + const conns = this.online.get(id) + if (!conns) return + conns.delete(connId) + if (conns.size) return + this.online.delete(id) + this.pending.delete(id) + this.toHosts({ t: 'leave', id }) + this.persist() + } + + evict () { + for (const id of this.cursors.keys()) { + if (this.cursors.size <= MAX_CURSORS) return + if (!this.online.get(id)?.size) this.cursors.delete(id) + } + } + + schedule () { + if (this.timer) return + this.timer = setTimeout(() => { + this.timer = undefined + for (const [id, { x, y }] of this.pending) this.toHosts({ t: 'p', id, x, y }) + this.pending.clear() + }, BROADCAST_INTERVAL) + } + + onlineCursors () { + return [...this.online.keys()].map(id => this.cursors.get(id)).filter(Boolean) as Cursor[] + } + + makeName (n: number) { + const adj = ADJECTIVES[n % ADJECTIVES.length] + const noun = NOUNS[(n * 7 + Math.floor(n / ADJECTIVES.length)) % NOUNS.length] + return `${adj} ${noun}` + } + + persistTimer: ReturnType | undefined + persist () { + if (this.persistTimer) return + this.persistTimer = setTimeout(() => { + this.persistTimer = undefined + this.room.storage.put('cursors', [...this.cursors.values()]) + }, 1000) + } + + toHosts (msg: unknown) { + const payload = JSON.stringify(msg) + for (const conn of this.room.getConnections()) { + if (conn.state?.role === 'host') conn.send(payload) + } + } + + toPhones (msg: unknown) { + const payload = JSON.stringify(msg) + for (const conn of this.room.getConnections()) { + if (conn.state?.role === 'phone') conn.send(payload) + } + } +} + +function sanitiseScene (scene: unknown): Scene | null { + if (!scene || typeof scene !== 'object') return null + const s = scene as Record + const zones = Array.isArray(s.zones) ? s.zones : [] + return { + title: typeof s.title === 'string' ? s.title.slice(0, 200) : undefined, + zones: zones.slice(0, 16).map((z: Record, i: number) => ({ + id: String(z?.id ?? i), + label: String(z?.label ?? ''), + color: typeof z?.color === 'string' ? z.color : COLORS[i % COLORS.length]!, + x: clamp(z?.x), + y: clamp(z?.y), + w: clamp(z?.w), + h: clamp(z?.h), + })), + } +} diff --git a/partykit/server.ts b/partykit/server.ts index 54b631f1..42ec9c4c 100644 --- a/partykit/server.ts +++ b/partykit/server.ts @@ -1,8 +1,27 @@ import type { PartyKitServer, Room } from 'partykit/server' -import { isValidEmoji } from '#shared/utils/emoji' +import { isValidEmoji } from '../shared/utils/emoji' +import CursorsParty from './cursors' + +const cursorParties = new Map>() +function getCursors (room: Room) { + let party = cursorParties.get(room.id) + if (!party) { + const instance = new CursorsParty(room) + party = instance.onStart().then(() => instance, err => { + cursorParties.delete(room.id) + throw err + }) + cursorParties.set(room.id, party) + } + return party +} export default { - async onConnect (ws, party) { + async onConnect (ws, party, ctx) { + if (party.id === 'cursors') { + return getCursors(party).then(c => c.onConnect(ws, ctx)) + } + // 1. send current vote count if applicable ws.send(`count:${await getCount(party)}`) @@ -63,6 +82,10 @@ export default { return new Response('Invalid request', { status: 422 }) }, async onMessage (message, ws, party) { + if (party.id === 'cursors') { + return getCursors(party).then(c => c.onMessage(message, ws)) + } + const messageStr = message.toString() // 8. handle clearing votes from slide deck @@ -99,6 +122,16 @@ export default { party.broadcast(messageStr) } }, + async onClose (ws, party) { + if (party.id === 'cursors') { + return getCursors(party).then(c => c.onClose(ws)) + } + }, + async onError (ws, _err, party) { + if (party.id === 'cursors') { + return getCursors(party).then(c => c.onError(ws)) + } + }, } satisfies PartyKitServer const getCount = (room: Room) => room.storage.get('count').then(r => r || 0) diff --git a/test/unit/bundle.spec.ts b/test/unit/bundle.spec.ts index 1f42488a..c5cf15f8 100644 --- a/test/unit/bundle.spec.ts +++ b/test/unit/bundle.spec.ts @@ -63,7 +63,7 @@ describe('project sizes', () => { expect .soft(roundToKilobytes(stats.client.totalBytes)) - .toMatchInlineSnapshot(`"270k"`) + .toMatchInlineSnapshot(`"276k"`) expect.soft(stats.client.files.map(f => f.replace(/\..*\.js/, '.js')).sort()) .toMatchInlineSnapshot(` [ -- 2.51.2