@weft-js/multiplayer 0.1.1 → 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.
- package/README.md +435 -3
- package/package.json +24 -17
- package/src/Cursors.tsx +15 -4
- package/src/conformance.ts +50 -14
- package/src/index/cursors.ts +196 -0
- package/src/index/peer.ts +26 -0
- package/src/index/presence.ts +102 -0
- package/src/index/shared.ts +13 -0
- package/src/index.ts +109 -234
- package/src/server.ts +186 -59
|
@@ -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'
|