@weft-js/multiplayer 0.1.1 → 0.1.4
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 +25 -18
- 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
package/README.md
CHANGED
|
@@ -1,9 +1,441 @@
|
|
|
1
|
-
|
|
1
|
+
<p align="center">
|
|
2
|
+
<a href="https://github.com/Ravonus/weft">
|
|
3
|
+
<img src="https://raw.githubusercontent.com/Ravonus/weft/main/site/public/logo.svg" width="88" height="88" alt="Weft">
|
|
4
|
+
</a>
|
|
5
|
+
</p>
|
|
2
6
|
|
|
3
|
-
>
|
|
7
|
+
<h1 align="center">@weft-js/multiplayer</h1>
|
|
4
8
|
|
|
5
|
-
|
|
9
|
+
<p align="center">
|
|
10
|
+
<strong>Presence, live cursors, a typed event relay and synced scroll, on a Weft <code>channel</code>.</strong><br>
|
|
11
|
+
Who a peer is comes from the server, never from the client, and every frame is checked before it reaches a page.
|
|
12
|
+
</p>
|
|
13
|
+
|
|
14
|
+
<p align="center">
|
|
15
|
+
<a href="https://www.npmjs.com/package/@weft-js/multiplayer"><img src="https://img.shields.io/npm/v/@weft-js/multiplayer?color=145c3c&label=npm" alt="npm version"></a>
|
|
16
|
+
<a href="https://github.com/Ravonus/weft/blob/main/LICENSE"><img src="https://img.shields.io/badge/license-MIT-145c3c" alt="MIT license"></a>
|
|
17
|
+
<a href="https://github.com/Ravonus/weft/actions"><img src="https://img.shields.io/github/actions/workflow/status/Ravonus/weft/quality.yml?branch=main&label=checks" alt="checks"></a>
|
|
18
|
+
</p>
|
|
19
|
+
|
|
20
|
+
<p align="center">
|
|
21
|
+
<a href="#quick-start">Quick start</a> ·
|
|
22
|
+
<a href="https://github.com/Ravonus/weft/tree/main/docs">Docs</a> ·
|
|
23
|
+
<a href="https://github.com/Ravonus/weft/tree/main/examples">Examples</a> ·
|
|
24
|
+
<a href="https://github.com/Ravonus/weft/blob/main/CHANGELOG.md">Changelog</a> ·
|
|
25
|
+
<a href="https://github.com/Ravonus/weft">GitHub</a>
|
|
26
|
+
</p>
|
|
27
|
+
|
|
28
|
+
> [!IMPORTANT]
|
|
29
|
+
> **Early release (0.1.x).** APIs can change between minor versions. Pin the version and read the
|
|
30
|
+
> [changelog](https://github.com/Ravonus/weft/blob/main/CHANGELOG.md) before you upgrade.
|
|
31
|
+
|
|
32
|
+
A shared document needs more than messages: who else is here, where their pointer is, what they just did. This package
|
|
33
|
+
adds those to a `channel` from `@weft-js/start`. On the server, `multiplayer()` returns the message schemas and
|
|
34
|
+
handlers to spread into your channel. On the page, `presence()`, `cursors()`, `relay()` and `syncedScroll()` read and
|
|
35
|
+
write them. It is a library, not a plugin: there is nothing to put in `weft.config.ts`.
|
|
36
|
+
|
|
37
|
+
## Highlights
|
|
38
|
+
|
|
39
|
+
- **Identity from the server.** A peer's `user` comes from your `user({ ctx, params, id })` on the server. A client
|
|
40
|
+
only names its own state and cursor, and every frame is stamped with the connection that sent it.
|
|
41
|
+
- **No roster on the server.** Each client answers an `ask` and sends a heartbeat. Peers that stop beating expire, so
|
|
42
|
+
any realtime bus works and the peers of an instance that died simply age out.
|
|
43
|
+
- **Cursors anchored to elements.** A position is a fraction of the nearest `[data-anchor]` element, so a pointer on a
|
|
44
|
+
card lands on the same card on a screen of another size, or doesn't show when that card isn't on screen.
|
|
45
|
+
- **A typed relay.** `relay()` sends named events that the server checks against a schema per kind. A kind you didn't
|
|
46
|
+
declare is refused. Handlers get data, never a synthetic DOM event.
|
|
47
|
+
- **Bounded by default.** Peer state is 4096 bytes, relay data 2048 bytes, a signal 16384 bytes, an anchor id 128
|
|
48
|
+
characters. Cursors send at most 12 times a second, under a channel's default limit of 20.
|
|
49
|
+
- **Pluggable cursor transport.** Cursors go over the channel's socket. Pass a `transport` to move them to WebRTC,
|
|
50
|
+
LiveKit or a Socket.IO relay; the socket carries them until that transport is up.
|
|
51
|
+
- **A drop-in `<Cursors>`.** Draws everyone else's pointer with their name and a colour that's the same for them
|
|
52
|
+
everywhere. `aria-hidden`, and instant under reduced motion.
|
|
53
|
+
|
|
54
|
+
## Contents
|
|
55
|
+
|
|
56
|
+
- [Install](#install)
|
|
57
|
+
- [Quick start](#quick-start)
|
|
58
|
+
- [The server half](#the-server-half)
|
|
59
|
+
- [Presence](#presence)
|
|
60
|
+
- [Cursors](#cursors)
|
|
61
|
+
- [The relay](#the-relay)
|
|
62
|
+
- [Synced scroll and hash](#synced-scroll-and-hash)
|
|
63
|
+
- [Transports](#transports)
|
|
64
|
+
- [API reference](#api-reference)
|
|
65
|
+
- [Limits](#limits)
|
|
66
|
+
- [Troubleshooting](#troubleshooting)
|
|
67
|
+
- [End-to-end tests](#end-to-end-tests)
|
|
68
|
+
- [Related packages](#related-packages)
|
|
69
|
+
|
|
70
|
+
## Install
|
|
6
71
|
|
|
7
72
|
```bash
|
|
73
|
+
# npm
|
|
8
74
|
npm install @weft-js/multiplayer
|
|
75
|
+
|
|
76
|
+
# pnpm
|
|
77
|
+
pnpm add @weft-js/multiplayer
|
|
78
|
+
|
|
79
|
+
# bun
|
|
80
|
+
bun add @weft-js/multiplayer
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
It's a library, not a plugin: there's nothing to add to `weft.config.ts`. You import it in a page and in
|
|
84
|
+
`src/realtime.server.ts`.
|
|
85
|
+
|
|
86
|
+
`@weft-js/core` and `@weft-js/start` are peer dependencies; every Weft app has them.
|
|
87
|
+
|
|
88
|
+
## Quick start
|
|
89
|
+
|
|
90
|
+
The server half goes into a `channel`, next to any messages of your own:
|
|
91
|
+
|
|
92
|
+
```ts
|
|
93
|
+
// src/server/doc.ts
|
|
94
|
+
import { channel } from '@weft-js/start'
|
|
95
|
+
import { multiplayer } from '@weft-js/multiplayer/server'
|
|
96
|
+
import { z } from 'zod'
|
|
97
|
+
|
|
98
|
+
const mp = multiplayer<{ id: string }>({
|
|
99
|
+
user: ({ ctx }) => ({ id: ctx.user!.id, name: ctx.user!.name }),
|
|
100
|
+
relay: { ping: z.object({ at: z.string().max(64) }) },
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
export const doc = channel({
|
|
104
|
+
params: z.object({ id: z.string().max(64) }),
|
|
105
|
+
authorize: ({ ctx }) => !!ctx.user,
|
|
106
|
+
client: { ...mp.client },
|
|
107
|
+
server: { ...mp.server },
|
|
108
|
+
on: { ...mp.on },
|
|
109
|
+
left: mp.left,
|
|
110
|
+
})
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
The page reads it:
|
|
114
|
+
|
|
115
|
+
```tsx
|
|
116
|
+
// src/routes/room.tsx
|
|
117
|
+
import { state } from '@weft-js/core'
|
|
118
|
+
import { cursors, presence, relay } from '@weft-js/multiplayer'
|
|
119
|
+
import { Cursors } from '@weft-js/multiplayer/ui'
|
|
120
|
+
import { doc } from '#/server/doc'
|
|
121
|
+
|
|
122
|
+
export default function Room() {
|
|
123
|
+
const chat = doc.connect({ id: 'lobby' })
|
|
124
|
+
const typing = state(false)
|
|
125
|
+
const live = presence<{ typing: boolean }>(chat, {
|
|
126
|
+
state: () => ({ typing: typing.value }),
|
|
127
|
+
})
|
|
128
|
+
const pointers = cursors(live)
|
|
129
|
+
const events = relay<{ ping: { at: string } }>(chat)
|
|
130
|
+
events.on('ping', (e) =>
|
|
131
|
+
console.log(`${e.peer} pinged ${e.data.at}`),
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
return (
|
|
135
|
+
<main>
|
|
136
|
+
<ul>
|
|
137
|
+
{live.peers.value.map((p) => (
|
|
138
|
+
<li key={p.id}>
|
|
139
|
+
{p.user.name}
|
|
140
|
+
{p.state?.typing ? ' (typing)' : ''}
|
|
141
|
+
</li>
|
|
142
|
+
))}
|
|
143
|
+
</ul>
|
|
144
|
+
<section
|
|
145
|
+
data-anchor="board"
|
|
146
|
+
style="height:300px"
|
|
147
|
+
/>
|
|
148
|
+
<button onClick={() => events.emit('ping', { at: 'board' })}>
|
|
149
|
+
Ping
|
|
150
|
+
</button>
|
|
151
|
+
<Cursors of={pointers} />
|
|
152
|
+
</main>
|
|
153
|
+
)
|
|
154
|
+
}
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
Open the page in two browsers signed in as different users: each sees the other in the list, their pointer over the
|
|
158
|
+
board, and their pings.
|
|
159
|
+
|
|
160
|
+
## The server half
|
|
161
|
+
|
|
162
|
+
`multiplayer(options)` from `@weft-js/multiplayer/server` returns four things to spread into `channel({ … })`:
|
|
163
|
+
|
|
164
|
+
| Field | Goes into | What it is |
|
|
165
|
+
| -------- | ----------------- | ---------- |
|
|
166
|
+
| `client` | `client: { … }` | Schemas for what a client may send: `mp.hello`, `mp.state`, `mp.answer`, `mp.cursor`, `mp.relay`, `mp.signal`. |
|
|
167
|
+
| `server` | `server: { … }` | The messages the server sends back: `mp.you`, `mp.peer`, `mp.gone`, `mp.ask`, `mp.cursor`, `mp.relay`, `mp.signal`. |
|
|
168
|
+
| `on` | `on: { … }` | A handler per client message. |
|
|
169
|
+
| `left` | `left` | Tells the room a connection left (`mp.gone`) and forgets who it was. |
|
|
170
|
+
|
|
171
|
+
Every name starts with `mp.`, so your own messages sit beside them. All of them except `mp.relay` are published as
|
|
172
|
+
`volatile`.
|
|
173
|
+
|
|
174
|
+
| Option | Type | Default | What it does |
|
|
175
|
+
| -------- | --------------------------------------------------------- | -------- | ------------ |
|
|
176
|
+
| `user` | `({ ctx, params, id }) => PeerUser \| Promise<PeerUser>` | required | Who this connection is. Must return an object with a string `id`. Runs once per connection, then cached until `left`. |
|
|
177
|
+
| `relay` | `Record<string, StandardSchemaV1>` | `{}` | The relay's event kinds and the schema each one's data must pass. Nothing else is relayed. |
|
|
178
|
+
| `ticket` | `({ id, user, params, ctx }) => unknown` | none | A credential for a cursor transport, handed to the client with its id: `livekitTicket()`, `socketioTicket()`. |
|
|
179
|
+
| `state` | `number` | `4096` | Bytes one peer's `state` may be, as JSON. |
|
|
180
|
+
|
|
181
|
+
What happens on a join: the client sends `mp.hello` with its state. The server answers `mp.you` (the connection's id,
|
|
182
|
+
and the ticket if there is one) to that client only, tells everyone else `mp.peer`, and sends `mp.ask` so each of them
|
|
183
|
+
answers with their own state.
|
|
184
|
+
|
|
185
|
+
The server module also exports the ticket helpers that transports build on:
|
|
186
|
+
|
|
187
|
+
| Export | What it does |
|
|
188
|
+
| ------------------------------- | ------------ |
|
|
189
|
+
| `signTicket(claims, secret)` | An HS256 JWT over `claims`, the format LiveKit reads and a relay can check with one shared secret. |
|
|
190
|
+
| `verifyTicket(ticket, secret)` | The claims, or `null` for a bad signature, another algorithm, or a past `exp`. A ticket without a numeric `exp` is refused. |
|
|
191
|
+
| `roomId(params)` | A stable name for a room that a third party holds: 32 characters of the SHA-256 of the params, so it says nothing about them. |
|
|
192
|
+
| `cursorSchema` | The schema a cursor must pass: `{ x, y }` fractions in `[-0.5, 1.5]`, an optional anchor id of up to 128 characters and style of up to 16, or `null`. |
|
|
193
|
+
|
|
194
|
+
## Presence
|
|
195
|
+
|
|
196
|
+
```ts
|
|
197
|
+
const live = presence<S>(conn, options)
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
`conn` is what `channel.connect()` returns. `presence` announces this connection, follows `state`, and keeps the list
|
|
201
|
+
of everyone else current.
|
|
202
|
+
|
|
203
|
+
| Option | Type | Default | What it does |
|
|
204
|
+
| ----------- | -------------------- | ------- | ------------ |
|
|
205
|
+
| `state` | `S \| () => S` | none | This connection's own state. A function is followed: a change is sent, coalesced over 50 ms. It goes to everyone on every change, so keep it small. |
|
|
206
|
+
| `beat` | `number` (ms) | `15000` | Time between heartbeats. A peer unheard for three beats expires. |
|
|
207
|
+
| `transport` | `PeerTransport` | none | Carries cursors between peers, with the socket as the fallback. See [Transports](#transports). |
|
|
208
|
+
|
|
209
|
+
| Returns | Type | What it is |
|
|
210
|
+
| ------------ | ---------------------------- | ---------- |
|
|
211
|
+
| `peers` | `Derived<Peer<S>[]>` | Everyone else here, oldest first: `{ id, user, state, at }`. `at` is stamped by this machine's clock. |
|
|
212
|
+
| `self` | `Derived<string \| undefined>` | This connection's id, once the server has said. |
|
|
213
|
+
| `via` | `Derived<string>` | Which way cursors go: `'socket'`, or the transport's name once it is up. |
|
|
214
|
+
| `connection` | the `conn` you passed | |
|
|
215
|
+
|
|
216
|
+
When the connection opens or moves to another room, the list empties and `mp.hello` goes out again. A channel reset
|
|
217
|
+
(`onReset`) does the same.
|
|
218
|
+
|
|
219
|
+
## Cursors
|
|
220
|
+
|
|
221
|
+
```ts
|
|
222
|
+
const pointers = cursors(live, options)
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
`cursors` follows this page's pointer, sends it (latest wins), and tracks everyone else's. A pointer over an element
|
|
226
|
+
with the anchor attribute is sent as a fraction of that element, scroll included. Elsewhere it is a fraction of the
|
|
227
|
+
viewport. When the pointer leaves the page, `null` is sent and the cursor disappears for everyone.
|
|
228
|
+
|
|
229
|
+
| Option | Type | Default | What it does |
|
|
230
|
+
| -------- | ---------------------------------- | --------------- | ------------ |
|
|
231
|
+
| `anchor` | `string` | `'data-anchor'` | The attribute that holds an element's id. Use the same ids on every client. |
|
|
232
|
+
| `rate` | `number` | `12` | Sends a second. 12 keeps a socket under its default 20 a second, with room for everything else. |
|
|
233
|
+
| `idle` | `number` (ms) | `5000` | A cursor that hasn't moved for this long is marked `idle` (`<Cursors>` fades it). |
|
|
234
|
+
| `root` | `() => HTMLElement \| Document` | the document | Where the pointer is followed. |
|
|
235
|
+
| `style` | `string` | none | Sent with each position, up to 16 characters; yours to interpret. |
|
|
236
|
+
|
|
237
|
+
`pointers.list` is a `Derived<LiveCursor[]>`, each with `id`, `peer` (missing until their presence arrives), `at`,
|
|
238
|
+
`left`, `top` (page pixels on this screen), `moved` and `idle`. A cursor whose anchor isn't on this page isn't in the
|
|
239
|
+
list: drawing it at (0, 0) would be wrong. Scrolling and resizing re-place them.
|
|
240
|
+
|
|
241
|
+
`<Cursors of={pointers} />` from `@weft-js/multiplayer/ui` draws the list: a fixed, `aria-hidden` overlay with an arrow
|
|
242
|
+
per peer, their `user.name` in a label, and a colour from `hue(user.id)`. Cursors glide over 120 ms, and jump under
|
|
243
|
+
`prefers-reduced-motion`. Draw your own from `pointers.list` if you want a different look.
|
|
244
|
+
|
|
245
|
+
## The relay
|
|
246
|
+
|
|
247
|
+
```ts
|
|
248
|
+
const events = relay<{
|
|
249
|
+
ping: { at: string }
|
|
250
|
+
select: { id: string }
|
|
251
|
+
}>(conn)
|
|
252
|
+
events.emit('select', { id: 'card-3' })
|
|
253
|
+
const stop = events.on('select', ({ peer, kind, data }) =>
|
|
254
|
+
highlight(peer, data.id),
|
|
255
|
+
)
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
`emit` sends an event, and the server checks it against the kind's schema in `multiplayer({ relay })` before
|
|
259
|
+
publishing it to everyone but the sender. Unlike cursors and state, relay events are not volatile. `on` returns its
|
|
260
|
+
stop function, and the component that called it stops it on cleanup. The type parameter is yours to keep in step with
|
|
261
|
+
the server's schemas.
|
|
262
|
+
|
|
263
|
+
## Synced scroll and hash
|
|
264
|
+
|
|
265
|
+
Both write to a box that you share, such as a `sharedState`:
|
|
266
|
+
|
|
267
|
+
```tsx
|
|
268
|
+
import { sharedState } from '@weft-js/start'
|
|
269
|
+
import { syncedScroll, syncedHash } from '@weft-js/multiplayer'
|
|
270
|
+
|
|
271
|
+
// One scroll position per document, as a fraction of the scrollable
|
|
272
|
+
// height.
|
|
273
|
+
syncedScroll(scrollBox) // the window
|
|
274
|
+
syncedScroll(scrollBox, () => panel) // or a scrolling element
|
|
275
|
+
syncedHash(hashBox) // location.hash
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
`syncedScroll(box, element?)` stores the position as a fraction, rounded to four places, so screens of different
|
|
279
|
+
heights land on the same place. A scroll it causes itself isn't written back for 150 ms. `syncedHash(box)` keeps
|
|
280
|
+
`location.hash` and the box the same. Both do nothing during server rendering.
|
|
281
|
+
|
|
282
|
+
## Transports
|
|
283
|
+
|
|
284
|
+
By default cursors go over the channel's socket, through the server. A transport moves them off it:
|
|
285
|
+
|
|
286
|
+
| Package | Transport | Needs |
|
|
287
|
+
| ------- | --------- | ----- |
|
|
288
|
+
| [`@weft-js/realtime-webrtc`](https://www.npmjs.com/package/@weft-js/realtime-webrtc) <sup>[source](https://github.com/Ravonus/weft/tree/main/packages/realtime-webrtc)</sup> | `webrtc({ iceServers, max })`: browser to browser | STUN, and TURN across networks |
|
|
289
|
+
| [`@weft-js/realtime-livekit`](https://www.npmjs.com/package/@weft-js/realtime-livekit) <sup>[source](https://github.com/Ravonus/weft/tree/main/packages/realtime-livekit)</sup> | `livekit({ url })`, ticket `livekitTicket()` | A LiveKit server or LiveKit Cloud |
|
|
290
|
+
| [`@weft-js/realtime-socketio`](https://www.npmjs.com/package/@weft-js/realtime-socketio) <sup>[source](https://github.com/Ravonus/weft/tree/main/packages/realtime-socketio)</sup> | `socketio({ url, path })`, ticket `socketioTicket({ secret })` | A Socket.IO server you run, with `relay(io, { secret })` |
|
|
291
|
+
|
|
292
|
+
```ts
|
|
293
|
+
const live = presence(chat, { transport: webrtc() })
|
|
294
|
+
// 'socket' until every peer is connected, then 'webrtc'
|
|
295
|
+
live.via.value
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
Only cursors ride a transport. State, the relay and signalling stay on the server, which checks them. A frame that
|
|
299
|
+
arrives off a transport is read as hostile: it must parse as a cursor (`readCursor`) and come from a connection in the
|
|
300
|
+
room, or it is dropped.
|
|
301
|
+
|
|
302
|
+
To write your own, implement `PeerTransport`: `{ name, open(room: PeerRoom): PeerLink }`. `room` gives you `self`,
|
|
303
|
+
`ticket`, `peers`, `signal(to, data)` and `onSignal(fn)` (which go through the server) and `receive(from, text)`.
|
|
304
|
+
Return `{ up, send, close }`; `up` is true only while a `send` reaches every peer. `transportSuite` from
|
|
305
|
+
`@weft-js/multiplayer/test` checks one:
|
|
306
|
+
|
|
307
|
+
```ts
|
|
308
|
+
import { transportSuite } from '@weft-js/multiplayer/test'
|
|
309
|
+
|
|
310
|
+
const checks = await transportSuite({
|
|
311
|
+
make: () => myTransport(),
|
|
312
|
+
ticket: (peer) => makeTicket(peer),
|
|
313
|
+
})
|
|
314
|
+
expect(checks.filter((c) => !c.ok)).toEqual([])
|
|
315
|
+
```
|
|
316
|
+
|
|
317
|
+
It opens two peers over a fake room and returns one `Check` (`{ name, ok, detail }`) per requirement: both links come
|
|
318
|
+
up, a frame reaches the other peer named by the transport, it doesn't come back to its sender, frames go both ways,
|
|
319
|
+
and a closed link sends nothing.
|
|
320
|
+
|
|
321
|
+
## API reference
|
|
322
|
+
|
|
323
|
+
| Entry | Export | What it is |
|
|
324
|
+
| ----- | ------ | ---------- |
|
|
325
|
+
| `@weft-js/multiplayer` | `presence(conn, options?)` | Who else is on the connection. |
|
|
326
|
+
| | `cursors(presence, options?)` | Sends this pointer and tracks the others. |
|
|
327
|
+
| | `relay<K>(conn)` | `{ emit, on }` for typed events the server validates. |
|
|
328
|
+
| | `syncedScroll(box, element?)` | A scroll position in a shared box, as a fraction. |
|
|
329
|
+
| | `syncedHash(box)` | `location.hash` in a shared box. |
|
|
330
|
+
| | `locate(target, x, y, attr?, view?)` | A pointer position as a `CursorAt`, relative to the nearest anchor. |
|
|
331
|
+
| | `resolve(at, attr?, view?)` | A `CursorAt` back to `{ left, top }` on this screen, or `null` when the anchor isn't here. |
|
|
332
|
+
| | `hue(id)` | A peer's colour, 0 to 359, the same everywhere. |
|
|
333
|
+
| | `expire(list, now, ttl)` | The peer list without those unheard for `ttl`. |
|
|
334
|
+
| | `applyPeer(list, peer)` | The list with that peer added or replaced. |
|
|
335
|
+
| | `readCursor(text)` | A transport frame as a cursor, `null` for "left", or `undefined` when refused. |
|
|
336
|
+
| | types | `Peer`, `PeerUser`, `CursorAt`, `Presence`, `PresenceOptions`, `CursorOptions`, `Cursors`, `LiveCursor`, `RelayEvent`, `PeerRoom`, `PeerLink`, `PeerTransport` |
|
|
337
|
+
| `@weft-js/multiplayer/server` | `multiplayer(options)` | `{ client, server, on, left }` for a `channel`. |
|
|
338
|
+
| | `signTicket`, `verifyTicket`, `roomId`, `cursorSchema` | See [The server half](#the-server-half). |
|
|
339
|
+
| | types | `MultiplayerOptions`, `TicketArgs`, `Peer`, `PeerUser`, `CursorAt` |
|
|
340
|
+
| `@weft-js/multiplayer/ui` | `Cursors` | `<Cursors of={cursors(…)} />`. |
|
|
341
|
+
| `@weft-js/multiplayer/test` | `transportSuite({ make, ticket? })` | The conformance checks for a `PeerTransport`. Types `Check`, `TransportSuiteOptions`. |
|
|
342
|
+
|
|
343
|
+
## Recipes
|
|
344
|
+
|
|
345
|
+
**Show who is typing.**
|
|
346
|
+
|
|
347
|
+
```tsx
|
|
348
|
+
const typing = state(false)
|
|
349
|
+
const live = presence<{ typing: boolean }>(chat, {
|
|
350
|
+
state: () => ({ typing: typing.value }),
|
|
351
|
+
})
|
|
352
|
+
const names = derived(() =>
|
|
353
|
+
live.peers.value
|
|
354
|
+
.filter((p) => p.state?.typing)
|
|
355
|
+
.map((p) => p.user.name),
|
|
356
|
+
)
|
|
357
|
+
```
|
|
358
|
+
|
|
359
|
+
**Follow the presenter's scroll.**
|
|
360
|
+
|
|
361
|
+
```ts
|
|
362
|
+
// src/server/deck.ts
|
|
363
|
+
export const deckScroll = sharedState('deck-scroll', {
|
|
364
|
+
key: z.string().max(64),
|
|
365
|
+
value: z.number().min(0).max(1),
|
|
366
|
+
initial: 0,
|
|
367
|
+
authorize: { read: isParticipant, write: isPresenter },
|
|
368
|
+
})
|
|
369
|
+
```
|
|
370
|
+
|
|
371
|
+
```tsx
|
|
372
|
+
syncedScroll(deckScroll.connect(() => props.id))
|
|
373
|
+
```
|
|
374
|
+
|
|
375
|
+
**Cursors only inside one panel.**
|
|
376
|
+
|
|
377
|
+
```tsx
|
|
378
|
+
const pointers = cursors(live, {
|
|
379
|
+
root: () => board,
|
|
380
|
+
anchor: 'data-anchor',
|
|
381
|
+
})
|
|
9
382
|
```
|
|
383
|
+
|
|
384
|
+
## Limits
|
|
385
|
+
|
|
386
|
+
| What | Limit | Where it is enforced |
|
|
387
|
+
| ---- | ----- | -------------------- |
|
|
388
|
+
| One peer's `state` | 4096 bytes of JSON (option `state`) | Server schema |
|
|
389
|
+
| Relay `data` | 2048 bytes of JSON, a declared kind only | Server schema, then the kind's schema |
|
|
390
|
+
| A signal (transport offers and candidates) | 16384 bytes | Server schema |
|
|
391
|
+
| A cursor | `x`, `y` in `[-0.5, 1.5]`; anchor id up to 128 characters; style up to 16 | Server schema and `readCursor` |
|
|
392
|
+
| Cursor sends | 12 a second by default | Client |
|
|
393
|
+
| Peer expiry | three missed heartbeats (45 s by default) | Client |
|
|
394
|
+
|
|
395
|
+
The channel's own limits apply on top: 20 messages a second per socket by default (R008), raised with `limits`. A
|
|
396
|
+
reconnect replays at most what the channel keeps, and presence rebuilds itself on a reset, so nothing here is stored.
|
|
397
|
+
|
|
398
|
+
## Troubleshooting
|
|
399
|
+
|
|
400
|
+
| Symptom | Cause | Fix |
|
|
401
|
+
| ------- | ----- | --- |
|
|
402
|
+
| A server error ``\`user\` must return an object with an `id` `` | `user` returned nothing or a non-string `id`. | Return `{ id, name? }`, for example from `ctx.user`. |
|
|
403
|
+
| `emit` rejects with Q002 `` `kind` refused its data `` | The data failed that kind's schema. | Send what the schema accepts. |
|
|
404
|
+
| `emit` rejects with a schema issue "that relay kind is not declared" | The kind isn't in `multiplayer({ relay })`, or its data is over 2048 bytes. | Declare it on the server. |
|
|
405
|
+
| R008 on the channel | Over the channel's rate limit. | Lower `rate` in `cursors()`, or raise `limits.perSecond` on the channel. |
|
|
406
|
+
| A cursor never shows | Its anchor id isn't on this page, or the attribute differs between clients. | Use the same `anchor` attribute and ids everywhere. |
|
|
407
|
+
| `via` stays `'socket'` | The transport isn't up for every peer yet. Cursors still flow through the server. | See the transport's README. |
|
|
408
|
+
|
|
409
|
+
## End-to-end tests
|
|
410
|
+
|
|
411
|
+
From the repository, `pnpm --filter @weft-js/multiplayer test:e2e prepare <dir>` prepares a room;
|
|
412
|
+
`pnpm --filter @weft-js/multiplayer test:e2e run <dir>` runs it in two browsers. The entrypoint
|
|
413
|
+
stays `.mjs` because Node runs it directly, outside Vitest.
|
|
414
|
+
|
|
415
|
+
`run` takes `--runtimes node,bun,deno`, `--bus memory,redis,nats` (each browser talks to its own instance, so
|
|
416
|
+
everything crosses the bus) and `--via webrtc|socketio|livekit` for a transport. `--redis <bin>` and `--nats <bin>` run
|
|
417
|
+
a throwaway server; `REDIS_URL` and `NATS_URL` use yours. `--via livekit` needs `LIVEKIT_URL`, `LIVEKIT_API_KEY` and
|
|
418
|
+
`LIVEKIT_API_SECRET`.
|
|
419
|
+
|
|
420
|
+
## Related packages
|
|
421
|
+
|
|
422
|
+
| Package | What it is |
|
|
423
|
+
| ------- | ---------- |
|
|
424
|
+
| [`@weft-js/start`](https://www.npmjs.com/package/@weft-js/start) | `channel`, `sharedState` and `realtime()`, which this builds on. |
|
|
425
|
+
| [`@weft-js/realtime-webrtc`](https://www.npmjs.com/package/@weft-js/realtime-webrtc) | Cursors browser to browser over WebRTC data channels. |
|
|
426
|
+
| [`@weft-js/realtime-livekit`](https://www.npmjs.com/package/@weft-js/realtime-livekit) | Cursors over a LiveKit room, which also carries audio and video. |
|
|
427
|
+
| [`@weft-js/realtime-socketio`](https://www.npmjs.com/package/@weft-js/realtime-socketio) | Cursors through a Socket.IO relay you run. |
|
|
428
|
+
| [`@weft-js/realtime-redis`](https://www.npmjs.com/package/@weft-js/realtime-redis) | A `realtime.bus` over Redis, so a room spans several server instances. |
|
|
429
|
+
| [`@weft-js/realtime-nats`](https://www.npmjs.com/package/@weft-js/realtime-nats) | A `realtime.bus` over NATS. |
|
|
430
|
+
| [`@weft-js/realtime-durable-object`](https://www.npmjs.com/package/@weft-js/realtime-durable-object) | A `realtime.bus` on Cloudflare Durable Objects. |
|
|
431
|
+
| [`@weft-js/core`](https://www.npmjs.com/package/@weft-js/core) | The compiler and reactive runtime, the Vite plugin, the plugin system and test utilities. |
|
|
432
|
+
|
|
433
|
+
## Contributing
|
|
434
|
+
|
|
435
|
+
See [CONTRIBUTING.md](https://github.com/Ravonus/weft/blob/main/CONTRIBUTING.md). From the repository,
|
|
436
|
+
`pnpm --filter @weft-js/multiplayer test` runs the unit tests: the server fragment on its own, and the client against
|
|
437
|
+
`@weft-js/core/test`'s fake socket with the real channel client and server.
|
|
438
|
+
|
|
439
|
+
## License
|
|
440
|
+
|
|
441
|
+
[MIT](https://github.com/Ravonus/weft/blob/main/LICENSE)
|
package/package.json
CHANGED
|
@@ -1,8 +1,25 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@weft-js/multiplayer",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.4",
|
|
4
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
|
+
"keywords": [
|
|
6
|
+
"weft"
|
|
7
|
+
],
|
|
5
8
|
"license": "MIT",
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "git+https://github.com/Ravonus/weft.git",
|
|
12
|
+
"directory": "packages/multiplayer"
|
|
13
|
+
},
|
|
14
|
+
"homepage": "https://github.com/Ravonus/weft/tree/main/packages/multiplayer#readme",
|
|
15
|
+
"bugs": {
|
|
16
|
+
"url": "https://github.com/Ravonus/weft/issues"
|
|
17
|
+
},
|
|
18
|
+
"files": [
|
|
19
|
+
"src",
|
|
20
|
+
"README.md",
|
|
21
|
+
"LICENSE"
|
|
22
|
+
],
|
|
6
23
|
"type": "module",
|
|
7
24
|
"exports": {
|
|
8
25
|
".": "./src/index.ts",
|
|
@@ -10,24 +27,14 @@
|
|
|
10
27
|
"./ui": "./src/Cursors.tsx",
|
|
11
28
|
"./test": "./src/conformance.ts"
|
|
12
29
|
},
|
|
30
|
+
"publishConfig": {
|
|
31
|
+
"access": "public"
|
|
32
|
+
},
|
|
13
33
|
"peerDependencies": {
|
|
14
|
-
"@weft-js/
|
|
15
|
-
"@weft-js/
|
|
34
|
+
"@weft-js/core": "^0.1.4",
|
|
35
|
+
"@weft-js/start": "^0.1.4"
|
|
16
36
|
},
|
|
17
37
|
"engines": {
|
|
18
|
-
"node": ">=
|
|
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"
|
|
38
|
+
"node": ">=24"
|
|
32
39
|
}
|
|
33
|
-
}
|
|
40
|
+
}
|
package/src/Cursors.tsx
CHANGED
|
@@ -11,12 +11,23 @@ export function Cursors<S>(props: { of: Tracker<S> }) {
|
|
|
11
11
|
return (
|
|
12
12
|
<div aria-hidden="true" data-scope="cursors">
|
|
13
13
|
<style>{CSS}</style>
|
|
14
|
-
{props.of.list.value.map(c => {
|
|
14
|
+
{props.of.list.value.map((c) => {
|
|
15
15
|
const color = `hsl(${hue(c.peer?.user.id ?? c.id)} 80% 45%)`
|
|
16
16
|
return (
|
|
17
|
-
<div
|
|
18
|
-
|
|
19
|
-
|
|
17
|
+
<div
|
|
18
|
+
key={c.id}
|
|
19
|
+
data-part="cursor"
|
|
20
|
+
data-peer={c.id}
|
|
21
|
+
style={`transform:translate(${c.left}px,${c.top}px);opacity:${c.idle ? 0.25 : 1};color:${color}`}
|
|
22
|
+
>
|
|
23
|
+
<svg width="16" height="18" viewBox="0 0 16 18">
|
|
24
|
+
<path fill="currentColor" stroke="#fff" d="M1 1l5 15 2.5-6L15 8z" />
|
|
25
|
+
</svg>
|
|
26
|
+
{c.peer?.user.name && (
|
|
27
|
+
<span data-part="name" style={`background:${color}`}>
|
|
28
|
+
{c.peer.user.name}
|
|
29
|
+
</span>
|
|
30
|
+
)}
|
|
20
31
|
</div>
|
|
21
32
|
)
|
|
22
33
|
})}
|
package/src/conformance.ts
CHANGED
|
@@ -1,15 +1,21 @@
|
|
|
1
|
-
//
|
|
2
|
-
// against a real relay, or in a page. `make` builds a transport for one named peer.
|
|
1
|
+
// Plain data in, Checks out: it runs in Node against a real relay, or in a page.
|
|
3
2
|
|
|
4
3
|
import { state, type Derived } from '@weft-js/core'
|
|
5
4
|
import type { PeerLink, PeerRoom, PeerTransport } from './index'
|
|
6
5
|
|
|
7
6
|
/** One requirement of the suite, and how the transport did. */
|
|
8
|
-
export interface Check {
|
|
7
|
+
export interface Check {
|
|
8
|
+
name: string
|
|
9
|
+
ok: boolean
|
|
10
|
+
detail: string
|
|
11
|
+
}
|
|
9
12
|
type Got = { from: string; text: string }
|
|
10
13
|
|
|
11
14
|
const settle = async (done: () => boolean, ms = 8000) => {
|
|
12
|
-
for (const end = Date.now() + ms; !done() && Date.now() < end;)
|
|
15
|
+
for (const end = Date.now() + ms; !done() && Date.now() < end;)
|
|
16
|
+
await new Promise((ok) => {
|
|
17
|
+
setTimeout(ok, 10)
|
|
18
|
+
})
|
|
13
19
|
return done()
|
|
14
20
|
}
|
|
15
21
|
|
|
@@ -20,11 +26,24 @@ export interface TransportSuiteOptions {
|
|
|
20
26
|
ticket?: (peer: string) => unknown
|
|
21
27
|
}
|
|
22
28
|
|
|
23
|
-
function fakeRoom(
|
|
29
|
+
function fakeRoom(
|
|
30
|
+
self: string,
|
|
31
|
+
ids: Derived<string[]>,
|
|
32
|
+
ticket: unknown,
|
|
33
|
+
wires: Map<string, Set<(from: string, data: unknown) => void>>,
|
|
34
|
+
got: Got[],
|
|
35
|
+
): PeerRoom {
|
|
24
36
|
return {
|
|
25
|
-
self,
|
|
26
|
-
|
|
27
|
-
|
|
37
|
+
self,
|
|
38
|
+
ticket,
|
|
39
|
+
peers: {
|
|
40
|
+
get value() {
|
|
41
|
+
return ids.value.filter((p) => p !== self)
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
signal: (to, data) => {
|
|
45
|
+
for (const f of wires.get(to) ?? []) queueMicrotask(() => f(self, data))
|
|
46
|
+
},
|
|
28
47
|
onSignal(fn) {
|
|
29
48
|
const set = wires.get(self) ?? new Set()
|
|
30
49
|
wires.set(self, set)
|
|
@@ -41,22 +60,39 @@ export async function transportSuite({ make, ticket }: TransportSuiteOptions): P
|
|
|
41
60
|
const check = (name: string, ok: boolean, detail = '') => void out.push({ name, ok, detail })
|
|
42
61
|
const ids = state(['a', 'b'])
|
|
43
62
|
const wires = new Map<string, Set<(from: string, data: unknown) => void>>()
|
|
44
|
-
const gotA: Got[] = [],
|
|
63
|
+
const gotA: Got[] = [],
|
|
64
|
+
gotB: Got[] = []
|
|
45
65
|
let a: PeerLink | undefined, b: PeerLink | undefined
|
|
46
66
|
try {
|
|
47
67
|
a = (await make('a')).open(fakeRoom('a', ids, await ticket?.('a'), wires, gotA))
|
|
48
68
|
b = (await make('b')).open(fakeRoom('b', ids, await ticket?.('b'), wires, gotB))
|
|
49
|
-
check(
|
|
69
|
+
check(
|
|
70
|
+
'both links come up',
|
|
71
|
+
await settle(() => a!.up.value && b!.up.value, 20_000),
|
|
72
|
+
`${a.up.value} ${b.up.value}`,
|
|
73
|
+
)
|
|
50
74
|
a.send('{"x":1}')
|
|
51
|
-
check(
|
|
52
|
-
|
|
75
|
+
check(
|
|
76
|
+
'a frame reaches the other peer, named by the transport',
|
|
77
|
+
(await settle(() => gotB.length > 0)) && gotB[0]!.from === 'a' && gotB[0]!.text === '{"x":1}',
|
|
78
|
+
JSON.stringify(gotB[0]),
|
|
79
|
+
)
|
|
80
|
+
await new Promise((ok) => {
|
|
81
|
+
setTimeout(ok, 100)
|
|
82
|
+
})
|
|
53
83
|
check('a frame does not come back to its sender', gotA.length === 0, `${gotA.length}`)
|
|
54
84
|
b.send('{"y":2}')
|
|
55
|
-
check(
|
|
85
|
+
check(
|
|
86
|
+
'frames go both ways',
|
|
87
|
+
(await settle(() => gotA.length > 0)) && gotA[0]!.from === 'b',
|
|
88
|
+
JSON.stringify(gotA[0]),
|
|
89
|
+
)
|
|
56
90
|
const before = gotB.length
|
|
57
91
|
a.close()
|
|
58
92
|
a = undefined
|
|
59
|
-
await new Promise(ok => {
|
|
93
|
+
await new Promise((ok) => {
|
|
94
|
+
setTimeout(ok, 100)
|
|
95
|
+
})
|
|
60
96
|
check('a closed link sends nothing', gotB.length === before, `${gotB.length - before}`)
|
|
61
97
|
} catch (e) {
|
|
62
98
|
check('the transport ran', false, String((e as Error | null)?.stack ?? e))
|