@weft-js/multiplayer 0.1.0 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,9 +1,441 @@
1
- # @weft-js/multiplayer
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
- > **Early release (0.1.0).** APIs will change between minor versions.
7
+ <h1 align="center">@weft-js/multiplayer</h1>
4
8
 
5
- Presence, cursors anchored to elements, a typed interaction relay and synced scroll on `channel`. A library, not a plugin.
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.0",
3
+ "version": "0.1.3",
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/start": "^0.1.0",
15
- "@weft-js/core": "^0.1.0"
34
+ "@weft-js/core": "^0.1.3",
35
+ "@weft-js/start": "^0.1.3"
16
36
  },
17
37
  "engines": {
18
38
  "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
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 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>}
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
  })}
@@ -1,15 +1,21 @@
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.
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 { name: string; ok: boolean; detail: string }
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;) await new Promise(ok => { setTimeout(ok, 10) })
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(self: string, ids: Derived<string[]>, ticket: unknown, wires: Map<string, Set<(from: string, data: unknown) => void>>, got: Got[]): PeerRoom {
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, 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)) },
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[] = [], gotB: 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('both links come up', await settle(() => a!.up.value && b!.up.value, 20_000), `${a.up.value} ${b.up.value}`)
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('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) })
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('frames go both ways', await settle(() => gotA.length > 0) && gotA[0]!.from === 'b', JSON.stringify(gotA[0]))
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 => { setTimeout(ok, 100) })
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))