@weft-js/realtime-livekit 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,255 @@
1
- # @weft-js/realtime-livekit
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/realtime-livekit</h1>
4
8
 
5
- Cursors over a LiveKit room, with the token signed by the room.
9
+ <p align="center">
10
+ <strong>Live cursors over a LiveKit room, with the token signed by your Weft server.</strong><br>
11
+ LiveKit proves who sent each frame, and the same room can carry audio and video.
12
+ </p>
13
+
14
+ <p align="center">
15
+ <a href="https://www.npmjs.com/package/@weft-js/realtime-livekit"><img src="https://img.shields.io/npm/v/@weft-js/realtime-livekit?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 cursor transport for [`@weft-js/multiplayer`](https://www.npmjs.com/package/@weft-js/multiplayer) <sup>[source](https://github.com/Ravonus/weft/tree/main/packages/multiplayer)</sup>. By
33
+ default cursors go through your server over the channel's socket. With this package they go through a
34
+ [LiveKit](https://livekit.io) room instead, which takes that traffic off your server. It suits an app that already uses
35
+ LiveKit, or wants audio and video in the same room as its cursors.
36
+
37
+ ## Highlights
38
+
39
+ - **Your server signs the token.** `livekitTicket()` signs a LiveKit access token whose identity is the channel
40
+ connection's id. No client can name itself to LiveKit.
41
+ - **The sender is proven.** A frame's sender is the LiveKit participant's identity, and `@weft-js/multiplayer` only
42
+ accepts frames from connections that are in the room.
43
+ - **Data only by default.** The token allows joining, subscribing and publishing data. `media: true` adds publishing
44
+ audio and video.
45
+ - **Rooms that say nothing.** The LiveKit room name is a hash of the channel's params, so LiveKit never sees them.
46
+ - **Falls back on its own.** Until every peer is in the LiveKit room, cursors keep going over the socket.
47
+ - **Lossy on purpose.** Cursor frames are published with `reliable: false` on the topic `weft`: a late cursor is
48
+ replaced by the next one.
49
+
50
+ ## Install
6
51
 
7
52
  ```bash
8
- npm install @weft-js/realtime-livekit
53
+ # npm
54
+ npm install @weft-js/multiplayer @weft-js/realtime-livekit
55
+
56
+ # pnpm
57
+ pnpm add @weft-js/multiplayer @weft-js/realtime-livekit
58
+
59
+ # bun
60
+ bun add @weft-js/multiplayer @weft-js/realtime-livekit
61
+ ```
62
+
63
+ Or install `@weft-js/multiplayer`, then let the `weft` CLI install this package and add its plugin to
64
+ `weft.config.ts`:
65
+
66
+ ```bash
67
+ npm install @weft-js/multiplayer
68
+ npx weft add realtime-livekit
69
+ ```
70
+
71
+ `@weft-js/multiplayer` is a peer dependency, so add it too. `livekit-client` comes with this package.
72
+
73
+ `weft add` installs the package and puts its plugin in `weft.config.ts`:
74
+
75
+ ```ts
76
+ // weft.config.ts
77
+ import { defineConfig } from '@weft-js/core/config'
78
+ import realtimeLivekit from '@weft-js/realtime-livekit/plugin'
79
+
80
+ export default defineConfig({
81
+ plugins: [realtimeLivekit()],
82
+ })
83
+ ```
84
+
85
+ The plugin fills no slot. It records what the app reaches (LiveKit from the browser, and the `LIVEKIT_API_KEY` and
86
+ `LIVEKIT_API_SECRET` variables), and tells agents how the transport is used. The transport itself is passed to
87
+ `presence()` in a page.
88
+
89
+ ## Setup
90
+
91
+ You need a LiveKit server: [LiveKit Cloud](https://livekit.io) or your own. The server reads two variables:
92
+
93
+ | Variable | Where | What it is |
94
+ | -------------------- | ----------- | ---------- |
95
+ | `LIVEKIT_API_KEY` | your server | The key `livekitTicket()` puts in the token's `iss`. |
96
+ | `LIVEKIT_API_SECRET` | your server | The secret it signs the token with (HS256). Never send it to a page. |
97
+
98
+ The page needs the LiveKit URL (`wss://…`). It isn't secret: read it on the server and hand it to the page, for
99
+ example from a route's `load`.
100
+
101
+ ## Quick start
102
+
103
+ Give `multiplayer()` the ticket:
104
+
105
+ ```ts
106
+ // src/server/doc.ts
107
+ import { channel } from '@weft-js/start'
108
+ import { multiplayer } from '@weft-js/multiplayer/server'
109
+ import { livekitTicket } from '@weft-js/realtime-livekit/server'
110
+ import { z } from 'zod'
111
+
112
+ const mp = multiplayer<{ id: string }>({
113
+ user: ({ ctx }) => ({ id: ctx.user!.id, name: ctx.user!.name }),
114
+ ticket: livekitTicket(), // LIVEKIT_API_KEY and LIVEKIT_API_SECRET
115
+ })
116
+
117
+ export const doc = channel({
118
+ params: z.object({ id: z.string().max(64) }),
119
+ authorize: ({ ctx }) => !!ctx.user,
120
+ client: { ...mp.client },
121
+ server: { ...mp.server },
122
+ on: { ...mp.on },
123
+ left: mp.left,
124
+ })
125
+ ```
126
+
127
+ Pass the transport to `presence()`:
128
+
129
+ ```tsx
130
+ // src/routes/room.tsx
131
+ import { cursors, presence } from '@weft-js/multiplayer'
132
+ import { Cursors } from '@weft-js/multiplayer/ui'
133
+ import { livekit } from '@weft-js/realtime-livekit'
134
+ import { doc } from '#/server/doc'
135
+
136
+ export const load = () => ({ livekit: process.env.LIVEKIT_URL ?? '' })
137
+
138
+ export default function Room(props: { data: { livekit: string } }) {
139
+ const chat = doc.connect({ id: 'lobby' })
140
+ const live = presence(chat, {
141
+ transport: livekit({ url: props.data.livekit }),
142
+ })
143
+ const pointers = cursors(live)
144
+ return (
145
+ <main>
146
+ <p>Cursors via {live.via.value}</p>
147
+ <Cursors of={pointers} />
148
+ </main>
149
+ )
150
+ }
9
151
  ```
152
+
153
+ `live.via.value` is `'socket'` until every other peer is in the LiveKit room, then `'livekit'`.
154
+
155
+ ## How it works
156
+
157
+ 1. A page joins the channel and sends `mp.hello`. The server calls `livekitTicket()` with the connection's id, the
158
+ peer's `user` and the channel's params, and returns the token with `mp.you`.
159
+ 2. `livekit({ url })` connects a `livekit-client` `Room` to `url` with that token. Without a ticket it doesn't
160
+ connect, and cursors stay on the socket.
161
+ 3. A cursor is published as data on the topic `weft`, unreliable. A received frame is handed to `presence` with the
162
+ participant's identity as the sender, and checked there like any frame off a transport.
163
+ 4. The link is `up` while every peer the channel knows is also a remote participant in the LiveKit room.
164
+
165
+ ## API reference
166
+
167
+ ### `@weft-js/realtime-livekit`
168
+
169
+ `livekit(options): PeerTransport`: the transport, for `presence(conn, { transport })`. Its `open` returns the
170
+ `PeerLink` with one more field, `room`: the `livekit-client` `Room`, for audio and video.
171
+
172
+ | Option | Type | Default | What it does |
173
+ | ------ | -------- | -------- | ------------ |
174
+ | `url` | `string` | required | The LiveKit server, `wss://…`. |
175
+
176
+ Type: `LivekitOptions`.
177
+
178
+ ### `@weft-js/realtime-livekit/server`
179
+
180
+ `livekitTicket(options?)`: returns the function `multiplayer({ ticket })` calls. It refuses to run without a key and
181
+ secret.
182
+
183
+ | Option | Type | Default | What it does |
184
+ | -------- | --------- | ----------------------------- | ------------ |
185
+ | `key` | `string` | `process.env.LIVEKIT_API_KEY` | The API key, the token's `iss`. |
186
+ | `secret` | `string` | `process.env.LIVEKIT_API_SECRET` | The API secret the token is signed with. |
187
+ | `ttl` | `number` | `3600` | Seconds the token is valid. |
188
+ | `media` | `boolean` | `false` | Also allow publishing audio and video (`canPublish`). Data only is what cursors need. |
189
+
190
+ The token's claims: `iss` (the key), `sub` (the connection id), `name` (`user.name`), `nbf`, `exp`, and a `video`
191
+ grant with `room` (`roomId(params)` from `@weft-js/multiplayer/server`), `roomJoin`, `canPublish` (from `media`),
192
+ `canSubscribe` and `canPublishData`. Type: `LivekitTicketOptions`.
193
+
194
+ ### `@weft-js/realtime-livekit/plugin`
195
+
196
+ The plugin for `weft.config.ts`, as a default export and as the named export `livekit`. Import the default, or rename
197
+ the named one, so it doesn't clash with the transport of the same name.
198
+
199
+ ## Recipes
200
+
201
+ **Audio and video in the same room.** Sign tokens that may publish media:
202
+
203
+ ```ts
204
+ const mp = multiplayer({
205
+ user,
206
+ ticket: livekitTicket({ media: true }),
207
+ })
208
+ ```
209
+
210
+ The `room` on the link `livekit()` opens is a `livekit-client` `Room`, so its own API publishes tracks.
211
+
212
+ **Shorter-lived tokens.**
213
+
214
+ ```ts
215
+ livekitTicket({ ttl: 600 })
216
+ ```
217
+
218
+ ## Limits
219
+
220
+ - Only cursors ride LiveKit. Presence state, the relay and signalling stay on the channel, where the server checks them.
221
+ - Frames are unreliable. A lost cursor is replaced by the next; nothing is retried.
222
+ - A connection gets its token once, with `mp.you`. A token past `ttl` can't open a new LiveKit connection; a new
223
+ `mp.hello` (a reconnect or a reset) brings a fresh one.
224
+
225
+ ## Troubleshooting
226
+
227
+ | Symptom | Cause | Fix |
228
+ | ------- | ----- | --- |
229
+ | `` `livekitTicket` needs `LIVEKIT_API_KEY` and `LIVEKIT_API_SECRET` `` on join | The server has neither variable nor options. | Set both variables, or pass `key` and `secret`. |
230
+ | `via` stays `'socket'` | No ticket reached the page (no `ticket` in `multiplayer()`), the URL is wrong, LiveKit refused the token, or a peer hasn't joined LiveKit yet. Connection errors are swallowed. | Check the URL and the key pair; cursors keep working over the socket meanwhile. |
231
+ | `weft check` stops after `weft add` | A newly added plugin's permissions must be approved. | `weft plugins approve @weft-js/realtime-livekit`. |
232
+
233
+ ## Related packages
234
+
235
+ | Package | What it is |
236
+ | ------- | ---------- |
237
+ | [`@weft-js/multiplayer`](https://www.npmjs.com/package/@weft-js/multiplayer) | Presence, cursors and the relay that this transport carries cursors for. |
238
+ | [`@weft-js/realtime-webrtc`](https://www.npmjs.com/package/@weft-js/realtime-webrtc) | Cursors browser to browser, with no service in between. |
239
+ | [`@weft-js/realtime-socketio`](https://www.npmjs.com/package/@weft-js/realtime-socketio) | Cursors through a Socket.IO relay you run. |
240
+ | [`@weft-js/realtime-redis`](https://www.npmjs.com/package/@weft-js/realtime-redis) | A `realtime.bus` over Redis: how server instances reach each other, not a cursor transport. |
241
+ | [`@weft-js/realtime-nats`](https://www.npmjs.com/package/@weft-js/realtime-nats) | A `realtime.bus` over NATS. |
242
+ | [`@weft-js/realtime-durable-object`](https://www.npmjs.com/package/@weft-js/realtime-durable-object) | A `realtime.bus` on Cloudflare Durable Objects. |
243
+ | [`@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. |
244
+ | [`@weft-js/start`](https://www.npmjs.com/package/@weft-js/start) | The full-stack runtime: routes, server functions, `channel`, `realtime()` and the realtime hub. |
245
+
246
+ ## Contributing
247
+
248
+ See [CONTRIBUTING.md](https://github.com/Ravonus/weft/blob/main/CONTRIBUTING.md). From the repository,
249
+ `pnpm --filter @weft-js/realtime-livekit test` checks the token against LiveKit's rules. The transport against a real
250
+ LiveKit server runs in Chromium through `@weft-js/multiplayer`'s end-to-end test with `--via livekit`, which needs
251
+ `LIVEKIT_URL`, `LIVEKIT_API_KEY` and `LIVEKIT_API_SECRET`.
252
+
253
+ ## License
254
+
255
+ [MIT](https://github.com/Ravonus/weft/blob/main/LICENSE)
package/package.json CHANGED
@@ -1,8 +1,17 @@
1
1
  {
2
2
  "name": "@weft-js/realtime-livekit",
3
- "version": "0.1.0",
3
+ "version": "0.1.3",
4
4
  "description": "@weft-js/realtime-livekit: cursors over a LiveKit room, with the token signed by the room",
5
5
  "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/Ravonus/weft.git",
9
+ "directory": "packages/realtime-livekit"
10
+ },
11
+ "homepage": "https://github.com/Ravonus/weft/tree/main/packages/realtime-livekit#readme",
12
+ "bugs": {
13
+ "url": "https://github.com/Ravonus/weft/issues"
14
+ },
6
15
  "type": "module",
7
16
  "exports": {
8
17
  ".": "./src/index.ts",
@@ -13,9 +22,9 @@
13
22
  "livekit-client": "2.23.0"
14
23
  },
15
24
  "peerDependencies": {
16
- "@weft-js/start": "^0.1.0",
17
- "@weft-js/core": "^0.1.0",
18
- "@weft-js/multiplayer": "^0.1.0"
25
+ "@weft-js/start": "^0.1.3",
26
+ "@weft-js/core": "^0.1.3",
27
+ "@weft-js/multiplayer": "^0.1.3"
19
28
  },
20
29
  "engines": {
21
30
  "node": ">=26"
@@ -33,4 +42,4 @@
33
42
  "publishConfig": {
34
43
  "access": "public"
35
44
  }
36
- }
45
+ }
package/plugin.mjs CHANGED
@@ -7,7 +7,10 @@ export const livekit = definePlugin({
7
7
  weft: '*',
8
8
  permissions: { net: ['browser:livekit'], env: ['LIVEKIT_API_KEY', 'LIVEKIT_API_SECRET'] },
9
9
  contribute: () => ({
10
- agents: { title: 'Peer transport (LiveKit)', text: '`presence(conn, { transport: livekit({ url }) })` sends cursors through LiveKit, and `link.room` is the room for audio and video. Pass `ticket: livekitTicket({ media })` from `@weft-js/realtime-livekit/server` to `multiplayer()`, with `LIVEKIT_API_KEY` and `LIVEKIT_API_SECRET` on the server.' },
10
+ agents: {
11
+ title: 'Peer transport (LiveKit)',
12
+ text: '`presence(conn, { transport: livekit({ url }) })` sends cursors through LiveKit, and `link.room` is the room for audio and video. Pass `ticket: livekitTicket({ media })` from `@weft-js/realtime-livekit/server` to `multiplayer()`, with `LIVEKIT_API_KEY` and `LIVEKIT_API_SECRET` on the server.',
13
+ },
11
14
  }),
12
15
  })
13
16
 
package/src/index.ts CHANGED
@@ -18,18 +18,38 @@ export function livekit({ url }: LivekitOptions): PeerTransport {
18
18
  return {
19
19
  name: 'livekit',
20
20
  open(room: PeerRoom): PeerLink & { room: Room } {
21
- return createRoot(dispose => {
21
+ return createRoot((dispose) => {
22
22
  const live = new Room()
23
23
  const here = state<string[]>([])
24
- const sync = () => { here.value = [...live.remoteParticipants.values()].map(p => p.identity) }
25
- const decoder = new TextDecoder(), encoder = new TextEncoder()
26
- live.on(RoomEvent.DataReceived, (bytes, participant, _kind, topic) => { if (topic === TOPIC && participant) room.receive(participant.identity, decoder.decode(bytes)) })
27
- for (const e of [RoomEvent.ParticipantConnected, RoomEvent.ParticipantDisconnected, RoomEvent.Connected, RoomEvent.Disconnected]) live.on(e, sync)
24
+ const sync = () => {
25
+ here.value = [...live.remoteParticipants.values()].map((p) => p.identity)
26
+ }
27
+ const decoder = new TextDecoder(),
28
+ encoder = new TextEncoder()
29
+ live.on(RoomEvent.DataReceived, (bytes, participant, _kind, topic) => {
30
+ if (topic === TOPIC && participant)
31
+ room.receive(participant.identity, decoder.decode(bytes))
32
+ })
33
+ for (const e of [
34
+ RoomEvent.ParticipantConnected,
35
+ RoomEvent.ParticipantDisconnected,
36
+ RoomEvent.Connected,
37
+ RoomEvent.Disconnected,
38
+ ])
39
+ live.on(e, sync)
28
40
  if (room.ticket) void live.connect(url, room.ticket as string).then(sync, () => {})
29
41
  return {
30
42
  room: live,
31
- up: derived(() => { const ids = room.peers.value; return ids.length > 0 && ids.every(id => here.value.includes(id)) }),
32
- send(text) { if (untrack(() => here.value.length)) void live.localParticipant.publishData(encoder.encode(text), { reliable: false, topic: TOPIC }).catch(() => {}) },
43
+ up: derived(() => {
44
+ const ids = room.peers.value
45
+ return ids.length > 0 && ids.every((id) => here.value.includes(id))
46
+ }),
47
+ send(text) {
48
+ if (untrack(() => here.value.length))
49
+ void live.localParticipant
50
+ .publishData(encoder.encode(text), { reliable: false, topic: TOPIC })
51
+ .catch(() => {})
52
+ },
33
53
  close() {
34
54
  void live.disconnect()
35
55
  dispose()
package/src/server.ts CHANGED
@@ -14,8 +14,32 @@ export interface LivekitTicketOptions {
14
14
  }
15
15
 
16
16
  /** For `multiplayer({ ticket })`: a LiveKit token that names the connection and its room. Refuses to run without a key and secret. */
17
- export const livekitTicket = ({ key = process.env.LIVEKIT_API_KEY, secret = process.env.LIVEKIT_API_SECRET, ttl = 3600, media = false }: LivekitTicketOptions = {}) => async ({ id, user, params }: { id: string; user: { name?: string }; params: unknown }) => {
18
- if (!key || !secret) throw new Error('`livekitTicket` needs `LIVEKIT_API_KEY` and `LIVEKIT_API_SECRET`')
19
- const now = Math.floor(Date.now() / 1000)
20
- return signTicket({ iss: key, sub: id, name: user.name, nbf: now, exp: now + ttl, video: { room: await roomId(params), roomJoin: true, canPublish: media, canSubscribe: true, canPublishData: true } }, secret)
21
- }
17
+ export const livekitTicket =
18
+ ({
19
+ key = process.env.LIVEKIT_API_KEY,
20
+ secret = process.env.LIVEKIT_API_SECRET,
21
+ ttl = 3600,
22
+ media = false,
23
+ }: LivekitTicketOptions = {}) =>
24
+ async ({ id, user, params }: { id: string; user: { name?: string }; params: unknown }) => {
25
+ if (!key || !secret)
26
+ throw new Error('`livekitTicket` needs `LIVEKIT_API_KEY` and `LIVEKIT_API_SECRET`')
27
+ const now = Math.floor(Date.now() / 1000)
28
+ return signTicket(
29
+ {
30
+ iss: key,
31
+ sub: id,
32
+ name: user.name,
33
+ nbf: now,
34
+ exp: now + ttl,
35
+ video: {
36
+ room: await roomId(params),
37
+ roomJoin: true,
38
+ canPublish: media,
39
+ canSubscribe: true,
40
+ canPublishData: true,
41
+ },
42
+ },
43
+ secret,
44
+ )
45
+ }