@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/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 { id: string; name?: string; [key: string]: unknown }
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> { id: string; user: PeerUser; state: S; at: number }
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 { a?: string; x: number; y: number; style?: string }
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,>(validate: (v: Wire) => T | Issue): StandardSchemaV1<T, T> => ({ '~standard': { version: 1, vendor: 'weft', validate: (v: unknown) => { const r = validate(v as Wire); return isIssue(r) ? r : { value: r } } } })
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) => { try { return (JSON.stringify(v) ?? '').length <= bytes } catch { return false } }
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, RELAY = 2048, SIGNAL = 16_384
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) => schema(v => (v && typeof v === 'object' && within(v.state, max) ? { state: v.state } : no(`\`state\` is JSON up to ${max} bytes`)))
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)) return no('an anchor is up to 128 characters')
44
- if (v.style !== undefined && (typeof v.style !== 'string' || v.style.length > 16)) return no('a style is up to 16 characters')
45
- return { x: v.x, y: v.y, ...(v.a === undefined ? {} : { a: v.a }), ...(v.style === undefined ? {} : { style: v.style }) }
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(v => (v && typeof v.to === 'string' && within(v.state, STATE) ? { to: v.to, state: v.state } : no('an answer names who asked')))
49
- const signalSchema = schema(v => (v && typeof v.to === 'string' && within(v.data, SIGNAL) ? { to: v.to, data: v.data } : no(`a signal names a peer, with up to ${SIGNAL} bytes`)))
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> { id: string; user: PeerUser; params: P; ctx: Context }
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 { publish(name: string, message: unknown, options?: PublishOptions): Promise<void> }
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') throw new Error('`user` must return an object with an `id`')
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) => ({ id: a.id, user: await who(a), state: value, at: Date.now() })
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 = schema(v => {
85
- if (!v || typeof v.kind !== 'string' || !Object.hasOwn(relay, v.kind) || !within(v.data, RELAY)) return no('that relay kind is not declared')
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
- // ---- tickets --------------------------------------------------------------------------------------------------------
132
-
133
- const b64 = (bytes: Uint8Array) => btoa(String.fromCharCode(...bytes)).replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/, '')
134
- const hmac = (secret: string) => crypto.subtle.importKey('raw', new TextEncoder().encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign', 'verify'])
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(ticket: unknown, secret: string): Promise<Record<string, unknown> | null> {
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) => Uint8Array.from(atob(s.replaceAll('-', '+').replaceAll('_', '/')), c => c.charCodeAt(0))
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 (!(await crypto.subtle.verify('HMAC', await hmac(secret), bytes(sig), new TextEncoder().encode(`${head}.${body}`)))) return null
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(new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(JSON.stringify(params ?? null))))).slice(0, 32)
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
  }