@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/src/index.ts CHANGED
@@ -1,29 +1,36 @@
1
- // Presence, cursors, a typed relay and synced scroll, on a `channel` connection (research 35). A library: nothing here
2
- // is a plugin, and an app that doesn't import it ships none of it. The server's half is `@weft-js/multiplayer/server`.
3
- //
4
- // const chat = doc.connect(() => ({ id: props.id }))
5
- // const room = presence(chat, { state: () => ({ typing: typing.value }) })
6
- // room.peers.value // Peer[], without this connection
7
- // const pointers = cursors(room) // follows the pointer; <Cursors of={pointers} /> draws the others
8
- // const clicks = relay<{ ping: { at: string } }>(chat); clicks.on('ping', e => flash(e.data.at, e.peer))
9
-
10
- import { derived, effect, onCleanup, onMount, state, untrack, type Derived, type State } from '@weft-js/core'
11
- import type { Connection } from '@weft-js/start'
1
+ import {
2
+ derived,
3
+ effect,
4
+ onCleanup,
5
+ onMount,
6
+ state,
7
+ untrack,
8
+ type Derived,
9
+ type State,
10
+ } from '@weft-js/core'
11
+ import { hear, isServer, silent, type Conn } from './index/shared'
12
+ import { listenPresence, openTransport } from './index/presence'
12
13
 
13
14
  /** Who a peer is, from `user` on the server: never from the client. */
14
- export interface PeerUser { id: string; name?: string; [key: string]: unknown }
15
+ export interface PeerUser {
16
+ id: string
17
+ name?: string
18
+ [key: string]: unknown
19
+ }
15
20
  /** Someone else in the room: their connection's id, who they are, the state they share and when they were last heard. */
16
- export interface Peer<S = unknown> { id: string; user: PeerUser; state: S; at: number }
21
+ export interface Peer<S = unknown> {
22
+ id: string
23
+ user: PeerUser
24
+ state: S
25
+ at: number
26
+ }
17
27
  /** A pointer as fractions of `a`, an anchor's id, or of the viewport without one. */
18
- export interface CursorAt { a?: string; x: number; y: number; style?: string }
19
-
20
- // What `presence` needs of a connection: the `mp.*` names are the fragment's, which a `doc.connect()`'s types don't say.
21
- type Conn = Pick<Connection<unknown, unknown>, 'status' | 'room' | 'onReset'> & {
22
- on(name: string, fn: (message: unknown) => void): () => void
23
- send(name: string, message: unknown): Promise<void>
28
+ export interface CursorAt {
29
+ a?: string
30
+ x: number
31
+ y: number
32
+ style?: string
24
33
  }
25
- /** One of the fragment's messages, whose shape the server's half decides. */
26
- const hear = <M,>(conn: Pick<Conn, 'on'>, name: string, fn: (message: M) => void) => conn.on(name, fn as (message: unknown) => void)
27
34
 
28
35
  /** What a transport pack is handed to carry cursors around the server (`@weft-js/realtime-webrtc`, `-livekit`, `-socketio`). */
29
36
  export interface PeerRoom {
@@ -79,12 +86,12 @@ export interface Presence<S> {
79
86
 
80
87
  const STATE_GAP = 50
81
88
 
82
- /** Roster logic with no DOM and no timers of its own, so a test can drive it. */
83
- export const applyPeer = <S,>(list: Peer<S>[], p: Peer<S>): Peer<S>[] => (list.some(x => x.id === p.id) ? list.map(x => (x.id === p.id ? p : x)) : [...list, p])
89
+ export { applyPeer, readCursor } from './index/peer'
84
90
  /** The list without peers unheard for `ttl`, or the same list when nobody expired. */
85
- export const expire = <S,>(list: Peer<S>[], now: number, ttl: number): Peer<S>[] => { const kept = list.filter(p => now - p.at <= ttl); return kept.length === list.length ? list : kept }
86
-
87
- const silent = (p: Promise<unknown>) => void p.catch(() => {}) // a volatile message: the next one replaces it
91
+ export const expire = <S>(list: Peer<S>[], now: number, ttl: number): Peer<S>[] => {
92
+ const kept = list.filter((p) => now - p.at <= ttl)
93
+ return kept.length === list.length ? list : kept
94
+ }
88
95
 
89
96
  /** Who else is on this connection. Announces this one, follows `state`, and expires peers that stop beating. */
90
97
  export function presence<S = undefined>(conn: Conn, options: PresenceOptions<S> = {}): Presence<S> {
@@ -93,48 +100,35 @@ export function presence<S = undefined>(conn: Conn, options: PresenceOptions<S>
93
100
  const list = state<Peer<S>[]>([])
94
101
  const self = state<string | undefined>(undefined)
95
102
  const ticket = state<unknown>(undefined)
96
- const own = () => (typeof options.state === 'function' ? (options.state as () => S)() : options.state) ?? null
103
+ const own = () =>
104
+ (typeof options.state === 'function' ? (options.state as () => S)() : options.state) ?? null
97
105
  const hello = () => silent(conn.send('mp.hello', { state: untrack(own) }))
98
- const ids = derived(() => list.value.map(p => p.id))
106
+ const ids = derived(() => list.value.map((p) => p.id))
99
107
  const heard = new Set<(id: string, at: CursorAt | null) => void>()
100
108
  const signals = new Set<(from: string, data: unknown) => void>()
101
- let link: PeerLink | undefined
102
109
  const linked = state<PeerLink | undefined>(undefined)
103
110
 
104
- hear(conn, 'mp.you', ({ id, ticket: t }: { id: string; ticket?: unknown }) => {
105
- self.value = id
106
- ticket.value = t
107
- })
108
- hear(conn, 'mp.peer', (p: Peer<S>) => {
109
- if (p.id === untrack(() => self.value)) return
110
- // Stamped by this clock: another machine's would expire it early or never.
111
- list.value = applyPeer(untrack(() => list.value), { ...p, at: Date.now() })
112
- })
113
- hear(conn, 'mp.gone', ({ id }: { id: string }) => {
114
- list.value = untrack(() => list.value).filter(p => p.id !== id)
115
- for (const f of heard) f(id, null)
116
- })
117
- hear(conn, 'mp.ask', ({ id }: { id: string }) => silent(conn.send('mp.answer', { to: id, state: untrack(own) })))
118
- hear(conn, 'mp.cursor', ({ id, at }: { id: string; at: CursorAt | null }) => { for (const f of heard) f(id, at) })
119
- hear(conn, 'mp.signal', ({ from, data }: { from: string; data: unknown }) => { for (const f of signals) f(from, data) })
120
- conn.onReset(() => {
121
- list.value = []
122
- hello()
123
- })
111
+ listenPresence({ conn, self, ticket, list, heard, signals, own, hello })
124
112
 
125
113
  // Open, or moved to another room: everything held is about the last one.
126
- effect(() => (conn.status.value === 'open' ? conn.room.value : undefined), (room, was) => {
127
- if (room === was) return
128
- list.value = []
129
- self.value = undefined
130
- if (room) hello()
131
- })
114
+ effect(
115
+ () => (conn.status.value === 'open' ? conn.room.value : undefined),
116
+ (room, was) => {
117
+ if (room === was) return
118
+ list.value = []
119
+ self.value = undefined
120
+ if (room) hello()
121
+ },
122
+ )
132
123
 
133
124
  let first = true
134
- effect(() => own(), () => {
135
- if (first) return void (first = false)
136
- if (untrack(() => conn.status.value) === 'open') schedule()
137
- })
125
+ effect(
126
+ () => own(),
127
+ () => {
128
+ if (first) return void (first = false)
129
+ if (untrack(() => conn.status.value) === 'open') schedule()
130
+ },
131
+ )
138
132
  let queued: ReturnType<typeof setTimeout> | undefined
139
133
  const schedule = () => {
140
134
  queued ??= setTimeout(() => {
@@ -145,8 +139,13 @@ export function presence<S = undefined>(conn: Conn, options: PresenceOptions<S>
145
139
 
146
140
  if (typeof setInterval !== 'undefined' && !isServer()) {
147
141
  const timer = setInterval(() => {
148
- list.value = expire(untrack(() => list.value), Date.now(), ttl)
149
- if (untrack(() => conn.status.value) === 'open') silent(conn.send('mp.state', { state: untrack(own) }))
142
+ list.value = expire(
143
+ untrack(() => list.value),
144
+ Date.now(),
145
+ ttl,
146
+ )
147
+ if (untrack(() => conn.status.value) === 'open')
148
+ silent(conn.send('mp.state', { state: untrack(own) }))
150
149
  }, beat)
151
150
  onCleanup(() => {
152
151
  clearInterval(timer)
@@ -154,182 +153,43 @@ export function presence<S = undefined>(conn: Conn, options: PresenceOptions<S>
154
153
  })
155
154
  }
156
155
 
157
- if (transport) {
158
- effect(() => [self.value, ticket.value] as const, ([me, t]) => {
159
- link?.close()
160
- link = undefined
161
- linked.value = undefined
162
- if (!me) return
163
- link = transport.open({
164
- self: me,
165
- ticket: t,
166
- peers: ids,
167
- signal: (to, data) => silent(conn.send('mp.signal', { to, data })),
168
- onSignal: fn => { signals.add(fn); return () => void signals.delete(fn) },
169
- receive(from, text) {
170
- const at = readCursor(text)
171
- if (at !== undefined && untrack(() => ids.value).includes(from)) for (const f of heard) f(from, at)
172
- },
173
- })
174
- linked.value = link
175
- })
176
- onCleanup(() => link?.close())
177
- }
156
+ if (transport) openTransport({ transport, self, ticket, ids, linked, conn, signals, heard })
178
157
 
179
158
  return {
180
159
  peers: list,
181
160
  self,
182
161
  via: derived(() => (linked.value?.up.value ? transport!.name : 'socket')),
183
162
  connection: conn,
184
- link: () => { const l = untrack(() => linked.value); return l && untrack(() => l.up.value) ? l : undefined },
185
- seen: fn => { heard.add(fn); onCleanup(() => void heard.delete(fn)) },
186
- }
187
- }
188
-
189
- const isServer = () => typeof document === 'undefined'
190
-
191
- /** A frame off a transport: no server looked at it, so it's read as hostile. Undefined is refused. */
192
- export function readCursor(text: string): CursorAt | null | undefined {
193
- let parsed: unknown
194
- try { parsed = JSON.parse(text) } catch { return undefined }
195
- if (parsed === null) return null
196
- if (typeof parsed !== 'object') return undefined
197
- const v = parsed as Record<string, unknown>
198
- const unit = (n: unknown): n is number => typeof n === 'number' && n >= -0.5 && n <= 1.5
199
- if (!unit(v.x) || !unit(v.y)) return undefined
200
- if (v.a !== undefined && (typeof v.a !== 'string' || v.a.length > 128)) return undefined
201
- return { x: v.x, y: v.y, ...(typeof v.a === 'string' ? { a: v.a } : {}), ...(typeof v.style === 'string' && v.style.length <= 16 ? { style: v.style } : {}) }
202
- }
203
-
204
- // ---- cursors --------------------------------------------------------------------------------------------------------
205
-
206
- /** Options of `cursors()`. */
207
- export interface CursorOptions {
208
- /** What a position is measured from. An element's id, in this attribute, on both sides. Default `data-anchor`. */
209
- anchor?: string
210
- /** Sends a second. Default 12, which keeps a socket under its default 20 a second with room for everything else. */
211
- rate?: number
212
- /** Ms before a still cursor fades. Default 5000. */
213
- idle?: number
214
- /** Where the pointer is followed. Default the document. */
215
- root?: () => HTMLElement | Document
216
- style?: string
217
- }
218
-
219
- /** One other cursor, placed on this screen. `peer` is missing until their presence arrives. */
220
- export interface LiveCursor<S = unknown> { id: string; peer?: Peer<S> | undefined; at: CursorAt; left: number; top: number; moved: number; idle: boolean }
221
- /** What `cursors()` returns, for `<Cursors of={…} />`. */
222
- export interface Cursors<S = unknown> { readonly list: Derived<LiveCursor<S>[]>; readonly anchor: string }
223
-
224
- /** Where a local pointer is, relative to the nearest anchor, or the viewport without one. Pure but for reading layout. */
225
- export function locate(target: Element | null, x: number, y: number, attr = 'data-anchor', view = { width: innerWidth, height: innerHeight }): CursorAt {
226
- const el = target?.closest?.(`[${attr}]`) as HTMLElement | null | undefined
227
- if (el) {
228
- const r = el.getBoundingClientRect()
229
- const w = Math.max(el.scrollWidth, r.width) || 1, h = Math.max(el.scrollHeight, r.height) || 1
230
- return { a: el.getAttribute(attr)!, x: (x - r.left + el.scrollLeft) / w, y: (y - r.top + el.scrollTop) / h }
163
+ link: () => {
164
+ const l = untrack(() => linked.value)
165
+ return l && untrack(() => l.up.value) ? l : undefined
166
+ },
167
+ seen: (fn) => {
168
+ heard.add(fn)
169
+ onCleanup(() => void heard.delete(fn))
170
+ },
231
171
  }
232
- return { x: x / view.width, y: y / view.height }
233
- }
234
-
235
- /** The same spot on this screen. Null when the anchor isn't here: a cursor at (0,0) would be a lie. */
236
- export function resolve(at: CursorAt, attr = 'data-anchor', view = { width: innerWidth, height: innerHeight }): { left: number; top: number } | null {
237
- if (!at.a) return { left: at.x * view.width, top: at.y * view.height }
238
- const el = document.querySelector<HTMLElement>(`[${attr}="${CSS.escape(at.a)}"]`)
239
- if (!el) return null
240
- const r = el.getBoundingClientRect()
241
- return { left: r.left + at.x * Math.max(el.scrollWidth, r.width) - el.scrollLeft, top: r.top + at.y * Math.max(el.scrollHeight, r.height) - el.scrollTop }
242
172
  }
243
173
 
244
- /** Sends this pointer, latest wins, and tracks everyone else's. Sending and the page's own events stop with the owner. */
245
- export function cursors<S>(room: Presence<S>, options: CursorOptions = {}): Cursors<S> {
246
- const { anchor = 'data-anchor', rate = 12, idle = 5000, root, style } = options
247
- const gap = 1000 / rate
248
- const raw = state<Record<string, { at: CursorAt; moved: number }>>({})
249
- const layout = state(0)
250
- const clock = state(0)
251
- const send = (at: CursorAt | null) => {
252
- const link = room.link()
253
- if (link) link.send(JSON.stringify(at))
254
- else silent(room.connection.send('mp.cursor', at))
255
- }
256
- room.seen((id, at) => {
257
- const { [id]: _, ...rest } = untrack(() => raw.value)
258
- raw.value = at ? { ...rest, [id]: { at, moved: Date.now() } } : rest
259
- })
260
-
261
- onMount(() => {
262
- if (isServer()) return
263
- const host = root?.() ?? document
264
- let last = 0, waiting: ReturnType<typeof setTimeout> | undefined, next: CursorAt | null | undefined
265
- const flush = () => {
266
- waiting = undefined
267
- if (next === undefined) return
268
- last = performance.now()
269
- send(next)
270
- next = undefined
271
- }
272
- const put = (at: CursorAt | null) => {
273
- next = at
274
- const wait = gap - (performance.now() - last)
275
- if (wait <= 0) flush()
276
- else waiting ??= setTimeout(flush, wait)
277
- }
278
- const move = (e: Event) => {
279
- const p = e as PointerEvent
280
- put({ ...locate(p.target as Element, p.clientX, p.clientY, anchor), ...(style ? { style } : {}) })
281
- }
282
- const out = () => put(null)
283
- let frame = 0
284
- const moved = () => { frame ||= requestAnimationFrame(() => { frame = 0; layout.value++ }) }
285
- host.addEventListener('pointermove', move, { passive: true })
286
- document.documentElement.addEventListener('pointerleave', out)
287
- addEventListener('scroll', moved, { passive: true, capture: true })
288
- addEventListener('resize', moved)
289
- const tick = setInterval(() => { clock.value = Date.now() }, 1000)
290
- onCleanup(() => {
291
- host.removeEventListener('pointermove', move)
292
- document.documentElement.removeEventListener('pointerleave', out)
293
- removeEventListener('scroll', moved, { capture: true })
294
- removeEventListener('resize', moved)
295
- clearInterval(tick)
296
- clearTimeout(waiting)
297
- cancelAnimationFrame(frame)
298
- })
299
- })
300
-
301
- const list = derived(() => {
302
- void layout.value
303
- const now = clock.value || Date.now()
304
- const peers = room.peers.value
305
- const out: LiveCursor<S>[] = []
306
- for (const [id, { at, moved }] of Object.entries(raw.value)) {
307
- const spot = resolve(at, anchor)
308
- if (spot) out.push({ id, peer: peers.find(p => p.id === id), at, ...spot, moved, idle: now - moved > idle })
309
- }
310
- return out
311
- })
312
- return { list, anchor }
313
- }
314
-
315
- /** A peer's colour, the same for them everywhere. */
316
- export function hue(id: string): number {
317
- let h = 0
318
- for (let i = 0; i < id.length; i++) h = (h * 31 + id.charCodeAt(i)) | 0
319
- return Math.abs(h) % 360
320
- }
321
-
322
- // ---- relay ----------------------------------------------------------------------------------------------------------
174
+ export { cursors, hue, locate, resolve } from './index/cursors'
175
+ export type { CursorOptions, Cursors, LiveCursor } from './index/cursors'
323
176
 
324
177
  /** Something a peer did, named and validated by the server, and which peer did it. */
325
- export interface RelayEvent<D> { peer: string; kind: string; data: D }
178
+ export interface RelayEvent<D> {
179
+ peer: string
180
+ kind: string
181
+ data: D
182
+ }
326
183
 
327
184
  /** Something a peer did, delivered to a callback. Never a synthetic DOM event: the page decides what it means. */
328
185
  export function relay<K extends Record<string, unknown>>(conn: Pick<Conn, 'on' | 'send'>) {
329
186
  const handlers = new Map<string, Set<(e: RelayEvent<unknown>) => void>>()
330
- hear(conn, 'mp.relay', ({ id, kind, data }: { id: string; kind: string; data: unknown }) => { for (const f of handlers.get(kind) ?? []) f({ peer: id, kind, data }) })
187
+ hear(conn, 'mp.relay', ({ id, kind, data }: { id: string; kind: string; data: unknown }) => {
188
+ for (const f of handlers.get(kind) ?? []) f({ peer: id, kind, data })
189
+ })
331
190
  return {
332
- emit: <N extends keyof K & string>(kind: N, data: K[N]) => conn.send('mp.relay', { kind, data }),
191
+ emit: <N extends keyof K & string>(kind: N, data: K[N]) =>
192
+ conn.send('mp.relay', { kind, data }),
333
193
  on<N extends keyof K & string>(kind: N, fn: (e: RelayEvent<K[N]>) => void) {
334
194
  const set = handlers.get(kind) ?? new Set()
335
195
  handlers.set(kind, set)
@@ -342,17 +202,22 @@ export function relay<K extends Record<string, unknown>>(conn: Pick<Conn, 'on' |
342
202
  }
343
203
  }
344
204
 
345
- // ---- synced scroll and hash: the box, not the DOM ------------------------------------------------------
346
-
347
205
  /** One scroll position in a shared box, as a fraction, so different heights land on the same place. */
348
- export function syncedScroll(box: State<number>, element: () => HTMLElement | Window = () => window) {
206
+ export function syncedScroll(
207
+ box: State<number>,
208
+ element: () => HTMLElement | Window = () => window,
209
+ ) {
349
210
  onMount(() => {
350
211
  if (isServer()) return
351
212
  const el = element()
352
213
  const page = el === window
353
- const max = () => (page ? document.documentElement.scrollHeight - innerHeight : (el as HTMLElement).scrollHeight - (el as HTMLElement).clientHeight)
214
+ const max = () =>
215
+ page
216
+ ? document.documentElement.scrollHeight - innerHeight
217
+ : (el as HTMLElement).scrollHeight - (el as HTMLElement).clientHeight
354
218
  const now = () => (page ? scrollY : (el as HTMLElement).scrollTop)
355
- let held = 0, frame = 0
219
+ let held = 0,
220
+ frame = 0
356
221
  const on = () => {
357
222
  if (performance.now() < held) return
358
223
  frame ||= requestAnimationFrame(() => {
@@ -366,12 +231,15 @@ export function syncedScroll(box: State<number>, element: () => HTMLElement | Wi
366
231
  el.removeEventListener('scroll', on)
367
232
  cancelAnimationFrame(frame)
368
233
  })
369
- effect(() => box.value, f => {
370
- const top = f * max()
371
- if (Math.abs(top - now()) < 2) return
372
- held = performance.now() + 150 // what this write causes isn't the user scrolling
373
- el.scrollTo({ top })
374
- })
234
+ effect(
235
+ () => box.value,
236
+ (f) => {
237
+ const top = f * max()
238
+ if (Math.abs(top - now()) < 2) return
239
+ held = performance.now() + 150 // what this write causes isn't the user scrolling
240
+ el.scrollTo({ top })
241
+ },
242
+ )
375
243
  })
376
244
  }
377
245
 
@@ -379,9 +247,16 @@ export function syncedScroll(box: State<number>, element: () => HTMLElement | Wi
379
247
  export function syncedHash(box: State<string>) {
380
248
  onMount(() => {
381
249
  if (isServer()) return
382
- const on = () => { box.value = location.hash }
250
+ const on = () => {
251
+ box.value = location.hash
252
+ }
383
253
  addEventListener('hashchange', on)
384
254
  onCleanup(() => removeEventListener('hashchange', on))
385
- effect(() => box.value, h => { if (h && h !== location.hash) location.hash = h })
255
+ effect(
256
+ () => box.value,
257
+ (h) => {
258
+ if (h && h !== location.hash) location.hash = h
259
+ },
260
+ )
386
261
  })
387
262
  }