@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/src/server.ts
CHANGED
|
@@ -1,12 +1,3 @@
|
|
|
1
|
-
// The server's half (research 35 §2): messages a `channel` spreads into its own definition, so the compiler still sees a
|
|
2
|
-
// plain `channel({ … })` and `authorize` stays the app's.
|
|
3
|
-
//
|
|
4
|
-
// const mp = multiplayer({ user: ({ ctx }) => ({ id: ctx.user!.id, name: ctx.user!.name }) })
|
|
5
|
-
// export const doc = channel({
|
|
6
|
-
// params, authorize,
|
|
7
|
-
// client: { ...mp.client, say }, server: { ...mp.server, said }, on: { ...mp.on, say: … }, left: mp.left,
|
|
8
|
-
// })
|
|
9
|
-
//
|
|
10
1
|
// Who a peer is comes from `user`, never from the client: a connection names only its own state and cursor, and every
|
|
11
2
|
// frame is stamped here with the connection that sent it. There's no roster on the server: each client
|
|
12
3
|
// answers an `ask`, peers expire without a beat, so any bus works and a dead instance's peers just age out.
|
|
@@ -15,41 +6,97 @@ import { StartError } from '@weft-js/start/data'
|
|
|
15
6
|
import type { Context, PublishOptions, StandardSchemaV1 } from '@weft-js/start'
|
|
16
7
|
|
|
17
8
|
/** Who a peer is: what `user` returns. */
|
|
18
|
-
export interface PeerUser {
|
|
9
|
+
export interface PeerUser {
|
|
10
|
+
id: string
|
|
11
|
+
name?: string
|
|
12
|
+
[key: string]: unknown
|
|
13
|
+
}
|
|
19
14
|
/** A peer as the room hears of it, stamped here with who sent it. */
|
|
20
|
-
export interface Peer<S = unknown> {
|
|
15
|
+
export interface Peer<S = unknown> {
|
|
16
|
+
id: string
|
|
17
|
+
user: PeerUser
|
|
18
|
+
state: S
|
|
19
|
+
at: number
|
|
20
|
+
}
|
|
21
21
|
/** A pointer as fractions of `a`, an anchor's id, or of the viewport without one. */
|
|
22
|
-
export interface CursorAt {
|
|
22
|
+
export interface CursorAt {
|
|
23
|
+
a?: string
|
|
24
|
+
x: number
|
|
25
|
+
y: number
|
|
26
|
+
style?: string
|
|
27
|
+
}
|
|
23
28
|
|
|
24
29
|
type Issue = { issues: { message: string }[] }
|
|
25
30
|
/** Whatever a client sent: read as hostile, field by field. */
|
|
26
31
|
type Wire = { [key: string]: unknown } | null | undefined
|
|
27
32
|
const isIssue = (r: unknown): r is Issue => !!(r as Issue | null)?.issues
|
|
28
|
-
const schema = <T
|
|
33
|
+
const schema = <T>(validate: (v: Wire) => T | Issue): StandardSchemaV1<T, T> => ({
|
|
34
|
+
'~standard': {
|
|
35
|
+
version: 1,
|
|
36
|
+
vendor: 'weft',
|
|
37
|
+
validate: (v: unknown) => {
|
|
38
|
+
const r = validate(v as Wire)
|
|
39
|
+
return isIssue(r) ? r : { value: r }
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
})
|
|
29
43
|
const no = (message: string): Issue => ({ issues: [{ message }] })
|
|
30
|
-
const within = (v: unknown, bytes: number) => {
|
|
44
|
+
const within = (v: unknown, bytes: number) => {
|
|
45
|
+
try {
|
|
46
|
+
return (JSON.stringify(v) ?? '').length <= bytes
|
|
47
|
+
} catch {
|
|
48
|
+
return false
|
|
49
|
+
}
|
|
50
|
+
}
|
|
31
51
|
const unit = (n: unknown): n is number => typeof n === 'number' && n >= -0.5 && n <= 1.5
|
|
32
52
|
|
|
33
|
-
const STATE = 4096,
|
|
53
|
+
const STATE = 4096,
|
|
54
|
+
RELAY = 2048,
|
|
55
|
+
SIGNAL = 16_384
|
|
34
56
|
|
|
35
|
-
const anything = schema(v => v)
|
|
57
|
+
const anything = schema((v) => v)
|
|
36
58
|
|
|
37
|
-
const stateful = (max: number) =>
|
|
59
|
+
const stateful = (max: number) =>
|
|
60
|
+
schema((v) =>
|
|
61
|
+
v && typeof v === 'object' && within(v.state, max)
|
|
62
|
+
? { state: v.state }
|
|
63
|
+
: no(`\`state\` is JSON up to ${max} bytes`),
|
|
64
|
+
)
|
|
38
65
|
|
|
39
66
|
/** What a client may send as a cursor: fractions just past the edges, or null when it leaves. */
|
|
40
|
-
export const cursorSchema = schema<CursorAt | null>(v => {
|
|
67
|
+
export const cursorSchema = schema<CursorAt | null>((v) => {
|
|
41
68
|
if (v === null) return null
|
|
42
69
|
if (!v || !unit(v.x) || !unit(v.y)) return no('a cursor is `{ x, y }` in fractions')
|
|
43
|
-
if (v.a !== undefined && (typeof v.a !== 'string' || v.a.length > 128))
|
|
44
|
-
|
|
45
|
-
|
|
70
|
+
if (v.a !== undefined && (typeof v.a !== 'string' || v.a.length > 128))
|
|
71
|
+
return no('an anchor is up to 128 characters')
|
|
72
|
+
if (v.style !== undefined && (typeof v.style !== 'string' || v.style.length > 16))
|
|
73
|
+
return no('a style is up to 16 characters')
|
|
74
|
+
return {
|
|
75
|
+
x: v.x,
|
|
76
|
+
y: v.y,
|
|
77
|
+
...(v.a === undefined ? {} : { a: v.a }),
|
|
78
|
+
...(v.style === undefined ? {} : { style: v.style }),
|
|
79
|
+
}
|
|
46
80
|
})
|
|
47
81
|
|
|
48
|
-
const answerSchema = schema(
|
|
49
|
-
|
|
82
|
+
const answerSchema = schema((v) =>
|
|
83
|
+
v && typeof v.to === 'string' && within(v.state, STATE)
|
|
84
|
+
? { to: v.to, state: v.state }
|
|
85
|
+
: no('an answer names who asked'),
|
|
86
|
+
)
|
|
87
|
+
const signalSchema = schema((v) =>
|
|
88
|
+
v && typeof v.to === 'string' && within(v.data, SIGNAL)
|
|
89
|
+
? { to: v.to, data: v.data }
|
|
90
|
+
: no(`a signal names a peer, with up to ${SIGNAL} bytes`),
|
|
91
|
+
)
|
|
50
92
|
|
|
51
93
|
/** What `ticket` gets: the connection, who it is and the room's params. */
|
|
52
|
-
export interface TicketArgs<P> {
|
|
94
|
+
export interface TicketArgs<P> {
|
|
95
|
+
id: string
|
|
96
|
+
user: PeerUser
|
|
97
|
+
params: P
|
|
98
|
+
ctx: Context
|
|
99
|
+
}
|
|
53
100
|
|
|
54
101
|
/** Options of `multiplayer()`. */
|
|
55
102
|
export interface MultiplayerOptions<P> {
|
|
@@ -64,7 +111,9 @@ export interface MultiplayerOptions<P> {
|
|
|
64
111
|
}
|
|
65
112
|
|
|
66
113
|
/** A channel's room as far as the fragment uses it: the app's own messages are none of its business. */
|
|
67
|
-
interface MpRoom {
|
|
114
|
+
interface MpRoom {
|
|
115
|
+
publish(name: string, message: unknown, options?: PublishOptions): Promise<void>
|
|
116
|
+
}
|
|
68
117
|
|
|
69
118
|
/** `client`, `server`, `on` and `left` to spread into a `channel`. Every message is `mp.*`, and all but `mp.relay` are volatile. */
|
|
70
119
|
export function multiplayer<P = unknown>(options: MultiplayerOptions<P>) {
|
|
@@ -75,16 +124,88 @@ export function multiplayer<P = unknown>(options: MultiplayerOptions<P>) {
|
|
|
75
124
|
const known = people.get(args.id)
|
|
76
125
|
if (known) return known
|
|
77
126
|
const made = await user(args)
|
|
78
|
-
if (!made || typeof made.id !== 'string')
|
|
127
|
+
if (!made || typeof made.id !== 'string')
|
|
128
|
+
throw new Error('`user` must return an object with an `id`')
|
|
79
129
|
people.set(args.id, made)
|
|
80
130
|
return made
|
|
81
131
|
}
|
|
82
|
-
const peer = async (a: { id: string; ctx: Context; params: P }, value: unknown) => ({
|
|
132
|
+
const peer = async (a: { id: string; ctx: Context; params: P }, value: unknown) => ({
|
|
133
|
+
id: a.id,
|
|
134
|
+
user: await who(a),
|
|
135
|
+
state: value,
|
|
136
|
+
at: Date.now(),
|
|
137
|
+
})
|
|
83
138
|
const volatile = { volatile: true }
|
|
84
|
-
const relaySchema =
|
|
85
|
-
|
|
139
|
+
const relaySchema = relayMessages(relay)
|
|
140
|
+
const { client, server } = messages(state, relaySchema)
|
|
141
|
+
return {
|
|
142
|
+
client,
|
|
143
|
+
server,
|
|
144
|
+
on: {
|
|
145
|
+
/** A connection arrives, or comes back: it learns its id and everyone hears it. */
|
|
146
|
+
'mp.hello': async ({ msg, id, ctx, params, room }: Arrived<{ state: unknown }>) => {
|
|
147
|
+
const me = await peer({ id, ctx, params }, msg.state)
|
|
148
|
+
await room.publish(
|
|
149
|
+
'mp.you',
|
|
150
|
+
{ id, ticket: await ticket?.({ id, user: me.user, params, ctx }) },
|
|
151
|
+
{ to: id, volatile: true },
|
|
152
|
+
)
|
|
153
|
+
await room.publish('mp.peer', me, { ...volatile, exceptSelf: true })
|
|
154
|
+
await room.publish('mp.ask', { id }, { ...volatile, exceptSelf: true })
|
|
155
|
+
},
|
|
156
|
+
'mp.state': async ({ msg, id, ctx, params, room }: Arrived<{ state: unknown }>) =>
|
|
157
|
+
room.publish('mp.peer', await peer({ id, ctx, params }, msg.state), {
|
|
158
|
+
...volatile,
|
|
159
|
+
exceptSelf: true,
|
|
160
|
+
}),
|
|
161
|
+
'mp.answer': async ({
|
|
162
|
+
msg,
|
|
163
|
+
id,
|
|
164
|
+
ctx,
|
|
165
|
+
params,
|
|
166
|
+
room,
|
|
167
|
+
}: Arrived<{ to: string; state: unknown }>) =>
|
|
168
|
+
room.publish('mp.peer', await peer({ id, ctx, params }, msg.state), {
|
|
169
|
+
...volatile,
|
|
170
|
+
to: msg.to,
|
|
171
|
+
}),
|
|
172
|
+
'mp.cursor': ({ msg, id, room }: Pick<Arrived<CursorAt | null>, 'msg' | 'id' | 'room'>) =>
|
|
173
|
+
room.publish('mp.cursor', { id, at: msg }, { ...volatile, exceptSelf: true }),
|
|
174
|
+
'mp.relay': async ({
|
|
175
|
+
msg,
|
|
176
|
+
id,
|
|
177
|
+
room,
|
|
178
|
+
}: Pick<Arrived<{ kind: string; data: unknown }>, 'msg' | 'id' | 'room'>) => {
|
|
179
|
+
const r = await relay[msg.kind]!['~standard'].validate(msg.data) // `relaySchema` refused any kind that isn't declared
|
|
180
|
+
if (r.issues) throw new StartError('Q002', `\`${msg.kind}\` refused its data`)
|
|
181
|
+
await room.publish('mp.relay', { id, kind: msg.kind, data: r.value }, { exceptSelf: true })
|
|
182
|
+
},
|
|
183
|
+
'mp.signal': ({
|
|
184
|
+
msg,
|
|
185
|
+
id,
|
|
186
|
+
room,
|
|
187
|
+
}: Pick<Arrived<{ to: string; data: unknown }>, 'msg' | 'id' | 'room'>) =>
|
|
188
|
+
room.publish('mp.signal', { from: id, data: msg.data }, { ...volatile, to: msg.to }),
|
|
189
|
+
},
|
|
190
|
+
left: async ({ id, room }: Pick<Arrived<unknown>, 'id' | 'room'>) => {
|
|
191
|
+
people.delete(id)
|
|
192
|
+
await room.publish('mp.gone', { id }, volatile)
|
|
193
|
+
},
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function relayMessages(relay: Record<string, StandardSchemaV1>) {
|
|
198
|
+
return schema((v) => {
|
|
199
|
+
if (!v || typeof v.kind !== 'string' || !Object.hasOwn(relay, v.kind) || !within(v.data, RELAY))
|
|
200
|
+
return no('that relay kind is not declared')
|
|
86
201
|
return { kind: v.kind, data: v.data }
|
|
87
202
|
})
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function messages(
|
|
206
|
+
state: number,
|
|
207
|
+
relaySchema: StandardSchemaV1<{ kind: string; data: unknown }, { kind: string; data: unknown }>,
|
|
208
|
+
) {
|
|
88
209
|
return {
|
|
89
210
|
client: {
|
|
90
211
|
'mp.hello': stateful(state),
|
|
@@ -103,35 +224,22 @@ export function multiplayer<P = unknown>(options: MultiplayerOptions<P>) {
|
|
|
103
224
|
'mp.relay': anything,
|
|
104
225
|
'mp.signal': anything,
|
|
105
226
|
},
|
|
106
|
-
on: {
|
|
107
|
-
/** A connection arrives, or comes back: it learns its id and everyone hears it. */
|
|
108
|
-
'mp.hello': async ({ msg, id, ctx, params, room }: Arrived<{ state: unknown }>) => {
|
|
109
|
-
const me = await peer({ id, ctx, params }, msg.state)
|
|
110
|
-
await room.publish('mp.you', { id, ticket: await ticket?.({ id, user: me.user, params, ctx }) }, { to: id, volatile: true })
|
|
111
|
-
await room.publish('mp.peer', me, { ...volatile, exceptSelf: true })
|
|
112
|
-
await room.publish('mp.ask', { id }, { ...volatile, exceptSelf: true })
|
|
113
|
-
},
|
|
114
|
-
'mp.state': async ({ msg, id, ctx, params, room }: Arrived<{ state: unknown }>) => room.publish('mp.peer', await peer({ id, ctx, params }, msg.state), { ...volatile, exceptSelf: true }),
|
|
115
|
-
'mp.answer': async ({ msg, id, ctx, params, room }: Arrived<{ to: string; state: unknown }>) => room.publish('mp.peer', await peer({ id, ctx, params }, msg.state), { ...volatile, to: msg.to }),
|
|
116
|
-
'mp.cursor': ({ msg, id, room }: Pick<Arrived<CursorAt | null>, 'msg' | 'id' | 'room'>) => room.publish('mp.cursor', { id, at: msg }, { ...volatile, exceptSelf: true }),
|
|
117
|
-
'mp.relay': async ({ msg, id, room }: Pick<Arrived<{ kind: string; data: unknown }>, 'msg' | 'id' | 'room'>) => {
|
|
118
|
-
const r = await relay[msg.kind]!['~standard'].validate(msg.data) // `relaySchema` refused any kind that isn't declared
|
|
119
|
-
if (r.issues) throw new StartError('Q002', `\`${msg.kind}\` refused its data`)
|
|
120
|
-
await room.publish('mp.relay', { id, kind: msg.kind, data: r.value }, { exceptSelf: true })
|
|
121
|
-
},
|
|
122
|
-
'mp.signal': ({ msg, id, room }: Pick<Arrived<{ to: string; data: unknown }>, 'msg' | 'id' | 'room'>) => room.publish('mp.signal', { from: id, data: msg.data }, { ...volatile, to: msg.to }),
|
|
123
|
-
},
|
|
124
|
-
left: async ({ id, room }: Pick<Arrived<unknown>, 'id' | 'room'>) => {
|
|
125
|
-
people.delete(id)
|
|
126
|
-
await room.publish('mp.gone', { id }, volatile)
|
|
127
|
-
},
|
|
128
227
|
}
|
|
129
228
|
}
|
|
130
229
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
230
|
+
const b64 = (bytes: Uint8Array) =>
|
|
231
|
+
btoa(String.fromCharCode(...bytes))
|
|
232
|
+
.replaceAll('+', '-')
|
|
233
|
+
.replaceAll('/', '_')
|
|
234
|
+
.replace(/=+$/, '')
|
|
235
|
+
const hmac = (secret: string) =>
|
|
236
|
+
crypto.subtle.importKey(
|
|
237
|
+
'raw',
|
|
238
|
+
new TextEncoder().encode(secret),
|
|
239
|
+
{ name: 'HMAC', hash: 'SHA-256' },
|
|
240
|
+
false,
|
|
241
|
+
['sign', 'verify'],
|
|
242
|
+
)
|
|
135
243
|
|
|
136
244
|
/** An HS256 JWT, which is what LiveKit reads and what a relay can check with one shared secret. */
|
|
137
245
|
export async function signTicket(claims: Record<string, unknown>, secret: string): Promise<string> {
|
|
@@ -141,13 +249,25 @@ export async function signTicket(claims: Record<string, unknown>, secret: string
|
|
|
141
249
|
}
|
|
142
250
|
|
|
143
251
|
/** The claims, or null: a bad signature, another algorithm, or past `exp`. */
|
|
144
|
-
export async function verifyTicket(
|
|
252
|
+
export async function verifyTicket(
|
|
253
|
+
ticket: unknown,
|
|
254
|
+
secret: string,
|
|
255
|
+
): Promise<Record<string, unknown> | null> {
|
|
145
256
|
const [head = '', body = '', sig] = String(ticket).split('.')
|
|
146
257
|
if (!sig) return null
|
|
147
|
-
const bytes = (s: string) =>
|
|
258
|
+
const bytes = (s: string) =>
|
|
259
|
+
Uint8Array.from(atob(s.replaceAll('-', '+').replaceAll('_', '/')), (c) => c.charCodeAt(0))
|
|
148
260
|
try {
|
|
149
261
|
if (JSON.parse(new TextDecoder().decode(bytes(head))).alg !== 'HS256') return null
|
|
150
|
-
if (
|
|
262
|
+
if (
|
|
263
|
+
!(await crypto.subtle.verify(
|
|
264
|
+
'HMAC',
|
|
265
|
+
await hmac(secret),
|
|
266
|
+
bytes(sig),
|
|
267
|
+
new TextEncoder().encode(`${head}.${body}`),
|
|
268
|
+
))
|
|
269
|
+
)
|
|
270
|
+
return null
|
|
151
271
|
const claims = JSON.parse(new TextDecoder().decode(bytes(body))) as Record<string, unknown>
|
|
152
272
|
return typeof claims.exp === 'number' && claims.exp * 1000 > Date.now() ? claims : null
|
|
153
273
|
} catch {
|
|
@@ -157,5 +277,12 @@ export async function verifyTicket(ticket: unknown, secret: string): Promise<Rec
|
|
|
157
277
|
|
|
158
278
|
/** A name for a room a third party can hold: stable, and it says nothing of the params. */
|
|
159
279
|
export async function roomId(params: unknown): Promise<string> {
|
|
160
|
-
return b64(
|
|
280
|
+
return b64(
|
|
281
|
+
new Uint8Array(
|
|
282
|
+
await crypto.subtle.digest(
|
|
283
|
+
'SHA-256',
|
|
284
|
+
new TextEncoder().encode(JSON.stringify(params ?? null)),
|
|
285
|
+
),
|
|
286
|
+
),
|
|
287
|
+
).slice(0, 32)
|
|
161
288
|
}
|