@weft-js/realtime-socketio 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 CHANGED
@@ -1,9 +1,250 @@
1
- # @weft-js/realtime-socketio
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.1).** APIs will change between minor versions.
7
+ <h1 align="center">@weft-js/realtime-socketio</h1>
4
8
 
5
- Cursors through a Socket.IO relay you run, with tickets the room signs.
9
+ <p align="center">
10
+ <strong>Live cursors through a Socket.IO relay you run, with tickets your Weft server signs.</strong><br>
11
+ For apps moving off Socket.IO: keep the relay you have, and let it carry cursors.
12
+ </p>
13
+
14
+ <p align="center">
15
+ <a href="https://www.npmjs.com/package/@weft-js/realtime-socketio"><img src="https://img.shields.io/npm/v/@weft-js/realtime-socketio?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>. It has
33
+ two halves: `socketio()` in the page, and `relay(io, { secret })` on a Socket.IO server you run. The relay admits only
34
+ connections with a ticket your Weft server signed, and stamps every frame with the ticket's subject, so a client can't
35
+ speak as anyone else.
36
+
37
+ > [!TIP]
38
+ > This is for migrations. If you don't already run a Socket.IO server, leave it out: cursors work over the channel's
39
+ > socket with nothing extra, and [`@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>
40
+ > takes them off your server without one.
41
+
42
+ ## Highlights
43
+
44
+ - **Tickets, not trust.** `socketioTicket({ secret })` signs an HS256 token naming the connection and its room. The
45
+ relay refuses any connection without a good one.
46
+ - **The sender is the ticket's.** The relay ignores who a client says it is and fans each frame out under the
47
+ ticket's `sub`.
48
+ - **One room per channel room.** The ticket's room is a hash of the channel's params, so the relay never sees them.
49
+ - **WebSocket only, volatile.** The client connects with `transports: ['websocket']` and sends with `volatile.emit`:
50
+ a cursor that can't go now is dropped, not queued.
51
+ - **Bounded frames.** The relay drops a frame that isn't a string or is over 1024 characters.
52
+ - **Falls back on its own.** Until the relay accepts the socket, and while no other peer is here, cursors go over the
53
+ channel.
54
+
55
+ ## Install
6
56
 
7
57
  ```bash
8
- npm install @weft-js/realtime-socketio
58
+ # npm
59
+ npm install @weft-js/multiplayer @weft-js/realtime-socketio
60
+
61
+ # pnpm
62
+ pnpm add @weft-js/multiplayer @weft-js/realtime-socketio
63
+
64
+ # bun
65
+ bun add @weft-js/multiplayer @weft-js/realtime-socketio
66
+ ```
67
+
68
+ Or install `@weft-js/multiplayer`, then let the `weft` CLI install this package and add its plugin to
69
+ `weft.config.ts`:
70
+
71
+ ```bash
72
+ npm install @weft-js/multiplayer
73
+ npx weft add realtime-socketio
9
74
  ```
75
+
76
+ `@weft-js/multiplayer` is a peer dependency, so add it too. `socket.io-client` comes with this package. `socket.io`
77
+ (`^4.8.0`) is an optional peer: only the relay needs it, on the server that runs the relay.
78
+
79
+ `weft add` puts its plugin in `weft.config.ts`:
80
+
81
+ ```ts
82
+ // weft.config.ts
83
+ import { defineConfig } from '@weft-js/core/config'
84
+ import realtimeSocketio from '@weft-js/realtime-socketio/plugin'
85
+
86
+ export default defineConfig({
87
+ plugins: [realtimeSocketio()],
88
+ })
89
+ ```
90
+
91
+ The plugin fills no slot. It records that the browser reaches your relay and tells agents how the pieces fit. The
92
+ transport itself is passed to `presence()` in a page.
93
+
94
+ ## Setup
95
+
96
+ | Piece | Where | What it needs |
97
+ | ----- | ----- | ------------- |
98
+ | `relay(io, { secret })` | Your Socket.IO server | `socket.io` 4.8 or later, and the shared secret. |
99
+ | `socketioTicket({ secret })` | Your Weft server, in `multiplayer({ ticket })` | The same secret. |
100
+ | `socketio({ url, path })` | The page | The relay's URL, and its `path` if not the default. |
101
+
102
+ The package reads no environment variable itself. Keep the secret in one (the end-to-end test calls it
103
+ `WEFT_E2E_SOCKETIO_SECRET`) and pass it to both halves.
104
+
105
+ ## Quick start
106
+
107
+ The relay, on your Socket.IO server:
108
+
109
+ ```ts
110
+ // relay.ts
111
+ import { createServer } from 'node:http'
112
+ import { Server } from 'socket.io'
113
+ import { relay } from '@weft-js/realtime-socketio/relay'
114
+
115
+ const http = createServer()
116
+ const io = new Server(http, {
117
+ cors: { origin: 'https://app.example.com' },
118
+ })
119
+ relay(io, { secret: process.env.CURSOR_SECRET! })
120
+ http.listen(3001)
121
+ ```
122
+
123
+ The ticket, in your channel:
124
+
125
+ ```ts
126
+ // src/server/doc.ts
127
+ import { channel } from '@weft-js/start'
128
+ import { multiplayer } from '@weft-js/multiplayer/server'
129
+ import { socketioTicket } from '@weft-js/realtime-socketio/relay'
130
+ import { z } from 'zod'
131
+
132
+ const mp = multiplayer<{ id: string }>({
133
+ user: ({ ctx }) => ({ id: ctx.user!.id, name: ctx.user!.name }),
134
+ ticket: socketioTicket({ secret: process.env.CURSOR_SECRET! }),
135
+ })
136
+
137
+ export const doc = channel({
138
+ params: z.object({ id: z.string().max(64) }),
139
+ authorize: ({ ctx }) => !!ctx.user,
140
+ client: { ...mp.client },
141
+ server: { ...mp.server },
142
+ on: { ...mp.on },
143
+ left: mp.left,
144
+ })
145
+ ```
146
+
147
+ The transport, in the page:
148
+
149
+ ```tsx
150
+ // src/routes/room.tsx
151
+ import { cursors, presence } from '@weft-js/multiplayer'
152
+ import { Cursors } from '@weft-js/multiplayer/ui'
153
+ import { socketio } from '@weft-js/realtime-socketio'
154
+ import { doc } from '#/server/doc'
155
+
156
+ export default function Room() {
157
+ const chat = doc.connect({ id: 'lobby' })
158
+ const live = presence(chat, {
159
+ transport: socketio({ url: 'https://relay.example.com' }),
160
+ })
161
+ return <Cursors of={cursors(live)} />
162
+ }
163
+ ```
164
+
165
+ `live.via.value` is `'socket'` until the relay has accepted this page and another peer is here, then `'socketio'`.
166
+
167
+ ## How it works
168
+
169
+ 1. On `mp.hello` the Weft server signs a ticket: `{ sub: <connection id>, room: roomId(params), exp }`. The page gets
170
+ it with `mp.you`.
171
+ 2. `socketio({ url })` connects with `auth: { ticket }`. The relay's middleware verifies it with the secret; a bad,
172
+ expired or incomplete ticket is refused with `bad ticket`.
173
+ 3. An admitted socket joins the ticket's room and receives `weft:ok`. From then on the link is up while there's
174
+ another peer.
175
+ 4. The page sends a cursor as `weft:f`. The relay re-emits it to the rest of the room as `weft:f` with the ticket's
176
+ `sub` as the sender, and `presence` checks it like any frame off a transport.
177
+
178
+ ## API reference
179
+
180
+ ### `@weft-js/realtime-socketio`
181
+
182
+ `socketio(options): PeerTransport`: the transport, for `presence(conn, { transport })`.
183
+
184
+ | Option | Type | Default | What it does |
185
+ | ------ | -------- | ------------------ | ------------ |
186
+ | `url` | `string` | required | The relay. |
187
+ | `path` | `string` | Socket.IO's default | The relay's Socket.IO path, if it isn't the default. |
188
+
189
+ Type: `SocketIoOptions`.
190
+
191
+ ### `@weft-js/realtime-socketio/relay`
192
+
193
+ | Export | What it does |
194
+ | ------ | ------------ |
195
+ | `relay(io, { secret })` | Adds the ticket check to a Socket.IO `Server` and fans out each frame to its room under the ticket's subject. |
196
+ | `socketioTicket({ secret, ttl })` | Returns the function `multiplayer({ ticket })` calls: a signed claim of who this connection is and which room it may enter. |
197
+
198
+ | `socketioTicket` option | Type | Default | What it does |
199
+ | ----------------------- | -------- | -------- | ------------ |
200
+ | `secret` | `string` | required | Shared with the relay. |
201
+ | `ttl` | `number` | `3600` | Seconds a ticket can open a connection. |
202
+
203
+ Type: `TicketOptions`. The relay checks the ticket when a socket connects, so a socket already open stays open past
204
+ `ttl`.
205
+
206
+ ### `@weft-js/realtime-socketio/plugin`
207
+
208
+ The plugin for `weft.config.ts`, as a default export and as the named export `socketio`. Import the default, or rename
209
+ the named one, so it doesn't clash with the transport of the same name.
210
+
211
+ ## Limits
212
+
213
+ - Only cursors ride the relay. Presence state, the relay events of `@weft-js/multiplayer` and signalling stay on the
214
+ channel, where the server checks them.
215
+ - Frames are volatile and over 1024 characters are dropped. Nothing is retried.
216
+ - The relay keeps no state beyond Socket.IO's rooms. To run several relay instances, they need Socket.IO's own adapter
217
+ between them; this package doesn't set one up.
218
+
219
+ ## Troubleshooting
220
+
221
+ | Symptom | Cause | Fix |
222
+ | ------- | ----- | --- |
223
+ | `connect_error` with `bad ticket` | The secrets differ, the ticket expired, or `multiplayer()` has no `ticket`. | Use one secret on both sides; check `ttl`. |
224
+ | `via` stays `'socket'` | The relay isn't reachable from the page (URL, `path`, CORS), or no other peer is here. | Check the relay's URL and its CORS origin. Cursors keep working over the channel meanwhile. |
225
+ | R007 from `weft check` | `socket.io-client` is in the app's own dependencies. Weft owns the app's connections. | Let this package bring `socket.io-client`; don't add it to the app. |
226
+ | `weft check` stops after `weft add` | A newly added plugin's permissions must be approved. | `weft plugins approve @weft-js/realtime-socketio`. |
227
+
228
+ ## Related packages
229
+
230
+ | Package | What it is |
231
+ | ------- | ---------- |
232
+ | [`@weft-js/multiplayer`](https://www.npmjs.com/package/@weft-js/multiplayer) | Presence, cursors and the relay that this transport carries cursors for. |
233
+ | [`@weft-js/realtime-webrtc`](https://www.npmjs.com/package/@weft-js/realtime-webrtc) | Cursors browser to browser, with no service in between. |
234
+ | [`@weft-js/realtime-livekit`](https://www.npmjs.com/package/@weft-js/realtime-livekit) | Cursors over a LiveKit room, which also carries audio and video. |
235
+ | [`@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. |
236
+ | [`@weft-js/realtime-nats`](https://www.npmjs.com/package/@weft-js/realtime-nats) | A `realtime.bus` over NATS. |
237
+ | [`@weft-js/realtime-durable-object`](https://www.npmjs.com/package/@weft-js/realtime-durable-object) | A `realtime.bus` on Cloudflare Durable Objects. |
238
+ | [`@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. |
239
+ | [`@weft-js/start`](https://www.npmjs.com/package/@weft-js/start) | The full-stack runtime: routes, server functions, `channel`, `realtime()` and the realtime hub. |
240
+
241
+ ## Contributing
242
+
243
+ See [CONTRIBUTING.md](https://github.com/Ravonus/weft/blob/main/CONTRIBUTING.md). From the repository,
244
+ `pnpm --filter @weft-js/realtime-socketio test` starts a real Socket.IO server with the relay and runs
245
+ `@weft-js/multiplayer`'s transport suite against real clients, plus a forged-ticket refusal. No external service is
246
+ needed.
247
+
248
+ ## License
249
+
250
+ [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-socketio",
3
- "version": "0.1.1",
3
+ "version": "0.1.4",
4
4
  "description": "@weft-js/realtime-socketio: cursors through a Socket.IO relay you run, with tickets the room signs",
5
5
  "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/Ravonus/weft.git",
9
+ "directory": "packages/realtime-socketio"
10
+ },
11
+ "homepage": "https://github.com/Ravonus/weft/tree/main/packages/realtime-socketio#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
  "socket.io-client": "4.8.4"
14
23
  },
15
24
  "peerDependencies": {
16
- "@weft-js/start": "^0.1.1",
17
- "@weft-js/core": "^0.1.1",
18
- "@weft-js/multiplayer": "^0.1.1",
25
+ "@weft-js/start": "^0.1.4",
26
+ "@weft-js/core": "^0.1.4",
27
+ "@weft-js/multiplayer": "^0.1.4",
19
28
  "socket.io": "^4.8.0"
20
29
  },
21
30
  "peerDependenciesMeta": {
@@ -24,7 +33,7 @@
24
33
  }
25
34
  },
26
35
  "engines": {
27
- "node": ">=26"
36
+ "node": ">=24"
28
37
  },
29
38
  "keywords": [
30
39
  "weft"
@@ -39,4 +48,4 @@
39
48
  "publishConfig": {
40
49
  "access": "public"
41
50
  }
42
- }
51
+ }
package/plugin.mjs CHANGED
@@ -7,7 +7,10 @@ export const socketio = definePlugin({
7
7
  weft: '*',
8
8
  permissions: { net: ['browser:socketio-relay'] },
9
9
  contribute: () => ({
10
- agents: { title: 'Peer transport (Socket.IO)', text: '`presence(conn, { transport: socketio({ url }) })` sends cursors through your relay. Run `relay(io, { secret })` from `@weft-js/realtime-socketio/relay` there, and pass `ticket: socketioTicket({ secret })`, from the same file, to `multiplayer()`. For migrations: with no relay already running, leave it out.' },
10
+ agents: {
11
+ title: 'Peer transport (Socket.IO)',
12
+ text: '`presence(conn, { transport: socketio({ url }) })` sends cursors through your relay. Run `relay(io, { secret })` from `@weft-js/realtime-socketio/relay` there, and pass `ticket: socketioTicket({ secret })`, from the same file, to `multiplayer()`. For migrations: with no relay already running, leave it out.',
13
+ },
11
14
  }),
12
15
  })
13
16
 
package/src/index.ts CHANGED
@@ -17,15 +17,27 @@ export function socketio({ url, path }: SocketIoOptions): PeerTransport {
17
17
  return {
18
18
  name: 'socketio',
19
19
  open(room: PeerRoom): PeerLink {
20
- return createRoot(dispose => {
20
+ return createRoot((dispose) => {
21
21
  const joined = state(false)
22
- const socket: Socket = io(url, { ...(path === undefined ? {} : { path }), auth: { ticket: room.ticket }, transports: ['websocket'] })
23
- socket.on('weft:ok', () => { joined.value = true })
24
- socket.on('disconnect', () => { joined.value = false })
25
- socket.on('weft:f', (from: unknown, text: unknown) => { if (typeof from === 'string' && typeof text === 'string') room.receive(from, text) })
22
+ const socket: Socket = io(url, {
23
+ ...(path === undefined ? {} : { path }),
24
+ auth: { ticket: room.ticket },
25
+ transports: ['websocket'],
26
+ })
27
+ socket.on('weft:ok', () => {
28
+ joined.value = true
29
+ })
30
+ socket.on('disconnect', () => {
31
+ joined.value = false
32
+ })
33
+ socket.on('weft:f', (from: unknown, text: unknown) => {
34
+ if (typeof from === 'string' && typeof text === 'string') room.receive(from, text)
35
+ })
26
36
  return {
27
37
  up: derived(() => joined.value && room.peers.value.length > 0),
28
- send: text => { if (untrack(() => joined.value)) socket.volatile.emit('weft:f', text) },
38
+ send: (text) => {
39
+ if (untrack(() => joined.value)) socket.volatile.emit('weft:f', text)
40
+ },
29
41
  close() {
30
42
  socket.disconnect()
31
43
  dispose()
package/src/relay.ts CHANGED
@@ -19,7 +19,8 @@ export function relay(io: Server, { secret }: { secret: string }) {
19
19
  await socket.join(socket.data.room)
20
20
  socket.emit('weft:ok')
21
21
  socket.on('weft:f', (text: unknown) => {
22
- if (typeof text === 'string' && text.length <= FRAME) socket.to(socket.data.room).volatile.emit('weft:f', socket.data.peer, text)
22
+ if (typeof text === 'string' && text.length <= FRAME)
23
+ socket.to(socket.data.room).volatile.emit('weft:f', socket.data.peer, text)
23
24
  })
24
25
  })
25
26
  }
@@ -33,5 +34,10 @@ export interface TicketOptions {
33
34
  }
34
35
 
35
36
  /** For `multiplayer({ ticket })`: a signed claim of who this connection is and which room it may enter. */
36
- export const socketioTicket = ({ secret, ttl = 3600 }: TicketOptions) => async ({ id, params }: { id: string; params: unknown }) =>
37
- signTicket({ sub: id, room: await roomId(params), exp: Math.floor(Date.now() / 1000) + ttl }, secret)
37
+ export const socketioTicket =
38
+ ({ secret, ttl = 3600 }: TicketOptions) =>
39
+ async ({ id, params }: { id: string; params: unknown }) =>
40
+ signTicket(
41
+ { sub: id, room: await roomId(params), exp: Math.floor(Date.now() / 1000) + ttl },
42
+ secret,
43
+ )