@weft-js/multiplayer 0.1.0 → 0.1.3

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.
@@ -0,0 +1,196 @@
1
+ import {
2
+ derived,
3
+ onCleanup,
4
+ onMount,
5
+ state,
6
+ untrack,
7
+ type Derived,
8
+ type State,
9
+ } from '@weft-js/core'
10
+ import type { CursorAt, Peer, Presence } from '../index'
11
+ import { isServer, silent } from './shared'
12
+
13
+ // ---- cursors --------------------------------------------------------------------------------------------------------
14
+
15
+ /** Options of `cursors()`. */
16
+ export interface CursorOptions {
17
+ /** What a position is measured from. An element's id, in this attribute, on both sides. Default `data-anchor`. */
18
+ anchor?: string
19
+ /** Sends a second. Default 12, which keeps a socket under its default 20 a second with room for everything else. */
20
+ rate?: number
21
+ /** Ms before a still cursor fades. Default 5000. */
22
+ idle?: number
23
+ /** Where the pointer is followed. Default the document. */
24
+ root?: () => HTMLElement | Document
25
+ style?: string
26
+ }
27
+
28
+ /** One other cursor, placed on this screen. `peer` is missing until their presence arrives. */
29
+ export interface LiveCursor<S = unknown> {
30
+ id: string
31
+ peer?: Peer<S> | undefined
32
+ at: CursorAt
33
+ left: number
34
+ top: number
35
+ moved: number
36
+ idle: boolean
37
+ }
38
+ /** What `cursors()` returns, for `<Cursors of={…} />`. */
39
+ export interface Cursors<S = unknown> {
40
+ readonly list: Derived<LiveCursor<S>[]>
41
+ readonly anchor: string
42
+ }
43
+
44
+ /** Where a local pointer is, relative to the nearest anchor, or the viewport without one. Pure but for reading layout. */
45
+ export function locate(
46
+ target: Element | null,
47
+ x: number,
48
+ y: number,
49
+ attr = 'data-anchor',
50
+ view = { width: innerWidth, height: innerHeight },
51
+ ): CursorAt {
52
+ const el = target?.closest?.(`[${attr}]`) as HTMLElement | null | undefined
53
+ if (el) {
54
+ const r = el.getBoundingClientRect()
55
+ const w = Math.max(el.scrollWidth, r.width) || 1,
56
+ h = Math.max(el.scrollHeight, r.height) || 1
57
+ return {
58
+ a: el.getAttribute(attr)!,
59
+ x: (x - r.left + el.scrollLeft) / w,
60
+ y: (y - r.top + el.scrollTop) / h,
61
+ }
62
+ }
63
+ return { x: x / view.width, y: y / view.height }
64
+ }
65
+
66
+ /** The same spot on this screen. Null when the anchor isn't here: a cursor at (0,0) would be a lie. */
67
+ export function resolve(
68
+ at: CursorAt,
69
+ attr = 'data-anchor',
70
+ view = { width: innerWidth, height: innerHeight },
71
+ ): { left: number; top: number } | null {
72
+ if (!at.a) return { left: at.x * view.width, top: at.y * view.height }
73
+ const el = document.querySelector<HTMLElement>(`[${attr}="${CSS.escape(at.a)}"]`)
74
+ if (!el) return null
75
+ const r = el.getBoundingClientRect()
76
+ return {
77
+ left: r.left + at.x * Math.max(el.scrollWidth, r.width) - el.scrollLeft,
78
+ top: r.top + at.y * Math.max(el.scrollHeight, r.height) - el.scrollTop,
79
+ }
80
+ }
81
+
82
+ /** Sends this pointer, latest wins, and tracks everyone else's. Sending and the page's own events stop with the owner. */
83
+ export function cursors<S>(room: Presence<S>, options: CursorOptions = {}): Cursors<S> {
84
+ const { anchor = 'data-anchor', rate = 12, idle = 5000, root, style } = options
85
+ const gap = 1000 / rate
86
+ const raw = state<Record<string, { at: CursorAt; moved: number }>>({})
87
+ const layout = state(0)
88
+ const clock = state(0)
89
+ const send = (at: CursorAt | null) => {
90
+ const link = room.link()
91
+ if (link) link.send(JSON.stringify(at))
92
+ else silent(room.connection.send('mp.cursor', at))
93
+ }
94
+ room.seen((id, at) => {
95
+ const { [id]: _, ...rest } = untrack(() => raw.value)
96
+ raw.value = at ? { ...rest, [id]: { at, moved: Date.now() } } : rest
97
+ })
98
+
99
+ onMount(() => mountCursor({ root, gap, anchor, style, send, layout, clock }))
100
+
101
+ const list = derived(() => {
102
+ void layout.value
103
+ const now = clock.value || Date.now()
104
+ const peers = room.peers.value
105
+ const out: LiveCursor<S>[] = []
106
+ for (const [id, { at, moved }] of Object.entries(raw.value)) {
107
+ const spot = resolve(at, anchor)
108
+ if (spot)
109
+ out.push({
110
+ id,
111
+ peer: peers.find((p) => p.id === id),
112
+ at,
113
+ ...spot,
114
+ moved,
115
+ idle: now - moved > idle,
116
+ })
117
+ }
118
+ return out
119
+ })
120
+ return { list, anchor }
121
+ }
122
+
123
+ function mountCursor({
124
+ root,
125
+ gap,
126
+ anchor,
127
+ style,
128
+ send,
129
+ layout,
130
+ clock,
131
+ }: {
132
+ root: (() => HTMLElement | Document) | undefined
133
+ gap: number
134
+ anchor: string
135
+ style: string | undefined
136
+ send: (at: CursorAt | null) => void
137
+ layout: State<number>
138
+ clock: State<number>
139
+ }) {
140
+ if (isServer()) return
141
+ const host = root?.() ?? document
142
+ let last = 0,
143
+ waiting: ReturnType<typeof setTimeout> | undefined,
144
+ next: CursorAt | null | undefined
145
+ const flush = () => {
146
+ waiting = undefined
147
+ if (next === undefined) return
148
+ last = performance.now()
149
+ send(next)
150
+ next = undefined
151
+ }
152
+ const put = (at: CursorAt | null) => {
153
+ next = at
154
+ const wait = gap - (performance.now() - last)
155
+ if (wait <= 0) flush()
156
+ else waiting ??= setTimeout(flush, wait)
157
+ }
158
+ const move = (e: Event) => {
159
+ const p = e as PointerEvent
160
+ put({
161
+ ...locate(p.target as Element, p.clientX, p.clientY, anchor),
162
+ ...(style ? { style } : {}),
163
+ })
164
+ }
165
+ const out = () => put(null)
166
+ let frame = 0
167
+ const moved = () => {
168
+ frame ||= requestAnimationFrame(() => {
169
+ frame = 0
170
+ layout.value++
171
+ })
172
+ }
173
+ host.addEventListener('pointermove', move, { passive: true })
174
+ document.documentElement.addEventListener('pointerleave', out)
175
+ addEventListener('scroll', moved, { passive: true, capture: true })
176
+ addEventListener('resize', moved)
177
+ const tick = setInterval(() => {
178
+ clock.value = Date.now()
179
+ }, 1000)
180
+ onCleanup(() => {
181
+ host.removeEventListener('pointermove', move)
182
+ document.documentElement.removeEventListener('pointerleave', out)
183
+ removeEventListener('scroll', moved, { capture: true })
184
+ removeEventListener('resize', moved)
185
+ clearInterval(tick)
186
+ clearTimeout(waiting)
187
+ cancelAnimationFrame(frame)
188
+ })
189
+ }
190
+
191
+ /** A peer's colour, the same for them everywhere. */
192
+ export function hue(id: string): number {
193
+ let h = 0
194
+ for (let i = 0; i < id.length; i++) h = (h * 31 + id.charCodeAt(i)) | 0
195
+ return Math.abs(h) % 360
196
+ }
@@ -0,0 +1,26 @@
1
+ import type { CursorAt, Peer } from '../index'
2
+
3
+ /** Roster logic with no DOM and no timers of its own, so a test can drive it. */
4
+ export const applyPeer = <S>(list: Peer<S>[], p: Peer<S>): Peer<S>[] =>
5
+ list.some((x) => x.id === p.id) ? list.map((x) => (x.id === p.id ? p : x)) : [...list, p]
6
+ /** A frame off a transport: no server looked at it, so it's read as hostile. Undefined is refused. */
7
+ export function readCursor(text: string): CursorAt | null | undefined {
8
+ let parsed: unknown
9
+ try {
10
+ parsed = JSON.parse(text)
11
+ } catch {
12
+ return undefined
13
+ }
14
+ if (parsed === null) return null
15
+ if (typeof parsed !== 'object') return undefined
16
+ const v = parsed as Record<string, unknown>
17
+ const unit = (n: unknown): n is number => typeof n === 'number' && n >= -0.5 && n <= 1.5
18
+ if (!unit(v.x) || !unit(v.y)) return undefined
19
+ if (v.a !== undefined && (typeof v.a !== 'string' || v.a.length > 128)) return undefined
20
+ return {
21
+ x: v.x,
22
+ y: v.y,
23
+ ...(typeof v.a === 'string' ? { a: v.a } : {}),
24
+ ...(typeof v.style === 'string' && v.style.length <= 16 ? { style: v.style } : {}),
25
+ }
26
+ }
@@ -0,0 +1,102 @@
1
+ import { effect, onCleanup, untrack, type Derived, type State } from '@weft-js/core'
2
+ import type { CursorAt, Peer, PeerLink, PeerTransport } from '../index'
3
+ import { applyPeer, readCursor } from './peer'
4
+ import { hear, silent, type Conn } from './shared'
5
+
6
+ export function listenPresence<S>({
7
+ conn,
8
+ self,
9
+ ticket,
10
+ list,
11
+ heard,
12
+ signals,
13
+ own,
14
+ hello,
15
+ }: {
16
+ conn: Conn
17
+ self: State<string | undefined>
18
+ ticket: State<unknown>
19
+ list: State<Peer<S>[]>
20
+ heard: Set<(id: string, at: CursorAt | null) => void>
21
+ signals: Set<(from: string, data: unknown) => void>
22
+ own: () => S | null
23
+ hello: () => void
24
+ }) {
25
+ hear(conn, 'mp.you', ({ id, ticket: t }: { id: string; ticket?: unknown }) => {
26
+ self.value = id
27
+ ticket.value = t
28
+ })
29
+ hear(conn, 'mp.peer', (p: Peer<S>) => {
30
+ if (p.id === untrack(() => self.value)) return
31
+ // Stamped by this clock: another machine's would expire it early or never.
32
+ list.value = applyPeer(
33
+ untrack(() => list.value),
34
+ { ...p, at: Date.now() },
35
+ )
36
+ })
37
+ hear(conn, 'mp.gone', ({ id }: { id: string }) => {
38
+ list.value = untrack(() => list.value).filter((p) => p.id !== id)
39
+ for (const f of heard) f(id, null)
40
+ })
41
+ hear(conn, 'mp.ask', ({ id }: { id: string }) =>
42
+ silent(conn.send('mp.answer', { to: id, state: untrack(own) })),
43
+ )
44
+ hear(conn, 'mp.cursor', ({ id, at }: { id: string; at: CursorAt | null }) => {
45
+ for (const f of heard) f(id, at)
46
+ })
47
+ hear(conn, 'mp.signal', ({ from, data }: { from: string; data: unknown }) => {
48
+ for (const f of signals) f(from, data)
49
+ })
50
+ conn.onReset(() => {
51
+ list.value = []
52
+ hello()
53
+ })
54
+ }
55
+
56
+ export function openTransport({
57
+ transport,
58
+ self,
59
+ ticket,
60
+ ids,
61
+ linked,
62
+ conn,
63
+ signals,
64
+ heard,
65
+ }: {
66
+ transport: PeerTransport
67
+ self: State<string | undefined>
68
+ ticket: State<unknown>
69
+ ids: Derived<string[]>
70
+ linked: State<PeerLink | undefined>
71
+ conn: Conn
72
+ signals: Set<(from: string, data: unknown) => void>
73
+ heard: Set<(id: string, at: CursorAt | null) => void>
74
+ }) {
75
+ let link: PeerLink | undefined
76
+ effect(
77
+ () => [self.value, ticket.value] as const,
78
+ ([me, t]) => {
79
+ link?.close()
80
+ link = undefined
81
+ linked.value = undefined
82
+ if (!me) return
83
+ link = transport.open({
84
+ self: me,
85
+ ticket: t,
86
+ peers: ids,
87
+ signal: (to, data) => silent(conn.send('mp.signal', { to, data })),
88
+ onSignal: (fn) => {
89
+ signals.add(fn)
90
+ return () => void signals.delete(fn)
91
+ },
92
+ receive(from, text) {
93
+ const at = readCursor(text)
94
+ if (at !== undefined && untrack(() => ids.value).includes(from))
95
+ for (const f of heard) f(from, at)
96
+ },
97
+ })
98
+ linked.value = link
99
+ },
100
+ )
101
+ onCleanup(() => link?.close())
102
+ }
@@ -0,0 +1,13 @@
1
+ import type { Connection } from '@weft-js/start'
2
+
3
+ // What `presence` needs of a connection: the `mp.*` names are the fragment's, which a `doc.connect()`'s types don't say.
4
+ export type Conn = Pick<Connection<unknown, unknown>, 'status' | 'room' | 'onReset'> & {
5
+ on(name: string, fn: (message: unknown) => void): () => void
6
+ send(name: string, message: unknown): Promise<void>
7
+ }
8
+ /** One of the fragment's messages, whose shape the server's half decides. */
9
+ export const hear = <M>(conn: Pick<Conn, 'on'>, name: string, fn: (message: M) => void) =>
10
+ conn.on(name, fn as (message: unknown) => void)
11
+
12
+ export const silent = (p: Promise<unknown>) => void p.catch(() => {}) // a volatile message: the next one replaces it
13
+ export const isServer = () => typeof document === 'undefined'