@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 +245 -4
- package/package.json +15 -6
- package/plugin.mjs +4 -1
- package/src/index.ts +18 -6
- package/src/relay.ts +9 -3
package/README.md
CHANGED
|
@@ -1,9 +1,250 @@
|
|
|
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/realtime-socketio</h1>
|
|
4
8
|
|
|
5
|
-
|
|
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
|
|
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.
|
|
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.
|
|
17
|
-
"@weft-js/core": "^0.1.
|
|
18
|
-
"@weft-js/multiplayer": "^0.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": ">=
|
|
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: {
|
|
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, {
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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 => {
|
|
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)
|
|
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 =
|
|
37
|
-
|
|
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
|
+
)
|