@weft-js/multiplayer 0.0.0-stage → 0.1.1

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/LICENSE ADDED
@@ -0,0 +1,16 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ravonus
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
6
+ documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
7
+ rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit
8
+ persons to whom the Software is furnished to do so, subject to the following conditions:
9
+
10
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
11
+ Software.
12
+
13
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
14
+ WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
15
+ COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
16
+ OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,9 @@
1
- # Temporary Holding Version
1
+ # @weft-js/multiplayer
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ > **Early release (0.1.1).** APIs will change between minor versions.
4
+
5
+ Presence, cursors anchored to elements, a typed interaction relay and synced scroll on `channel`. A library, not a plugin.
6
+
7
+ ```bash
8
+ npm install @weft-js/multiplayer
9
+ ```
package/package.json CHANGED
@@ -1,6 +1,33 @@
1
1
  {
2
2
  "name": "@weft-js/multiplayer",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.1",
4
+ "description": "@weft-js/multiplayer: presence, cursors anchored to elements, a typed interaction relay and synced scroll on `channel`. A library, not a plugin",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "exports": {
8
+ ".": "./src/index.ts",
9
+ "./server": "./src/server.ts",
10
+ "./ui": "./src/Cursors.tsx",
11
+ "./test": "./src/conformance.ts"
12
+ },
13
+ "peerDependencies": {
14
+ "@weft-js/start": "^0.1.1",
15
+ "@weft-js/core": "^0.1.1"
16
+ },
17
+ "engines": {
18
+ "node": ">=26"
19
+ },
20
+ "keywords": [
21
+ "weft"
22
+ ],
23
+ "files": [
24
+ "src",
25
+ "plugin.mjs",
26
+ "plugin.d.mts",
27
+ "README.md",
28
+ "LICENSE"
29
+ ],
30
+ "publishConfig": {
31
+ "access": "public"
32
+ }
33
+ }
@@ -0,0 +1,25 @@
1
+ // The other cursors, drawn over the page: `aria-hidden`, gliding 120 ms, and instant under reduced motion (warp 07 §6.3).
2
+ import { hue, type Cursors as Tracker } from './index'
3
+
4
+ const CSS = `[data-scope=cursors]{position:fixed;inset:0;pointer-events:none;overflow:hidden;z-index:2147483000}
5
+ [data-scope=cursors] [data-part=cursor]{position:absolute;left:0;top:0;transition:transform 120ms linear,opacity 300ms}
6
+ [data-scope=cursors] [data-part=name]{margin-left:10px;padding:1px 6px;border-radius:6px;font:12px/1.4 system-ui;color:#fff}
7
+ @media (prefers-reduced-motion:reduce){[data-scope=cursors] [data-part=cursor]{transition:none}}`
8
+
9
+ /** Draws everyone else's cursors from `cursors(room)`, each with its peer's name and colour. */
10
+ export function Cursors<S>(props: { of: Tracker<S> }) {
11
+ return (
12
+ <div aria-hidden="true" data-scope="cursors">
13
+ <style>{CSS}</style>
14
+ {props.of.list.value.map(c => {
15
+ const color = `hsl(${hue(c.peer?.user.id ?? c.id)} 80% 45%)`
16
+ return (
17
+ <div key={c.id} data-part="cursor" data-peer={c.id} style={`transform:translate(${c.left}px,${c.top}px);opacity:${c.idle ? 0.25 : 1};color:${color}`}>
18
+ <svg width="16" height="18" viewBox="0 0 16 18"><path fill="currentColor" stroke="#fff" d="M1 1l5 15 2.5-6L15 8z" /></svg>
19
+ {c.peer?.user.name && <span data-part="name" style={`background:${color}`}>{c.peer.user.name}</span>}
20
+ </div>
21
+ )
22
+ })}
23
+ </div>
24
+ )
25
+ }
@@ -0,0 +1,68 @@
1
+ // What a transport pack has to do, for `@weft/realtime-*` (research 35 §4). Plain data in, Checks out: it runs in Node
2
+ // against a real relay, or in a page. `make` builds a transport for one named peer.
3
+
4
+ import { state, type Derived } from '@weft-js/core'
5
+ import type { PeerLink, PeerRoom, PeerTransport } from './index'
6
+
7
+ /** One requirement of the suite, and how the transport did. */
8
+ export interface Check { name: string; ok: boolean; detail: string }
9
+ type Got = { from: string; text: string }
10
+
11
+ const settle = async (done: () => boolean, ms = 8000) => {
12
+ for (const end = Date.now() + ms; !done() && Date.now() < end;) await new Promise(ok => { setTimeout(ok, 10) })
13
+ return done()
14
+ }
15
+
16
+ /** What `transportSuite` needs: a way to build the transport for a peer, and that peer's ticket if it takes one. */
17
+ export interface TransportSuiteOptions {
18
+ make: (peer: string) => PeerTransport | Promise<PeerTransport>
19
+ /** A credential for `peer`, as `multiplayer({ ticket })` would hand it. It may be a promise. */
20
+ ticket?: (peer: string) => unknown
21
+ }
22
+
23
+ function fakeRoom(self: string, ids: Derived<string[]>, ticket: unknown, wires: Map<string, Set<(from: string, data: unknown) => void>>, got: Got[]): PeerRoom {
24
+ return {
25
+ self, ticket,
26
+ peers: { get value() { return ids.value.filter(p => p !== self) } },
27
+ signal: (to, data) => { for (const f of wires.get(to) ?? []) queueMicrotask(() => f(self, data)) },
28
+ onSignal(fn) {
29
+ const set = wires.get(self) ?? new Set()
30
+ wires.set(self, set)
31
+ set.add(fn)
32
+ return () => void set.delete(fn)
33
+ },
34
+ receive: (from, text) => void got.push({ from, text }),
35
+ }
36
+ }
37
+
38
+ /** One Check per requirement of a peer transport. */
39
+ export async function transportSuite({ make, ticket }: TransportSuiteOptions): Promise<Check[]> {
40
+ const out: Check[] = []
41
+ const check = (name: string, ok: boolean, detail = '') => void out.push({ name, ok, detail })
42
+ const ids = state(['a', 'b'])
43
+ const wires = new Map<string, Set<(from: string, data: unknown) => void>>()
44
+ const gotA: Got[] = [], gotB: Got[] = []
45
+ let a: PeerLink | undefined, b: PeerLink | undefined
46
+ try {
47
+ a = (await make('a')).open(fakeRoom('a', ids, await ticket?.('a'), wires, gotA))
48
+ b = (await make('b')).open(fakeRoom('b', ids, await ticket?.('b'), wires, gotB))
49
+ check('both links come up', await settle(() => a!.up.value && b!.up.value, 20_000), `${a.up.value} ${b.up.value}`)
50
+ a.send('{"x":1}')
51
+ check('a frame reaches the other peer, named by the transport', await settle(() => gotB.length > 0) && gotB[0]!.from === 'a' && gotB[0]!.text === '{"x":1}', JSON.stringify(gotB[0]))
52
+ await new Promise(ok => { setTimeout(ok, 100) })
53
+ check('a frame does not come back to its sender', gotA.length === 0, `${gotA.length}`)
54
+ b.send('{"y":2}')
55
+ check('frames go both ways', await settle(() => gotA.length > 0) && gotA[0]!.from === 'b', JSON.stringify(gotA[0]))
56
+ const before = gotB.length
57
+ a.close()
58
+ a = undefined
59
+ await new Promise(ok => { setTimeout(ok, 100) })
60
+ check('a closed link sends nothing', gotB.length === before, `${gotB.length - before}`)
61
+ } catch (e) {
62
+ check('the transport ran', false, String((e as Error | null)?.stack ?? e))
63
+ } finally {
64
+ a?.close()
65
+ b?.close()
66
+ }
67
+ return out
68
+ }
package/src/index.ts ADDED
@@ -0,0 +1,387 @@
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'
12
+
13
+ /** 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
+ /** 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 }
17
+ /** 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>
24
+ }
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
+
28
+ /** What a transport pack is handed to carry cursors around the server (`@weft-js/realtime-webrtc`, `-livekit`, `-socketio`). */
29
+ export interface PeerRoom {
30
+ /** This connection, as the server named it. */
31
+ readonly self: string
32
+ /** From `ticket` in `multiplayer()`. */
33
+ readonly ticket: unknown
34
+ /** Who else is here, by connection. */
35
+ readonly peers: Derived<string[]>
36
+ /** A frame to one peer through the server, stamped with who sent it. For offers and candidates. */
37
+ signal(to: string, data: unknown): void
38
+ onSignal(fn: (from: string, data: unknown) => void): () => void
39
+ /** A frame arrived. `from` has to be one the transport proved, and the frame is checked again here. */
40
+ receive(from: string, text: string): void
41
+ }
42
+
43
+ /** An open transport, as `open` returns it. */
44
+ export interface PeerLink {
45
+ /** True while a `send` reaches every peer. Until then frames go through the server, so nothing waits on it. */
46
+ readonly up: Derived<boolean>
47
+ send(text: string): void
48
+ close(): void
49
+ }
50
+
51
+ /** A way to carry cursors around the server: what `webrtc()`, `livekit()` and `socketio()` return. */
52
+ export interface PeerTransport {
53
+ readonly name: string
54
+ open(room: PeerRoom): PeerLink
55
+ }
56
+
57
+ /** Options of `presence()`. */
58
+ export interface PresenceOptions<S> {
59
+ /** This connection's own state. A function is followed. Say what's small: it goes to everyone on every change. */
60
+ state?: S | (() => S)
61
+ /** Ms between heartbeats. Default 15000, and peers expire after three missed. */
62
+ beat?: number
63
+ /** Carries cursors between peers, with the socket as the fallback. */
64
+ transport?: PeerTransport
65
+ }
66
+
67
+ /** What `presence()` returns: who else is in the room, kept current. */
68
+ export interface Presence<S> {
69
+ /** Everyone else here, oldest first. */
70
+ readonly peers: Derived<Peer<S>[]>
71
+ /** This connection, once the server has said. */
72
+ readonly self: Derived<string | undefined>
73
+ /** Which way cursors are going: `socket`, or the transport's name. */
74
+ readonly via: Derived<string>
75
+ readonly connection: Conn
76
+ /** @internal */ readonly link: () => PeerLink | undefined
77
+ /** @internal */ readonly seen: (fn: (id: string, at: CursorAt | null) => void) => void
78
+ }
79
+
80
+ const STATE_GAP = 50
81
+
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])
84
+ /** 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
88
+
89
+ /** Who else is on this connection. Announces this one, follows `state`, and expires peers that stop beating. */
90
+ export function presence<S = undefined>(conn: Conn, options: PresenceOptions<S> = {}): Presence<S> {
91
+ const { beat = 15_000, transport } = options
92
+ const ttl = beat * 3
93
+ const list = state<Peer<S>[]>([])
94
+ const self = state<string | undefined>(undefined)
95
+ const ticket = state<unknown>(undefined)
96
+ const own = () => (typeof options.state === 'function' ? (options.state as () => S)() : options.state) ?? null
97
+ const hello = () => silent(conn.send('mp.hello', { state: untrack(own) }))
98
+ const ids = derived(() => list.value.map(p => p.id))
99
+ const heard = new Set<(id: string, at: CursorAt | null) => void>()
100
+ const signals = new Set<(from: string, data: unknown) => void>()
101
+ let link: PeerLink | undefined
102
+ const linked = state<PeerLink | undefined>(undefined)
103
+
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
+ })
124
+
125
+ // 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
+ })
132
+
133
+ let first = true
134
+ effect(() => own(), () => {
135
+ if (first) return void (first = false)
136
+ if (untrack(() => conn.status.value) === 'open') schedule()
137
+ })
138
+ let queued: ReturnType<typeof setTimeout> | undefined
139
+ const schedule = () => {
140
+ queued ??= setTimeout(() => {
141
+ queued = undefined
142
+ silent(conn.send('mp.state', { state: untrack(own) }))
143
+ }, STATE_GAP)
144
+ }
145
+
146
+ if (typeof setInterval !== 'undefined' && !isServer()) {
147
+ 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) }))
150
+ }, beat)
151
+ onCleanup(() => {
152
+ clearInterval(timer)
153
+ clearTimeout(queued)
154
+ })
155
+ }
156
+
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
+ }
178
+
179
+ return {
180
+ peers: list,
181
+ self,
182
+ via: derived(() => (linked.value?.up.value ? transport!.name : 'socket')),
183
+ 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 }
231
+ }
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
+ }
243
+
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 ----------------------------------------------------------------------------------------------------------
323
+
324
+ /** 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 }
326
+
327
+ /** Something a peer did, delivered to a callback. Never a synthetic DOM event: the page decides what it means. */
328
+ export function relay<K extends Record<string, unknown>>(conn: Pick<Conn, 'on' | 'send'>) {
329
+ 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 }) })
331
+ return {
332
+ emit: <N extends keyof K & string>(kind: N, data: K[N]) => conn.send('mp.relay', { kind, data }),
333
+ on<N extends keyof K & string>(kind: N, fn: (e: RelayEvent<K[N]>) => void) {
334
+ const set = handlers.get(kind) ?? new Set()
335
+ handlers.set(kind, set)
336
+ const handler = fn as (e: RelayEvent<unknown>) => void // the server checked `data` against this kind's schema
337
+ set.add(handler)
338
+ const stop = () => void set.delete(handler)
339
+ onCleanup(stop)
340
+ return stop
341
+ },
342
+ }
343
+ }
344
+
345
+ // ---- synced scroll and hash: the box, not the DOM ------------------------------------------------------
346
+
347
+ /** 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) {
349
+ onMount(() => {
350
+ if (isServer()) return
351
+ const el = element()
352
+ const page = el === window
353
+ const max = () => (page ? document.documentElement.scrollHeight - innerHeight : (el as HTMLElement).scrollHeight - (el as HTMLElement).clientHeight)
354
+ const now = () => (page ? scrollY : (el as HTMLElement).scrollTop)
355
+ let held = 0, frame = 0
356
+ const on = () => {
357
+ if (performance.now() < held) return
358
+ frame ||= requestAnimationFrame(() => {
359
+ frame = 0
360
+ const m = max()
361
+ if (m > 0) box.value = Math.round((now() / m) * 10_000) / 10_000
362
+ })
363
+ }
364
+ el.addEventListener('scroll', on, { passive: true })
365
+ onCleanup(() => {
366
+ el.removeEventListener('scroll', on)
367
+ cancelAnimationFrame(frame)
368
+ })
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
+ })
375
+ })
376
+ }
377
+
378
+ /** The URL hash in a shared box. */
379
+ export function syncedHash(box: State<string>) {
380
+ onMount(() => {
381
+ if (isServer()) return
382
+ const on = () => { box.value = location.hash }
383
+ addEventListener('hashchange', on)
384
+ onCleanup(() => removeEventListener('hashchange', on))
385
+ effect(() => box.value, h => { if (h && h !== location.hash) location.hash = h })
386
+ })
387
+ }
package/src/server.ts ADDED
@@ -0,0 +1,161 @@
1
+ // The server's half (research 35 §2): messages a `channel` spreads into its own definition, so the compiler still sees a
2
+ // plain `channel({ … })` and `authorize` stays the app's.
3
+ //
4
+ // const mp = multiplayer({ user: ({ ctx }) => ({ id: ctx.user!.id, name: ctx.user!.name }) })
5
+ // export const doc = channel({
6
+ // params, authorize,
7
+ // client: { ...mp.client, say }, server: { ...mp.server, said }, on: { ...mp.on, say: … }, left: mp.left,
8
+ // })
9
+ //
10
+ // Who a peer is comes from `user`, never from the client: a connection names only its own state and cursor, and every
11
+ // frame is stamped here with the connection that sent it. There's no roster on the server: each client
12
+ // answers an `ask`, peers expire without a beat, so any bus works and a dead instance's peers just age out.
13
+
14
+ import { StartError } from '@weft-js/start/data'
15
+ import type { Context, PublishOptions, StandardSchemaV1 } from '@weft-js/start'
16
+
17
+ /** Who a peer is: what `user` returns. */
18
+ export interface PeerUser { id: string; name?: string; [key: string]: unknown }
19
+ /** A peer as the room hears of it, stamped here with who sent it. */
20
+ export interface Peer<S = unknown> { id: string; user: PeerUser; state: S; at: number }
21
+ /** A pointer as fractions of `a`, an anchor's id, or of the viewport without one. */
22
+ export interface CursorAt { a?: string; x: number; y: number; style?: string }
23
+
24
+ type Issue = { issues: { message: string }[] }
25
+ /** Whatever a client sent: read as hostile, field by field. */
26
+ type Wire = { [key: string]: unknown } | null | undefined
27
+ const isIssue = (r: unknown): r is Issue => !!(r as Issue | null)?.issues
28
+ const schema = <T,>(validate: (v: Wire) => T | Issue): StandardSchemaV1<T, T> => ({ '~standard': { version: 1, vendor: 'weft', validate: (v: unknown) => { const r = validate(v as Wire); return isIssue(r) ? r : { value: r } } } })
29
+ const no = (message: string): Issue => ({ issues: [{ message }] })
30
+ const within = (v: unknown, bytes: number) => { try { return (JSON.stringify(v) ?? '').length <= bytes } catch { return false } }
31
+ const unit = (n: unknown): n is number => typeof n === 'number' && n >= -0.5 && n <= 1.5
32
+
33
+ const STATE = 4096, RELAY = 2048, SIGNAL = 16_384
34
+
35
+ const anything = schema(v => v)
36
+
37
+ const stateful = (max: number) => schema(v => (v && typeof v === 'object' && within(v.state, max) ? { state: v.state } : no(`\`state\` is JSON up to ${max} bytes`)))
38
+
39
+ /** What a client may send as a cursor: fractions just past the edges, or null when it leaves. */
40
+ export const cursorSchema = schema<CursorAt | null>(v => {
41
+ if (v === null) return null
42
+ if (!v || !unit(v.x) || !unit(v.y)) return no('a cursor is `{ x, y }` in fractions')
43
+ if (v.a !== undefined && (typeof v.a !== 'string' || v.a.length > 128)) return no('an anchor is up to 128 characters')
44
+ if (v.style !== undefined && (typeof v.style !== 'string' || v.style.length > 16)) return no('a style is up to 16 characters')
45
+ return { x: v.x, y: v.y, ...(v.a === undefined ? {} : { a: v.a }), ...(v.style === undefined ? {} : { style: v.style }) }
46
+ })
47
+
48
+ const answerSchema = schema(v => (v && typeof v.to === 'string' && within(v.state, STATE) ? { to: v.to, state: v.state } : no('an answer names who asked')))
49
+ const signalSchema = schema(v => (v && typeof v.to === 'string' && within(v.data, SIGNAL) ? { to: v.to, data: v.data } : no(`a signal names a peer, with up to ${SIGNAL} bytes`)))
50
+
51
+ /** What `ticket` gets: the connection, who it is and the room's params. */
52
+ export interface TicketArgs<P> { id: string; user: PeerUser; params: P; ctx: Context }
53
+
54
+ /** Options of `multiplayer()`. */
55
+ export interface MultiplayerOptions<P> {
56
+ /** Who this connection is. Required: a peer can't name itself. */
57
+ user: (args: { ctx: Context; params: P; id: string }) => PeerUser | Promise<PeerUser>
58
+ /** The relay's event kinds and what each may carry. Nothing else is relayed. */
59
+ relay?: Record<string, StandardSchemaV1>
60
+ /** A transport's credential, handed to the client with its id. `livekitTicket`, `socketioTicket`. */
61
+ ticket?: (args: TicketArgs<P>) => unknown
62
+ /** Bytes one peer's `state` may be. Default 4096. */
63
+ state?: number
64
+ }
65
+
66
+ /** A channel's room as far as the fragment uses it: the app's own messages are none of its business. */
67
+ interface MpRoom { publish(name: string, message: unknown, options?: PublishOptions): Promise<void> }
68
+
69
+ /** `client`, `server`, `on` and `left` to spread into a `channel`. Every message is `mp.*`, and all but `mp.relay` are volatile. */
70
+ export function multiplayer<P = unknown>(options: MultiplayerOptions<P>) {
71
+ const { user, relay = {}, ticket, state = STATE } = options
72
+ type Arrived<M> = { msg: M; id: string; ctx: Context; params: P; room: MpRoom }
73
+ const people = new Map<string, PeerUser>()
74
+ const who = async (args: { ctx: Context; params: P; id: string }) => {
75
+ const known = people.get(args.id)
76
+ if (known) return known
77
+ const made = await user(args)
78
+ if (!made || typeof made.id !== 'string') throw new Error('`user` must return an object with an `id`')
79
+ people.set(args.id, made)
80
+ return made
81
+ }
82
+ const peer = async (a: { id: string; ctx: Context; params: P }, value: unknown) => ({ id: a.id, user: await who(a), state: value, at: Date.now() })
83
+ const volatile = { volatile: true }
84
+ const relaySchema = schema(v => {
85
+ if (!v || typeof v.kind !== 'string' || !Object.hasOwn(relay, v.kind) || !within(v.data, RELAY)) return no('that relay kind is not declared')
86
+ return { kind: v.kind, data: v.data }
87
+ })
88
+ return {
89
+ client: {
90
+ 'mp.hello': stateful(state),
91
+ 'mp.state': stateful(state),
92
+ 'mp.answer': answerSchema,
93
+ 'mp.cursor': cursorSchema,
94
+ 'mp.relay': relaySchema,
95
+ 'mp.signal': signalSchema,
96
+ },
97
+ server: {
98
+ 'mp.you': anything,
99
+ 'mp.peer': anything,
100
+ 'mp.gone': anything,
101
+ 'mp.ask': anything,
102
+ 'mp.cursor': anything,
103
+ 'mp.relay': anything,
104
+ 'mp.signal': anything,
105
+ },
106
+ on: {
107
+ /** A connection arrives, or comes back: it learns its id and everyone hears it. */
108
+ 'mp.hello': async ({ msg, id, ctx, params, room }: Arrived<{ state: unknown }>) => {
109
+ const me = await peer({ id, ctx, params }, msg.state)
110
+ await room.publish('mp.you', { id, ticket: await ticket?.({ id, user: me.user, params, ctx }) }, { to: id, volatile: true })
111
+ await room.publish('mp.peer', me, { ...volatile, exceptSelf: true })
112
+ await room.publish('mp.ask', { id }, { ...volatile, exceptSelf: true })
113
+ },
114
+ 'mp.state': async ({ msg, id, ctx, params, room }: Arrived<{ state: unknown }>) => room.publish('mp.peer', await peer({ id, ctx, params }, msg.state), { ...volatile, exceptSelf: true }),
115
+ 'mp.answer': async ({ msg, id, ctx, params, room }: Arrived<{ to: string; state: unknown }>) => room.publish('mp.peer', await peer({ id, ctx, params }, msg.state), { ...volatile, to: msg.to }),
116
+ 'mp.cursor': ({ msg, id, room }: Pick<Arrived<CursorAt | null>, 'msg' | 'id' | 'room'>) => room.publish('mp.cursor', { id, at: msg }, { ...volatile, exceptSelf: true }),
117
+ 'mp.relay': async ({ msg, id, room }: Pick<Arrived<{ kind: string; data: unknown }>, 'msg' | 'id' | 'room'>) => {
118
+ const r = await relay[msg.kind]!['~standard'].validate(msg.data) // `relaySchema` refused any kind that isn't declared
119
+ if (r.issues) throw new StartError('Q002', `\`${msg.kind}\` refused its data`)
120
+ await room.publish('mp.relay', { id, kind: msg.kind, data: r.value }, { exceptSelf: true })
121
+ },
122
+ 'mp.signal': ({ msg, id, room }: Pick<Arrived<{ to: string; data: unknown }>, 'msg' | 'id' | 'room'>) => room.publish('mp.signal', { from: id, data: msg.data }, { ...volatile, to: msg.to }),
123
+ },
124
+ left: async ({ id, room }: Pick<Arrived<unknown>, 'id' | 'room'>) => {
125
+ people.delete(id)
126
+ await room.publish('mp.gone', { id }, volatile)
127
+ },
128
+ }
129
+ }
130
+
131
+ // ---- tickets --------------------------------------------------------------------------------------------------------
132
+
133
+ const b64 = (bytes: Uint8Array) => btoa(String.fromCharCode(...bytes)).replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/, '')
134
+ const hmac = (secret: string) => crypto.subtle.importKey('raw', new TextEncoder().encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign', 'verify'])
135
+
136
+ /** An HS256 JWT, which is what LiveKit reads and what a relay can check with one shared secret. */
137
+ export async function signTicket(claims: Record<string, unknown>, secret: string): Promise<string> {
138
+ const text = (v: unknown) => b64(new TextEncoder().encode(JSON.stringify(v)))
139
+ const body = `${text({ alg: 'HS256', typ: 'JWT' })}.${text(claims)}`
140
+ return `${body}.${b64(new Uint8Array(await crypto.subtle.sign('HMAC', await hmac(secret), new TextEncoder().encode(body))))}`
141
+ }
142
+
143
+ /** The claims, or null: a bad signature, another algorithm, or past `exp`. */
144
+ export async function verifyTicket(ticket: unknown, secret: string): Promise<Record<string, unknown> | null> {
145
+ const [head = '', body = '', sig] = String(ticket).split('.')
146
+ if (!sig) return null
147
+ const bytes = (s: string) => Uint8Array.from(atob(s.replaceAll('-', '+').replaceAll('_', '/')), c => c.charCodeAt(0))
148
+ try {
149
+ if (JSON.parse(new TextDecoder().decode(bytes(head))).alg !== 'HS256') return null
150
+ if (!(await crypto.subtle.verify('HMAC', await hmac(secret), bytes(sig), new TextEncoder().encode(`${head}.${body}`)))) return null
151
+ const claims = JSON.parse(new TextDecoder().decode(bytes(body))) as Record<string, unknown>
152
+ return typeof claims.exp === 'number' && claims.exp * 1000 > Date.now() ? claims : null
153
+ } catch {
154
+ return null
155
+ }
156
+ }
157
+
158
+ /** A name for a room a third party can hold: stable, and it says nothing of the params. */
159
+ export async function roomId(params: unknown): Promise<string> {
160
+ return b64(new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(JSON.stringify(params ?? null))))).slice(0, 32)
161
+ }