@weft-js/multiplayer 0.0.0-stage → 0.1.1
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/LICENSE +16 -0
- package/README.md +8 -2
- package/package.json +31 -4
- package/src/Cursors.tsx +25 -0
- package/src/conformance.ts +68 -0
- package/src/index.ts +387 -0
- package/src/server.ts +161 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 ravonus
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
|
|
6
|
+
documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
|
|
7
|
+
rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit
|
|
8
|
+
persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
9
|
+
|
|
10
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
|
|
11
|
+
Software.
|
|
12
|
+
|
|
13
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
|
|
14
|
+
WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
|
15
|
+
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
|
|
16
|
+
OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,9 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @weft-js/multiplayer
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
> **Early release (0.1.1).** APIs will change between minor versions.
|
|
4
|
+
|
|
5
|
+
Presence, cursors anchored to elements, a typed interaction relay and synced scroll on `channel`. A library, not a plugin.
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install @weft-js/multiplayer
|
|
9
|
+
```
|
package/package.json
CHANGED
|
@@ -1,6 +1,33 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@weft-js/multiplayer",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "@weft-js/multiplayer: presence, cursors anchored to elements, a typed interaction relay and synced scroll on `channel`. A library, not a plugin",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./src/index.ts",
|
|
9
|
+
"./server": "./src/server.ts",
|
|
10
|
+
"./ui": "./src/Cursors.tsx",
|
|
11
|
+
"./test": "./src/conformance.ts"
|
|
12
|
+
},
|
|
13
|
+
"peerDependencies": {
|
|
14
|
+
"@weft-js/start": "^0.1.1",
|
|
15
|
+
"@weft-js/core": "^0.1.1"
|
|
16
|
+
},
|
|
17
|
+
"engines": {
|
|
18
|
+
"node": ">=26"
|
|
19
|
+
},
|
|
20
|
+
"keywords": [
|
|
21
|
+
"weft"
|
|
22
|
+
],
|
|
23
|
+
"files": [
|
|
24
|
+
"src",
|
|
25
|
+
"plugin.mjs",
|
|
26
|
+
"plugin.d.mts",
|
|
27
|
+
"README.md",
|
|
28
|
+
"LICENSE"
|
|
29
|
+
],
|
|
30
|
+
"publishConfig": {
|
|
31
|
+
"access": "public"
|
|
32
|
+
}
|
|
33
|
+
}
|
package/src/Cursors.tsx
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// The other cursors, drawn over the page: `aria-hidden`, gliding 120 ms, and instant under reduced motion (warp 07 §6.3).
|
|
2
|
+
import { hue, type Cursors as Tracker } from './index'
|
|
3
|
+
|
|
4
|
+
const CSS = `[data-scope=cursors]{position:fixed;inset:0;pointer-events:none;overflow:hidden;z-index:2147483000}
|
|
5
|
+
[data-scope=cursors] [data-part=cursor]{position:absolute;left:0;top:0;transition:transform 120ms linear,opacity 300ms}
|
|
6
|
+
[data-scope=cursors] [data-part=name]{margin-left:10px;padding:1px 6px;border-radius:6px;font:12px/1.4 system-ui;color:#fff}
|
|
7
|
+
@media (prefers-reduced-motion:reduce){[data-scope=cursors] [data-part=cursor]{transition:none}}`
|
|
8
|
+
|
|
9
|
+
/** Draws everyone else's cursors from `cursors(room)`, each with its peer's name and colour. */
|
|
10
|
+
export function Cursors<S>(props: { of: Tracker<S> }) {
|
|
11
|
+
return (
|
|
12
|
+
<div aria-hidden="true" data-scope="cursors">
|
|
13
|
+
<style>{CSS}</style>
|
|
14
|
+
{props.of.list.value.map(c => {
|
|
15
|
+
const color = `hsl(${hue(c.peer?.user.id ?? c.id)} 80% 45%)`
|
|
16
|
+
return (
|
|
17
|
+
<div key={c.id} data-part="cursor" data-peer={c.id} style={`transform:translate(${c.left}px,${c.top}px);opacity:${c.idle ? 0.25 : 1};color:${color}`}>
|
|
18
|
+
<svg width="16" height="18" viewBox="0 0 16 18"><path fill="currentColor" stroke="#fff" d="M1 1l5 15 2.5-6L15 8z" /></svg>
|
|
19
|
+
{c.peer?.user.name && <span data-part="name" style={`background:${color}`}>{c.peer.user.name}</span>}
|
|
20
|
+
</div>
|
|
21
|
+
)
|
|
22
|
+
})}
|
|
23
|
+
</div>
|
|
24
|
+
)
|
|
25
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// What a transport pack has to do, for `@weft/realtime-*` (research 35 §4). Plain data in, Checks out: it runs in Node
|
|
2
|
+
// against a real relay, or in a page. `make` builds a transport for one named peer.
|
|
3
|
+
|
|
4
|
+
import { state, type Derived } from '@weft-js/core'
|
|
5
|
+
import type { PeerLink, PeerRoom, PeerTransport } from './index'
|
|
6
|
+
|
|
7
|
+
/** One requirement of the suite, and how the transport did. */
|
|
8
|
+
export interface Check { name: string; ok: boolean; detail: string }
|
|
9
|
+
type Got = { from: string; text: string }
|
|
10
|
+
|
|
11
|
+
const settle = async (done: () => boolean, ms = 8000) => {
|
|
12
|
+
for (const end = Date.now() + ms; !done() && Date.now() < end;) await new Promise(ok => { setTimeout(ok, 10) })
|
|
13
|
+
return done()
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** What `transportSuite` needs: a way to build the transport for a peer, and that peer's ticket if it takes one. */
|
|
17
|
+
export interface TransportSuiteOptions {
|
|
18
|
+
make: (peer: string) => PeerTransport | Promise<PeerTransport>
|
|
19
|
+
/** A credential for `peer`, as `multiplayer({ ticket })` would hand it. It may be a promise. */
|
|
20
|
+
ticket?: (peer: string) => unknown
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function fakeRoom(self: string, ids: Derived<string[]>, ticket: unknown, wires: Map<string, Set<(from: string, data: unknown) => void>>, got: Got[]): PeerRoom {
|
|
24
|
+
return {
|
|
25
|
+
self, ticket,
|
|
26
|
+
peers: { get value() { return ids.value.filter(p => p !== self) } },
|
|
27
|
+
signal: (to, data) => { for (const f of wires.get(to) ?? []) queueMicrotask(() => f(self, data)) },
|
|
28
|
+
onSignal(fn) {
|
|
29
|
+
const set = wires.get(self) ?? new Set()
|
|
30
|
+
wires.set(self, set)
|
|
31
|
+
set.add(fn)
|
|
32
|
+
return () => void set.delete(fn)
|
|
33
|
+
},
|
|
34
|
+
receive: (from, text) => void got.push({ from, text }),
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** One Check per requirement of a peer transport. */
|
|
39
|
+
export async function transportSuite({ make, ticket }: TransportSuiteOptions): Promise<Check[]> {
|
|
40
|
+
const out: Check[] = []
|
|
41
|
+
const check = (name: string, ok: boolean, detail = '') => void out.push({ name, ok, detail })
|
|
42
|
+
const ids = state(['a', 'b'])
|
|
43
|
+
const wires = new Map<string, Set<(from: string, data: unknown) => void>>()
|
|
44
|
+
const gotA: Got[] = [], gotB: Got[] = []
|
|
45
|
+
let a: PeerLink | undefined, b: PeerLink | undefined
|
|
46
|
+
try {
|
|
47
|
+
a = (await make('a')).open(fakeRoom('a', ids, await ticket?.('a'), wires, gotA))
|
|
48
|
+
b = (await make('b')).open(fakeRoom('b', ids, await ticket?.('b'), wires, gotB))
|
|
49
|
+
check('both links come up', await settle(() => a!.up.value && b!.up.value, 20_000), `${a.up.value} ${b.up.value}`)
|
|
50
|
+
a.send('{"x":1}')
|
|
51
|
+
check('a frame reaches the other peer, named by the transport', await settle(() => gotB.length > 0) && gotB[0]!.from === 'a' && gotB[0]!.text === '{"x":1}', JSON.stringify(gotB[0]))
|
|
52
|
+
await new Promise(ok => { setTimeout(ok, 100) })
|
|
53
|
+
check('a frame does not come back to its sender', gotA.length === 0, `${gotA.length}`)
|
|
54
|
+
b.send('{"y":2}')
|
|
55
|
+
check('frames go both ways', await settle(() => gotA.length > 0) && gotA[0]!.from === 'b', JSON.stringify(gotA[0]))
|
|
56
|
+
const before = gotB.length
|
|
57
|
+
a.close()
|
|
58
|
+
a = undefined
|
|
59
|
+
await new Promise(ok => { setTimeout(ok, 100) })
|
|
60
|
+
check('a closed link sends nothing', gotB.length === before, `${gotB.length - before}`)
|
|
61
|
+
} catch (e) {
|
|
62
|
+
check('the transport ran', false, String((e as Error | null)?.stack ?? e))
|
|
63
|
+
} finally {
|
|
64
|
+
a?.close()
|
|
65
|
+
b?.close()
|
|
66
|
+
}
|
|
67
|
+
return out
|
|
68
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
// Presence, cursors, a typed relay and synced scroll, on a `channel` connection (research 35). A library: nothing here
|
|
2
|
+
// is a plugin, and an app that doesn't import it ships none of it. The server's half is `@weft-js/multiplayer/server`.
|
|
3
|
+
//
|
|
4
|
+
// const chat = doc.connect(() => ({ id: props.id }))
|
|
5
|
+
// const room = presence(chat, { state: () => ({ typing: typing.value }) })
|
|
6
|
+
// room.peers.value // Peer[], without this connection
|
|
7
|
+
// const pointers = cursors(room) // follows the pointer; <Cursors of={pointers} /> draws the others
|
|
8
|
+
// const clicks = relay<{ ping: { at: string } }>(chat); clicks.on('ping', e => flash(e.data.at, e.peer))
|
|
9
|
+
|
|
10
|
+
import { derived, effect, onCleanup, onMount, state, untrack, type Derived, type State } from '@weft-js/core'
|
|
11
|
+
import type { Connection } from '@weft-js/start'
|
|
12
|
+
|
|
13
|
+
/** Who a peer is, from `user` on the server: never from the client. */
|
|
14
|
+
export interface PeerUser { id: string; name?: string; [key: string]: unknown }
|
|
15
|
+
/** Someone else in the room: their connection's id, who they are, the state they share and when they were last heard. */
|
|
16
|
+
export interface Peer<S = unknown> { id: string; user: PeerUser; state: S; at: number }
|
|
17
|
+
/** A pointer as fractions of `a`, an anchor's id, or of the viewport without one. */
|
|
18
|
+
export interface CursorAt { a?: string; x: number; y: number; style?: string }
|
|
19
|
+
|
|
20
|
+
// What `presence` needs of a connection: the `mp.*` names are the fragment's, which a `doc.connect()`'s types don't say.
|
|
21
|
+
type Conn = Pick<Connection<unknown, unknown>, 'status' | 'room' | 'onReset'> & {
|
|
22
|
+
on(name: string, fn: (message: unknown) => void): () => void
|
|
23
|
+
send(name: string, message: unknown): Promise<void>
|
|
24
|
+
}
|
|
25
|
+
/** One of the fragment's messages, whose shape the server's half decides. */
|
|
26
|
+
const hear = <M,>(conn: Pick<Conn, 'on'>, name: string, fn: (message: M) => void) => conn.on(name, fn as (message: unknown) => void)
|
|
27
|
+
|
|
28
|
+
/** What a transport pack is handed to carry cursors around the server (`@weft-js/realtime-webrtc`, `-livekit`, `-socketio`). */
|
|
29
|
+
export interface PeerRoom {
|
|
30
|
+
/** This connection, as the server named it. */
|
|
31
|
+
readonly self: string
|
|
32
|
+
/** From `ticket` in `multiplayer()`. */
|
|
33
|
+
readonly ticket: unknown
|
|
34
|
+
/** Who else is here, by connection. */
|
|
35
|
+
readonly peers: Derived<string[]>
|
|
36
|
+
/** A frame to one peer through the server, stamped with who sent it. For offers and candidates. */
|
|
37
|
+
signal(to: string, data: unknown): void
|
|
38
|
+
onSignal(fn: (from: string, data: unknown) => void): () => void
|
|
39
|
+
/** A frame arrived. `from` has to be one the transport proved, and the frame is checked again here. */
|
|
40
|
+
receive(from: string, text: string): void
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** An open transport, as `open` returns it. */
|
|
44
|
+
export interface PeerLink {
|
|
45
|
+
/** True while a `send` reaches every peer. Until then frames go through the server, so nothing waits on it. */
|
|
46
|
+
readonly up: Derived<boolean>
|
|
47
|
+
send(text: string): void
|
|
48
|
+
close(): void
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** A way to carry cursors around the server: what `webrtc()`, `livekit()` and `socketio()` return. */
|
|
52
|
+
export interface PeerTransport {
|
|
53
|
+
readonly name: string
|
|
54
|
+
open(room: PeerRoom): PeerLink
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Options of `presence()`. */
|
|
58
|
+
export interface PresenceOptions<S> {
|
|
59
|
+
/** This connection's own state. A function is followed. Say what's small: it goes to everyone on every change. */
|
|
60
|
+
state?: S | (() => S)
|
|
61
|
+
/** Ms between heartbeats. Default 15000, and peers expire after three missed. */
|
|
62
|
+
beat?: number
|
|
63
|
+
/** Carries cursors between peers, with the socket as the fallback. */
|
|
64
|
+
transport?: PeerTransport
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** What `presence()` returns: who else is in the room, kept current. */
|
|
68
|
+
export interface Presence<S> {
|
|
69
|
+
/** Everyone else here, oldest first. */
|
|
70
|
+
readonly peers: Derived<Peer<S>[]>
|
|
71
|
+
/** This connection, once the server has said. */
|
|
72
|
+
readonly self: Derived<string | undefined>
|
|
73
|
+
/** Which way cursors are going: `socket`, or the transport's name. */
|
|
74
|
+
readonly via: Derived<string>
|
|
75
|
+
readonly connection: Conn
|
|
76
|
+
/** @internal */ readonly link: () => PeerLink | undefined
|
|
77
|
+
/** @internal */ readonly seen: (fn: (id: string, at: CursorAt | null) => void) => void
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const STATE_GAP = 50
|
|
81
|
+
|
|
82
|
+
/** Roster logic with no DOM and no timers of its own, so a test can drive it. */
|
|
83
|
+
export const applyPeer = <S,>(list: Peer<S>[], p: Peer<S>): Peer<S>[] => (list.some(x => x.id === p.id) ? list.map(x => (x.id === p.id ? p : x)) : [...list, p])
|
|
84
|
+
/** The list without peers unheard for `ttl`, or the same list when nobody expired. */
|
|
85
|
+
export const expire = <S,>(list: Peer<S>[], now: number, ttl: number): Peer<S>[] => { const kept = list.filter(p => now - p.at <= ttl); return kept.length === list.length ? list : kept }
|
|
86
|
+
|
|
87
|
+
const silent = (p: Promise<unknown>) => void p.catch(() => {}) // a volatile message: the next one replaces it
|
|
88
|
+
|
|
89
|
+
/** Who else is on this connection. Announces this one, follows `state`, and expires peers that stop beating. */
|
|
90
|
+
export function presence<S = undefined>(conn: Conn, options: PresenceOptions<S> = {}): Presence<S> {
|
|
91
|
+
const { beat = 15_000, transport } = options
|
|
92
|
+
const ttl = beat * 3
|
|
93
|
+
const list = state<Peer<S>[]>([])
|
|
94
|
+
const self = state<string | undefined>(undefined)
|
|
95
|
+
const ticket = state<unknown>(undefined)
|
|
96
|
+
const own = () => (typeof options.state === 'function' ? (options.state as () => S)() : options.state) ?? null
|
|
97
|
+
const hello = () => silent(conn.send('mp.hello', { state: untrack(own) }))
|
|
98
|
+
const ids = derived(() => list.value.map(p => p.id))
|
|
99
|
+
const heard = new Set<(id: string, at: CursorAt | null) => void>()
|
|
100
|
+
const signals = new Set<(from: string, data: unknown) => void>()
|
|
101
|
+
let link: PeerLink | undefined
|
|
102
|
+
const linked = state<PeerLink | undefined>(undefined)
|
|
103
|
+
|
|
104
|
+
hear(conn, 'mp.you', ({ id, ticket: t }: { id: string; ticket?: unknown }) => {
|
|
105
|
+
self.value = id
|
|
106
|
+
ticket.value = t
|
|
107
|
+
})
|
|
108
|
+
hear(conn, 'mp.peer', (p: Peer<S>) => {
|
|
109
|
+
if (p.id === untrack(() => self.value)) return
|
|
110
|
+
// Stamped by this clock: another machine's would expire it early or never.
|
|
111
|
+
list.value = applyPeer(untrack(() => list.value), { ...p, at: Date.now() })
|
|
112
|
+
})
|
|
113
|
+
hear(conn, 'mp.gone', ({ id }: { id: string }) => {
|
|
114
|
+
list.value = untrack(() => list.value).filter(p => p.id !== id)
|
|
115
|
+
for (const f of heard) f(id, null)
|
|
116
|
+
})
|
|
117
|
+
hear(conn, 'mp.ask', ({ id }: { id: string }) => silent(conn.send('mp.answer', { to: id, state: untrack(own) })))
|
|
118
|
+
hear(conn, 'mp.cursor', ({ id, at }: { id: string; at: CursorAt | null }) => { for (const f of heard) f(id, at) })
|
|
119
|
+
hear(conn, 'mp.signal', ({ from, data }: { from: string; data: unknown }) => { for (const f of signals) f(from, data) })
|
|
120
|
+
conn.onReset(() => {
|
|
121
|
+
list.value = []
|
|
122
|
+
hello()
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
// Open, or moved to another room: everything held is about the last one.
|
|
126
|
+
effect(() => (conn.status.value === 'open' ? conn.room.value : undefined), (room, was) => {
|
|
127
|
+
if (room === was) return
|
|
128
|
+
list.value = []
|
|
129
|
+
self.value = undefined
|
|
130
|
+
if (room) hello()
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
let first = true
|
|
134
|
+
effect(() => own(), () => {
|
|
135
|
+
if (first) return void (first = false)
|
|
136
|
+
if (untrack(() => conn.status.value) === 'open') schedule()
|
|
137
|
+
})
|
|
138
|
+
let queued: ReturnType<typeof setTimeout> | undefined
|
|
139
|
+
const schedule = () => {
|
|
140
|
+
queued ??= setTimeout(() => {
|
|
141
|
+
queued = undefined
|
|
142
|
+
silent(conn.send('mp.state', { state: untrack(own) }))
|
|
143
|
+
}, STATE_GAP)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (typeof setInterval !== 'undefined' && !isServer()) {
|
|
147
|
+
const timer = setInterval(() => {
|
|
148
|
+
list.value = expire(untrack(() => list.value), Date.now(), ttl)
|
|
149
|
+
if (untrack(() => conn.status.value) === 'open') silent(conn.send('mp.state', { state: untrack(own) }))
|
|
150
|
+
}, beat)
|
|
151
|
+
onCleanup(() => {
|
|
152
|
+
clearInterval(timer)
|
|
153
|
+
clearTimeout(queued)
|
|
154
|
+
})
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (transport) {
|
|
158
|
+
effect(() => [self.value, ticket.value] as const, ([me, t]) => {
|
|
159
|
+
link?.close()
|
|
160
|
+
link = undefined
|
|
161
|
+
linked.value = undefined
|
|
162
|
+
if (!me) return
|
|
163
|
+
link = transport.open({
|
|
164
|
+
self: me,
|
|
165
|
+
ticket: t,
|
|
166
|
+
peers: ids,
|
|
167
|
+
signal: (to, data) => silent(conn.send('mp.signal', { to, data })),
|
|
168
|
+
onSignal: fn => { signals.add(fn); return () => void signals.delete(fn) },
|
|
169
|
+
receive(from, text) {
|
|
170
|
+
const at = readCursor(text)
|
|
171
|
+
if (at !== undefined && untrack(() => ids.value).includes(from)) for (const f of heard) f(from, at)
|
|
172
|
+
},
|
|
173
|
+
})
|
|
174
|
+
linked.value = link
|
|
175
|
+
})
|
|
176
|
+
onCleanup(() => link?.close())
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return {
|
|
180
|
+
peers: list,
|
|
181
|
+
self,
|
|
182
|
+
via: derived(() => (linked.value?.up.value ? transport!.name : 'socket')),
|
|
183
|
+
connection: conn,
|
|
184
|
+
link: () => { const l = untrack(() => linked.value); return l && untrack(() => l.up.value) ? l : undefined },
|
|
185
|
+
seen: fn => { heard.add(fn); onCleanup(() => void heard.delete(fn)) },
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const isServer = () => typeof document === 'undefined'
|
|
190
|
+
|
|
191
|
+
/** A frame off a transport: no server looked at it, so it's read as hostile. Undefined is refused. */
|
|
192
|
+
export function readCursor(text: string): CursorAt | null | undefined {
|
|
193
|
+
let parsed: unknown
|
|
194
|
+
try { parsed = JSON.parse(text) } catch { return undefined }
|
|
195
|
+
if (parsed === null) return null
|
|
196
|
+
if (typeof parsed !== 'object') return undefined
|
|
197
|
+
const v = parsed as Record<string, unknown>
|
|
198
|
+
const unit = (n: unknown): n is number => typeof n === 'number' && n >= -0.5 && n <= 1.5
|
|
199
|
+
if (!unit(v.x) || !unit(v.y)) return undefined
|
|
200
|
+
if (v.a !== undefined && (typeof v.a !== 'string' || v.a.length > 128)) return undefined
|
|
201
|
+
return { x: v.x, y: v.y, ...(typeof v.a === 'string' ? { a: v.a } : {}), ...(typeof v.style === 'string' && v.style.length <= 16 ? { style: v.style } : {}) }
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// ---- cursors --------------------------------------------------------------------------------------------------------
|
|
205
|
+
|
|
206
|
+
/** Options of `cursors()`. */
|
|
207
|
+
export interface CursorOptions {
|
|
208
|
+
/** What a position is measured from. An element's id, in this attribute, on both sides. Default `data-anchor`. */
|
|
209
|
+
anchor?: string
|
|
210
|
+
/** Sends a second. Default 12, which keeps a socket under its default 20 a second with room for everything else. */
|
|
211
|
+
rate?: number
|
|
212
|
+
/** Ms before a still cursor fades. Default 5000. */
|
|
213
|
+
idle?: number
|
|
214
|
+
/** Where the pointer is followed. Default the document. */
|
|
215
|
+
root?: () => HTMLElement | Document
|
|
216
|
+
style?: string
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** One other cursor, placed on this screen. `peer` is missing until their presence arrives. */
|
|
220
|
+
export interface LiveCursor<S = unknown> { id: string; peer?: Peer<S> | undefined; at: CursorAt; left: number; top: number; moved: number; idle: boolean }
|
|
221
|
+
/** What `cursors()` returns, for `<Cursors of={…} />`. */
|
|
222
|
+
export interface Cursors<S = unknown> { readonly list: Derived<LiveCursor<S>[]>; readonly anchor: string }
|
|
223
|
+
|
|
224
|
+
/** Where a local pointer is, relative to the nearest anchor, or the viewport without one. Pure but for reading layout. */
|
|
225
|
+
export function locate(target: Element | null, x: number, y: number, attr = 'data-anchor', view = { width: innerWidth, height: innerHeight }): CursorAt {
|
|
226
|
+
const el = target?.closest?.(`[${attr}]`) as HTMLElement | null | undefined
|
|
227
|
+
if (el) {
|
|
228
|
+
const r = el.getBoundingClientRect()
|
|
229
|
+
const w = Math.max(el.scrollWidth, r.width) || 1, h = Math.max(el.scrollHeight, r.height) || 1
|
|
230
|
+
return { a: el.getAttribute(attr)!, x: (x - r.left + el.scrollLeft) / w, y: (y - r.top + el.scrollTop) / h }
|
|
231
|
+
}
|
|
232
|
+
return { x: x / view.width, y: y / view.height }
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** The same spot on this screen. Null when the anchor isn't here: a cursor at (0,0) would be a lie. */
|
|
236
|
+
export function resolve(at: CursorAt, attr = 'data-anchor', view = { width: innerWidth, height: innerHeight }): { left: number; top: number } | null {
|
|
237
|
+
if (!at.a) return { left: at.x * view.width, top: at.y * view.height }
|
|
238
|
+
const el = document.querySelector<HTMLElement>(`[${attr}="${CSS.escape(at.a)}"]`)
|
|
239
|
+
if (!el) return null
|
|
240
|
+
const r = el.getBoundingClientRect()
|
|
241
|
+
return { left: r.left + at.x * Math.max(el.scrollWidth, r.width) - el.scrollLeft, top: r.top + at.y * Math.max(el.scrollHeight, r.height) - el.scrollTop }
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** Sends this pointer, latest wins, and tracks everyone else's. Sending and the page's own events stop with the owner. */
|
|
245
|
+
export function cursors<S>(room: Presence<S>, options: CursorOptions = {}): Cursors<S> {
|
|
246
|
+
const { anchor = 'data-anchor', rate = 12, idle = 5000, root, style } = options
|
|
247
|
+
const gap = 1000 / rate
|
|
248
|
+
const raw = state<Record<string, { at: CursorAt; moved: number }>>({})
|
|
249
|
+
const layout = state(0)
|
|
250
|
+
const clock = state(0)
|
|
251
|
+
const send = (at: CursorAt | null) => {
|
|
252
|
+
const link = room.link()
|
|
253
|
+
if (link) link.send(JSON.stringify(at))
|
|
254
|
+
else silent(room.connection.send('mp.cursor', at))
|
|
255
|
+
}
|
|
256
|
+
room.seen((id, at) => {
|
|
257
|
+
const { [id]: _, ...rest } = untrack(() => raw.value)
|
|
258
|
+
raw.value = at ? { ...rest, [id]: { at, moved: Date.now() } } : rest
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
onMount(() => {
|
|
262
|
+
if (isServer()) return
|
|
263
|
+
const host = root?.() ?? document
|
|
264
|
+
let last = 0, waiting: ReturnType<typeof setTimeout> | undefined, next: CursorAt | null | undefined
|
|
265
|
+
const flush = () => {
|
|
266
|
+
waiting = undefined
|
|
267
|
+
if (next === undefined) return
|
|
268
|
+
last = performance.now()
|
|
269
|
+
send(next)
|
|
270
|
+
next = undefined
|
|
271
|
+
}
|
|
272
|
+
const put = (at: CursorAt | null) => {
|
|
273
|
+
next = at
|
|
274
|
+
const wait = gap - (performance.now() - last)
|
|
275
|
+
if (wait <= 0) flush()
|
|
276
|
+
else waiting ??= setTimeout(flush, wait)
|
|
277
|
+
}
|
|
278
|
+
const move = (e: Event) => {
|
|
279
|
+
const p = e as PointerEvent
|
|
280
|
+
put({ ...locate(p.target as Element, p.clientX, p.clientY, anchor), ...(style ? { style } : {}) })
|
|
281
|
+
}
|
|
282
|
+
const out = () => put(null)
|
|
283
|
+
let frame = 0
|
|
284
|
+
const moved = () => { frame ||= requestAnimationFrame(() => { frame = 0; layout.value++ }) }
|
|
285
|
+
host.addEventListener('pointermove', move, { passive: true })
|
|
286
|
+
document.documentElement.addEventListener('pointerleave', out)
|
|
287
|
+
addEventListener('scroll', moved, { passive: true, capture: true })
|
|
288
|
+
addEventListener('resize', moved)
|
|
289
|
+
const tick = setInterval(() => { clock.value = Date.now() }, 1000)
|
|
290
|
+
onCleanup(() => {
|
|
291
|
+
host.removeEventListener('pointermove', move)
|
|
292
|
+
document.documentElement.removeEventListener('pointerleave', out)
|
|
293
|
+
removeEventListener('scroll', moved, { capture: true })
|
|
294
|
+
removeEventListener('resize', moved)
|
|
295
|
+
clearInterval(tick)
|
|
296
|
+
clearTimeout(waiting)
|
|
297
|
+
cancelAnimationFrame(frame)
|
|
298
|
+
})
|
|
299
|
+
})
|
|
300
|
+
|
|
301
|
+
const list = derived(() => {
|
|
302
|
+
void layout.value
|
|
303
|
+
const now = clock.value || Date.now()
|
|
304
|
+
const peers = room.peers.value
|
|
305
|
+
const out: LiveCursor<S>[] = []
|
|
306
|
+
for (const [id, { at, moved }] of Object.entries(raw.value)) {
|
|
307
|
+
const spot = resolve(at, anchor)
|
|
308
|
+
if (spot) out.push({ id, peer: peers.find(p => p.id === id), at, ...spot, moved, idle: now - moved > idle })
|
|
309
|
+
}
|
|
310
|
+
return out
|
|
311
|
+
})
|
|
312
|
+
return { list, anchor }
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** A peer's colour, the same for them everywhere. */
|
|
316
|
+
export function hue(id: string): number {
|
|
317
|
+
let h = 0
|
|
318
|
+
for (let i = 0; i < id.length; i++) h = (h * 31 + id.charCodeAt(i)) | 0
|
|
319
|
+
return Math.abs(h) % 360
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// ---- relay ----------------------------------------------------------------------------------------------------------
|
|
323
|
+
|
|
324
|
+
/** Something a peer did, named and validated by the server, and which peer did it. */
|
|
325
|
+
export interface RelayEvent<D> { peer: string; kind: string; data: D }
|
|
326
|
+
|
|
327
|
+
/** Something a peer did, delivered to a callback. Never a synthetic DOM event: the page decides what it means. */
|
|
328
|
+
export function relay<K extends Record<string, unknown>>(conn: Pick<Conn, 'on' | 'send'>) {
|
|
329
|
+
const handlers = new Map<string, Set<(e: RelayEvent<unknown>) => void>>()
|
|
330
|
+
hear(conn, 'mp.relay', ({ id, kind, data }: { id: string; kind: string; data: unknown }) => { for (const f of handlers.get(kind) ?? []) f({ peer: id, kind, data }) })
|
|
331
|
+
return {
|
|
332
|
+
emit: <N extends keyof K & string>(kind: N, data: K[N]) => conn.send('mp.relay', { kind, data }),
|
|
333
|
+
on<N extends keyof K & string>(kind: N, fn: (e: RelayEvent<K[N]>) => void) {
|
|
334
|
+
const set = handlers.get(kind) ?? new Set()
|
|
335
|
+
handlers.set(kind, set)
|
|
336
|
+
const handler = fn as (e: RelayEvent<unknown>) => void // the server checked `data` against this kind's schema
|
|
337
|
+
set.add(handler)
|
|
338
|
+
const stop = () => void set.delete(handler)
|
|
339
|
+
onCleanup(stop)
|
|
340
|
+
return stop
|
|
341
|
+
},
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// ---- synced scroll and hash: the box, not the DOM ------------------------------------------------------
|
|
346
|
+
|
|
347
|
+
/** One scroll position in a shared box, as a fraction, so different heights land on the same place. */
|
|
348
|
+
export function syncedScroll(box: State<number>, element: () => HTMLElement | Window = () => window) {
|
|
349
|
+
onMount(() => {
|
|
350
|
+
if (isServer()) return
|
|
351
|
+
const el = element()
|
|
352
|
+
const page = el === window
|
|
353
|
+
const max = () => (page ? document.documentElement.scrollHeight - innerHeight : (el as HTMLElement).scrollHeight - (el as HTMLElement).clientHeight)
|
|
354
|
+
const now = () => (page ? scrollY : (el as HTMLElement).scrollTop)
|
|
355
|
+
let held = 0, frame = 0
|
|
356
|
+
const on = () => {
|
|
357
|
+
if (performance.now() < held) return
|
|
358
|
+
frame ||= requestAnimationFrame(() => {
|
|
359
|
+
frame = 0
|
|
360
|
+
const m = max()
|
|
361
|
+
if (m > 0) box.value = Math.round((now() / m) * 10_000) / 10_000
|
|
362
|
+
})
|
|
363
|
+
}
|
|
364
|
+
el.addEventListener('scroll', on, { passive: true })
|
|
365
|
+
onCleanup(() => {
|
|
366
|
+
el.removeEventListener('scroll', on)
|
|
367
|
+
cancelAnimationFrame(frame)
|
|
368
|
+
})
|
|
369
|
+
effect(() => box.value, f => {
|
|
370
|
+
const top = f * max()
|
|
371
|
+
if (Math.abs(top - now()) < 2) return
|
|
372
|
+
held = performance.now() + 150 // what this write causes isn't the user scrolling
|
|
373
|
+
el.scrollTo({ top })
|
|
374
|
+
})
|
|
375
|
+
})
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/** The URL hash in a shared box. */
|
|
379
|
+
export function syncedHash(box: State<string>) {
|
|
380
|
+
onMount(() => {
|
|
381
|
+
if (isServer()) return
|
|
382
|
+
const on = () => { box.value = location.hash }
|
|
383
|
+
addEventListener('hashchange', on)
|
|
384
|
+
onCleanup(() => removeEventListener('hashchange', on))
|
|
385
|
+
effect(() => box.value, h => { if (h && h !== location.hash) location.hash = h })
|
|
386
|
+
})
|
|
387
|
+
}
|
package/src/server.ts
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
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
|
+
// Who a peer is comes from `user`, never from the client: a connection names only its own state and cursor, and every
|
|
11
|
+
// frame is stamped here with the connection that sent it. There's no roster on the server: each client
|
|
12
|
+
// answers an `ask`, peers expire without a beat, so any bus works and a dead instance's peers just age out.
|
|
13
|
+
|
|
14
|
+
import { StartError } from '@weft-js/start/data'
|
|
15
|
+
import type { Context, PublishOptions, StandardSchemaV1 } from '@weft-js/start'
|
|
16
|
+
|
|
17
|
+
/** Who a peer is: what `user` returns. */
|
|
18
|
+
export interface PeerUser { id: string; name?: string; [key: string]: unknown }
|
|
19
|
+
/** 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 }
|
|
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 }
|
|
23
|
+
|
|
24
|
+
type Issue = { issues: { message: string }[] }
|
|
25
|
+
/** Whatever a client sent: read as hostile, field by field. */
|
|
26
|
+
type Wire = { [key: string]: unknown } | null | undefined
|
|
27
|
+
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 } } } })
|
|
29
|
+
const no = (message: string): Issue => ({ issues: [{ message }] })
|
|
30
|
+
const within = (v: unknown, bytes: number) => { try { return (JSON.stringify(v) ?? '').length <= bytes } catch { return false } }
|
|
31
|
+
const unit = (n: unknown): n is number => typeof n === 'number' && n >= -0.5 && n <= 1.5
|
|
32
|
+
|
|
33
|
+
const STATE = 4096, RELAY = 2048, SIGNAL = 16_384
|
|
34
|
+
|
|
35
|
+
const anything = schema(v => v)
|
|
36
|
+
|
|
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`)))
|
|
38
|
+
|
|
39
|
+
/** 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 => {
|
|
41
|
+
if (v === null) return null
|
|
42
|
+
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 }) }
|
|
46
|
+
})
|
|
47
|
+
|
|
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`)))
|
|
50
|
+
|
|
51
|
+
/** 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 }
|
|
53
|
+
|
|
54
|
+
/** Options of `multiplayer()`. */
|
|
55
|
+
export interface MultiplayerOptions<P> {
|
|
56
|
+
/** Who this connection is. Required: a peer can't name itself. */
|
|
57
|
+
user: (args: { ctx: Context; params: P; id: string }) => PeerUser | Promise<PeerUser>
|
|
58
|
+
/** The relay's event kinds and what each may carry. Nothing else is relayed. */
|
|
59
|
+
relay?: Record<string, StandardSchemaV1>
|
|
60
|
+
/** A transport's credential, handed to the client with its id. `livekitTicket`, `socketioTicket`. */
|
|
61
|
+
ticket?: (args: TicketArgs<P>) => unknown
|
|
62
|
+
/** Bytes one peer's `state` may be. Default 4096. */
|
|
63
|
+
state?: number
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** 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> }
|
|
68
|
+
|
|
69
|
+
/** `client`, `server`, `on` and `left` to spread into a `channel`. Every message is `mp.*`, and all but `mp.relay` are volatile. */
|
|
70
|
+
export function multiplayer<P = unknown>(options: MultiplayerOptions<P>) {
|
|
71
|
+
const { user, relay = {}, ticket, state = STATE } = options
|
|
72
|
+
type Arrived<M> = { msg: M; id: string; ctx: Context; params: P; room: MpRoom }
|
|
73
|
+
const people = new Map<string, PeerUser>()
|
|
74
|
+
const who = async (args: { ctx: Context; params: P; id: string }) => {
|
|
75
|
+
const known = people.get(args.id)
|
|
76
|
+
if (known) return known
|
|
77
|
+
const made = await user(args)
|
|
78
|
+
if (!made || typeof made.id !== 'string') throw new Error('`user` must return an object with an `id`')
|
|
79
|
+
people.set(args.id, made)
|
|
80
|
+
return made
|
|
81
|
+
}
|
|
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() })
|
|
83
|
+
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')
|
|
86
|
+
return { kind: v.kind, data: v.data }
|
|
87
|
+
})
|
|
88
|
+
return {
|
|
89
|
+
client: {
|
|
90
|
+
'mp.hello': stateful(state),
|
|
91
|
+
'mp.state': stateful(state),
|
|
92
|
+
'mp.answer': answerSchema,
|
|
93
|
+
'mp.cursor': cursorSchema,
|
|
94
|
+
'mp.relay': relaySchema,
|
|
95
|
+
'mp.signal': signalSchema,
|
|
96
|
+
},
|
|
97
|
+
server: {
|
|
98
|
+
'mp.you': anything,
|
|
99
|
+
'mp.peer': anything,
|
|
100
|
+
'mp.gone': anything,
|
|
101
|
+
'mp.ask': anything,
|
|
102
|
+
'mp.cursor': anything,
|
|
103
|
+
'mp.relay': anything,
|
|
104
|
+
'mp.signal': anything,
|
|
105
|
+
},
|
|
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
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
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'])
|
|
135
|
+
|
|
136
|
+
/** An HS256 JWT, which is what LiveKit reads and what a relay can check with one shared secret. */
|
|
137
|
+
export async function signTicket(claims: Record<string, unknown>, secret: string): Promise<string> {
|
|
138
|
+
const text = (v: unknown) => b64(new TextEncoder().encode(JSON.stringify(v)))
|
|
139
|
+
const body = `${text({ alg: 'HS256', typ: 'JWT' })}.${text(claims)}`
|
|
140
|
+
return `${body}.${b64(new Uint8Array(await crypto.subtle.sign('HMAC', await hmac(secret), new TextEncoder().encode(body))))}`
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** 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> {
|
|
145
|
+
const [head = '', body = '', sig] = String(ticket).split('.')
|
|
146
|
+
if (!sig) return null
|
|
147
|
+
const bytes = (s: string) => Uint8Array.from(atob(s.replaceAll('-', '+').replaceAll('_', '/')), c => c.charCodeAt(0))
|
|
148
|
+
try {
|
|
149
|
+
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
|
|
151
|
+
const claims = JSON.parse(new TextDecoder().decode(bytes(body))) as Record<string, unknown>
|
|
152
|
+
return typeof claims.exp === 'number' && claims.exp * 1000 > Date.now() ? claims : null
|
|
153
|
+
} catch {
|
|
154
|
+
return null
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** A name for a room a third party can hold: stable, and it says nothing of the params. */
|
|
159
|
+
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)
|
|
161
|
+
}
|