@lincyaw/classroom-server 1.0.0-rc.0 → 1.0.0-rc.2
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 +3 -3
- package/package.json +4 -4
- package/src/cli.ts +6 -10
- package/src/index.ts +3 -447
- package/src/permissions.ts +120 -0
- package/src/room.ts +79 -0
- package/src/server.ts +266 -0
- /package/bin/{server.mjs → presmium-classroom-server.mjs} +0 -0
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
Presmium's classroom sync server: the y-websocket rooms classroom sessions sync through.
|
|
4
4
|
|
|
5
|
-
Part of [Presmium](https://github.com/Lincyaw/presmium), which
|
|
5
|
+
Part of [Presmium](https://github.com/Lincyaw/presmium), which compiles canonical lecture.json teaching documents into web pages, slides, Jupyter notebooks and narrated playback.
|
|
6
6
|
|
|
7
7
|
## Usage
|
|
8
8
|
|
|
@@ -10,11 +10,11 @@ Part of [Presmium](https://github.com/Lincyaw/presmium), which turns lectures wr
|
|
|
10
10
|
pnpm exec presmium-classroom-server --port 1234
|
|
11
11
|
```
|
|
12
12
|
|
|
13
|
-
Documents point to it
|
|
13
|
+
Documents point to it through `metadata.presmium["presmium.session"].server` in `lecture.json`. `presmium dev` and `presmium serve` also run one on their own port. Options: `--port`, `--host`, `--deck` (where archived classrooms are written). Run it on the classroom's own network: anyone with a join link can join.
|
|
14
14
|
|
|
15
15
|
## Documentation
|
|
16
16
|
|
|
17
|
-
- [Author guide](https://github.com/Lincyaw/presmium/blob/main/site/guide/lecture.
|
|
17
|
+
- [Author guide](https://github.com/Lincyaw/presmium/blob/main/site/guide/locales/en/lecture.json)
|
|
18
18
|
|
|
19
19
|
## License
|
|
20
20
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lincyaw/classroom-server",
|
|
3
|
-
"version": "1.0.0-rc.
|
|
3
|
+
"version": "1.0.0-rc.2",
|
|
4
4
|
"description": "Presmium's classroom sync server: y-websocket rooms",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"repository": {
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
"access": "public"
|
|
24
24
|
},
|
|
25
25
|
"bin": {
|
|
26
|
-
"presmium-classroom-server": "./bin/server.mjs"
|
|
26
|
+
"presmium-classroom-server": "./bin/presmium-classroom-server.mjs"
|
|
27
27
|
},
|
|
28
28
|
"exports": {
|
|
29
29
|
".": "./src/index.ts"
|
|
@@ -38,13 +38,13 @@
|
|
|
38
38
|
"ws": "^8.22.0",
|
|
39
39
|
"y-protocols": "^1.0.7",
|
|
40
40
|
"yjs": "^13.6.33",
|
|
41
|
-
"@lincyaw/kernel": "1.0.0-rc.
|
|
41
|
+
"@lincyaw/kernel": "1.0.0-rc.2"
|
|
42
42
|
},
|
|
43
43
|
"devDependencies": {
|
|
44
44
|
"@types/ws": "^8.18.2",
|
|
45
45
|
"y-websocket": "^3.1.0"
|
|
46
46
|
},
|
|
47
47
|
"scripts": {
|
|
48
|
-
"start": "node bin/server.mjs"
|
|
48
|
+
"start": "node bin/presmium-classroom-server.mjs"
|
|
49
49
|
}
|
|
50
50
|
}
|
package/src/cli.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { networkInterfaces } from 'node:os'
|
|
2
1
|
import { parseArgs } from 'node:util'
|
|
3
2
|
import { errorMessage } from '@lincyaw/kernel'
|
|
4
|
-
import {
|
|
3
|
+
import { lanAddresses } from '@lincyaw/kernel/node'
|
|
4
|
+
import { startClassroomServer } from './server.ts'
|
|
5
5
|
|
|
6
6
|
const USAGE = `Usage: presmium-classroom-server [--port <n>] [--host <address>] [--deck <dir>]
|
|
7
7
|
|
|
@@ -23,7 +23,7 @@ questions, votes and submissions.`
|
|
|
23
23
|
|
|
24
24
|
export async function main(argv: string[]): Promise<number | undefined> {
|
|
25
25
|
let port: number
|
|
26
|
-
let
|
|
26
|
+
let listenAddress: string | undefined
|
|
27
27
|
let deck: string | undefined
|
|
28
28
|
try {
|
|
29
29
|
const { values } = parseArgs({
|
|
@@ -36,21 +36,17 @@ export async function main(argv: string[]): Promise<number | undefined> {
|
|
|
36
36
|
}
|
|
37
37
|
port = Number(values.port ?? 1234)
|
|
38
38
|
if (!Number.isInteger(port) || port < 0 || port > 65535) throw new Error(`invalid port "${values.port}"`)
|
|
39
|
-
|
|
39
|
+
listenAddress = values.host
|
|
40
40
|
deck = values.deck
|
|
41
41
|
} catch (error) {
|
|
42
42
|
process.stderr.write(`presmium-classroom-server: ${errorMessage(error)}\n\n${USAGE}\n`)
|
|
43
43
|
return 2
|
|
44
44
|
}
|
|
45
|
-
const server = await startClassroomServer({ port,
|
|
46
|
-
const network = !
|
|
45
|
+
const server = await startClassroomServer({ port, listenAddress, deck })
|
|
46
|
+
const network = !listenAddress || listenAddress === '0.0.0.0' || listenAddress === '::' ? lanAddresses().map(address => `network ws://${address}:${server.port}\n`) : []
|
|
47
47
|
process.stdout.write(`classroom server ${server.url}\n${network.join('')}archives ${server.archiveDir}\n`)
|
|
48
48
|
const stop = () => void server.close().then(() => process.exit(0))
|
|
49
49
|
process.once('SIGINT', stop)
|
|
50
50
|
process.once('SIGTERM', stop)
|
|
51
51
|
return undefined
|
|
52
52
|
}
|
|
53
|
-
|
|
54
|
-
function lanAddresses(): string[] {
|
|
55
|
-
return Object.values(networkInterfaces()).flatMap(entries => entries ?? []).filter(entry => entry.family === 'IPv4' && !entry.internal).map(entry => entry.address)
|
|
56
|
-
}
|
package/src/index.ts
CHANGED
|
@@ -1,447 +1,3 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
5
|
-
import type { AddressInfo } from 'node:net'
|
|
6
|
-
import { join, resolve } from 'node:path'
|
|
7
|
-
import { errorMessage } from '@lincyaw/kernel'
|
|
8
|
-
import * as decoding from 'lib0/decoding'
|
|
9
|
-
import * as encoding from 'lib0/encoding'
|
|
10
|
-
import { WebSocket, WebSocketServer } from 'ws'
|
|
11
|
-
import * as awarenessProtocol from 'y-protocols/awareness'
|
|
12
|
-
import * as syncProtocol from 'y-protocols/sync'
|
|
13
|
-
import * as Y from 'yjs'
|
|
14
|
-
|
|
15
|
-
const MESSAGE_SYNC = 0
|
|
16
|
-
const MESSAGE_AWARENESS = 1
|
|
17
|
-
const PING_INTERVAL = 30_000
|
|
18
|
-
const ROOM = /^[\w.-]{1,128}$/
|
|
19
|
-
export const ARCHIVED_CLOSE_CODE = 4410
|
|
20
|
-
export const REFUSED_CLOSE_CODE = 4403
|
|
21
|
-
export const ARCHIVE_DIR = '.presmium/classroom'
|
|
22
|
-
const MAX_MESSAGE = 1 << 20
|
|
23
|
-
const MAX_ENTRY = 16 << 10
|
|
24
|
-
|
|
25
|
-
class Room extends Y.Doc {
|
|
26
|
-
readonly conns = new Map<WebSocket, Set<number>>()
|
|
27
|
-
readonly writers = new Map<WebSocket, string>()
|
|
28
|
-
readonly authors = new Map<number, string>()
|
|
29
|
-
readonly awareness?: awarenessProtocol.Awareness
|
|
30
|
-
archived = false
|
|
31
|
-
claim?: Buffer
|
|
32
|
-
readonly participants = new Map<string, Buffer>()
|
|
33
|
-
readonly personal: boolean
|
|
34
|
-
|
|
35
|
-
constructor(name: string) {
|
|
36
|
-
super({ gc: true })
|
|
37
|
-
this.authors.set(this.clientID, '')
|
|
38
|
-
this.personal = name.includes('.')
|
|
39
|
-
this.on('update', (update: Uint8Array) => {
|
|
40
|
-
const encoder = encoding.createEncoder()
|
|
41
|
-
encoding.writeVarUint(encoder, MESSAGE_SYNC)
|
|
42
|
-
syncProtocol.writeUpdate(encoder, update)
|
|
43
|
-
this.broadcast(encoding.toUint8Array(encoder))
|
|
44
|
-
})
|
|
45
|
-
if (this.personal) return
|
|
46
|
-
const awareness = this.awareness = new awarenessProtocol.Awareness(this)
|
|
47
|
-
awareness.setLocalState(null)
|
|
48
|
-
awareness.on('update', ({ added, updated, removed }: Record<'added' | 'updated' | 'removed', number[]>, conn: WebSocket | null) => {
|
|
49
|
-
const changed = [...added, ...updated, ...removed]
|
|
50
|
-
const controlled = conn && this.conns.get(conn)
|
|
51
|
-
if (controlled) {
|
|
52
|
-
added.forEach(id => controlled.add(id))
|
|
53
|
-
removed.forEach(id => controlled.delete(id))
|
|
54
|
-
const writer = this.writers.get(conn)!
|
|
55
|
-
for (const id of added) if (!this.authors.has(id)) this.authors.set(id, writer)
|
|
56
|
-
}
|
|
57
|
-
this.broadcast(awarenessMessage(awareness, changed))
|
|
58
|
-
})
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
get session(): Y.Map<unknown> {
|
|
62
|
-
return this.getMap('session')
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
get ended(): boolean {
|
|
66
|
-
return this.session.get('status') === 'ended'
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
broadcast(message: Uint8Array): void {
|
|
70
|
-
for (const conn of this.conns.keys()) send(conn, message)
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
content(): Record<string, unknown> {
|
|
74
|
-
return Object.fromEntries([...this.share.keys()].map(key => [key, this.getMap(key).toJSON()]))
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
override destroy(): void {
|
|
78
|
-
this.awareness?.destroy()
|
|
79
|
-
super.destroy()
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
function awarenessMessage(awareness: awarenessProtocol.Awareness, clients: number[]): Uint8Array {
|
|
84
|
-
const encoder = encoding.createEncoder()
|
|
85
|
-
encoding.writeVarUint(encoder, MESSAGE_AWARENESS)
|
|
86
|
-
encoding.writeVarUint8Array(encoder, awarenessProtocol.encodeAwarenessUpdate(awareness, clients))
|
|
87
|
-
return encoding.toUint8Array(encoder)
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
function send(conn: WebSocket, message: Uint8Array): void {
|
|
91
|
-
if (conn.readyState !== WebSocket.OPEN) return
|
|
92
|
-
conn.send(message, error => error && conn.close())
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
function roomName(url = '/'): string | undefined {
|
|
96
|
-
const name = url.slice(1).split('?')[0]!
|
|
97
|
-
return ROOM.test(name) ? name : undefined
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
function paramsOf(url = '/'): URLSearchParams {
|
|
101
|
-
return new URL(url, 'http://classroom').searchParams
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
function digest(key: string): Buffer {
|
|
105
|
-
return createHash('sha256').update(key).digest()
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
function sameKey(known: Buffer | undefined, key: string | null): boolean {
|
|
109
|
-
return Boolean(known && key && timingSafeEqual(known, digest(key)))
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
function readBody(request: IncomingMessage, limit = 1024): Promise<string> {
|
|
113
|
-
return new Promise((resolve, reject) => {
|
|
114
|
-
let text = ''
|
|
115
|
-
request.setEncoding('utf8')
|
|
116
|
-
request.on('data', (chunk: string) => {
|
|
117
|
-
text += chunk
|
|
118
|
-
if (text.length > limit) request.destroy(new Error('request too large'))
|
|
119
|
-
})
|
|
120
|
-
request.on('end', () => resolve(text))
|
|
121
|
-
request.on('error', reject)
|
|
122
|
-
})
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
export interface ClassroomWriter {
|
|
126
|
-
presenter: boolean
|
|
127
|
-
participant?: string
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
function writerKey(writer: ClassroomWriter): string {
|
|
131
|
-
return writer.presenter ? 'presenter' : `participant:${writer.participant ?? ''}`
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
function structClients(update: Uint8Array): number[] {
|
|
135
|
-
return [...Y.parseUpdateMeta(update).from.keys()]
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
const PARTICIPANT_FIRST = new Set(['store:questions'])
|
|
139
|
-
|
|
140
|
-
function ownKey(name: string, key: string, participant: string): boolean {
|
|
141
|
-
return PARTICIPANT_FIRST.has(name) ? key.slice(0, key.indexOf(':')) === participant : key.slice(key.lastIndexOf(':') + 1) === participant
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
function names(value: unknown, participant: string): boolean {
|
|
145
|
-
const entry = value as { participant?: unknown, author?: unknown } | null
|
|
146
|
-
return typeof entry === 'object' && entry !== null && !(entry instanceof Y.AbstractType) && (entry.participant === participant || entry.author === participant)
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
function entryRefusal(name: string, key: string | null, before: unknown, value: unknown, participant: string | undefined): string | undefined {
|
|
150
|
-
if (!participant) return 'a write from a connection that proved no participant'
|
|
151
|
-
if (key === null || !name.startsWith('store:') || name === 'store:quizzes') return `a write to ${name}, which is the presenter's`
|
|
152
|
-
if (!key.includes(':') || !ownKey(name, key, participant)) return `a write to ${name} under another participant's key`
|
|
153
|
-
if (before !== undefined && !names(before, participant)) return `a change to another participant's entry in ${name}`
|
|
154
|
-
if (value === undefined) return undefined
|
|
155
|
-
if (!names(value, participant)) return `a value in ${name} that does not name its participant`
|
|
156
|
-
return JSON.stringify(value).length > MAX_ENTRY ? `a value in ${name} over ${MAX_ENTRY} bytes` : undefined
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
function entryBefore(room: Y.Doc, name: string, key: string | null): unknown {
|
|
160
|
-
if (key === null || !room.share.has(name)) return undefined
|
|
161
|
-
try {
|
|
162
|
-
return room.getMap(name).get(key)
|
|
163
|
-
} catch {
|
|
164
|
-
return undefined
|
|
165
|
-
}
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
function pending(doc: Y.Doc): [structs: Uint8Array, deletions: Uint8Array] {
|
|
169
|
-
return [doc.store.pendingStructs?.update ?? new Uint8Array(), doc.store.pendingDs ?? new Uint8Array()]
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
function refusal(room: Room, update: Uint8Array, writer: ClassroomWriter): string | undefined {
|
|
173
|
-
const own = writerKey(writer)
|
|
174
|
-
if (structClients(update).some(client => (room.authors.get(client) ?? own) !== own)) return 'an update in the name of another writer\'s client'
|
|
175
|
-
const probe = new Y.Doc()
|
|
176
|
-
Y.applyUpdate(probe, Y.encodeStateAsUpdate(room))
|
|
177
|
-
const before = pending(probe)
|
|
178
|
-
const changes: [type: unknown, keys: Set<string | null>][] = []
|
|
179
|
-
probe.on('afterTransaction', (transaction: Y.Transaction) => changes.push(...transaction.changed))
|
|
180
|
-
Y.applyUpdate(probe, update)
|
|
181
|
-
const after = pending(probe)
|
|
182
|
-
let refused = before.every((bytes, i) => Buffer.compare(bytes, after[i]!) === 0) ? undefined : 'an update that does not integrate into the room'
|
|
183
|
-
for (const [type, keys] of changes) {
|
|
184
|
-
const name = [...probe.share].find(([, shared]) => shared === type)?.[0]
|
|
185
|
-
for (const key of keys) {
|
|
186
|
-
refused ??= name === undefined ? 'a change inside a nested type' : entryRefusal(name, key, entryBefore(room, name, key), key === null ? undefined : probe.getMap(name).get(key), writer.participant)
|
|
187
|
-
}
|
|
188
|
-
}
|
|
189
|
-
probe.destroy()
|
|
190
|
-
return refused
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
function changes(update: Uint8Array): boolean {
|
|
194
|
-
const { structs, ds } = Y.decodeUpdate(update)
|
|
195
|
-
return structs.length > 0 || ds.clients.size > 0
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
function ownAwareness(room: Room, awareness: awarenessProtocol.Awareness, conn: WebSocket, update: Uint8Array, writer: ClassroomWriter): { update: Uint8Array, dropped: number } {
|
|
199
|
-
const decoder = decoding.createDecoder(update)
|
|
200
|
-
const kept: [client: number, clock: number, state: string][] = []
|
|
201
|
-
let dropped = 0
|
|
202
|
-
const count = decoding.readVarUint(decoder)
|
|
203
|
-
for (let i = 0; i < count; i++) {
|
|
204
|
-
const entry: [number, number, string] = [decoding.readVarUint(decoder), decoding.readVarUint(decoder), decoding.readVarString(decoder)]
|
|
205
|
-
const [client, clock, state] = entry
|
|
206
|
-
const known = awareness.meta.get(client)?.clock ?? -1
|
|
207
|
-
if (clock < known || (clock === known && !(state === 'null' && awareness.getStates().has(client)))) continue
|
|
208
|
-
if (ownClient(room, conn, client, writer) && ownState(state, writer)) kept.push(entry)
|
|
209
|
-
else dropped++
|
|
210
|
-
}
|
|
211
|
-
const encoder = encoding.createEncoder()
|
|
212
|
-
encoding.writeVarUint(encoder, kept.length)
|
|
213
|
-
for (const [client, clock, state] of kept) {
|
|
214
|
-
encoding.writeVarUint(encoder, client)
|
|
215
|
-
encoding.writeVarUint(encoder, clock)
|
|
216
|
-
encoding.writeVarString(encoder, state)
|
|
217
|
-
}
|
|
218
|
-
return { update: encoding.toUint8Array(encoder), dropped }
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
function ownClient(room: Room, conn: WebSocket, client: number, writer: ClassroomWriter): boolean {
|
|
222
|
-
const own = writerKey(writer)
|
|
223
|
-
return (room.authors.get(client) ?? own) === own && ![...room.conns].some(([other, clients]) => other !== conn && clients.has(client))
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
function ownState(json: string, writer: ClassroomWriter): boolean {
|
|
227
|
-
if (writer.presenter) return true
|
|
228
|
-
try {
|
|
229
|
-
const participant = (JSON.parse(json) as { participant?: { id?: unknown, role?: unknown } | null } | null)?.participant
|
|
230
|
-
return participant == null || (writer.participant !== undefined && participant.id === writer.participant && participant.role === 'learner')
|
|
231
|
-
} catch {
|
|
232
|
-
return false
|
|
233
|
-
}
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
function handle(room: Room, conn: WebSocket, data: Uint8Array, writer: ClassroomWriter, drop: (reason: string) => void): void {
|
|
237
|
-
const decoder = decoding.createDecoder(data)
|
|
238
|
-
const type = decoding.readVarUint(decoder)
|
|
239
|
-
if (type === MESSAGE_SYNC) {
|
|
240
|
-
if (decoding.peekVarUint(decoder) !== syncProtocol.messageYjsSyncStep1) {
|
|
241
|
-
const message = decoding.clone(decoder)
|
|
242
|
-
decoding.readVarUint(message)
|
|
243
|
-
const update = decoding.readVarUint8Array(message)
|
|
244
|
-
if (room.ended) {
|
|
245
|
-
if (changes(update)) drop('an update after the classroom ended')
|
|
246
|
-
return
|
|
247
|
-
}
|
|
248
|
-
const refused = writer.presenter || room.personal ? undefined : refusal(room, update, writer)
|
|
249
|
-
if (refused) {
|
|
250
|
-
drop(refused)
|
|
251
|
-
conn.close(REFUSED_CLOSE_CODE, 'update refused')
|
|
252
|
-
return
|
|
253
|
-
}
|
|
254
|
-
const key = writerKey(writer)
|
|
255
|
-
for (const client of structClients(update)) if (!room.authors.has(client)) room.authors.set(client, key)
|
|
256
|
-
}
|
|
257
|
-
const encoder = encoding.createEncoder()
|
|
258
|
-
encoding.writeVarUint(encoder, MESSAGE_SYNC)
|
|
259
|
-
syncProtocol.readSyncMessage(decoder, encoder, room, conn)
|
|
260
|
-
if (encoding.length(encoder) > 1) send(conn, encoding.toUint8Array(encoder))
|
|
261
|
-
} else if (type === MESSAGE_AWARENESS && room.awareness) {
|
|
262
|
-
const own = ownAwareness(room, room.awareness, conn, decoding.readVarUint8Array(decoder), writer)
|
|
263
|
-
if (own.dropped > 0) drop(`${own.dropped} awareness state(s) of other clients or participants`)
|
|
264
|
-
awarenessProtocol.applyAwarenessUpdate(room.awareness, own.update, conn)
|
|
265
|
-
}
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
function reply(response: ServerResponse, status: number, body: Record<string, unknown>): void {
|
|
269
|
-
response.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8' })
|
|
270
|
-
response.end(`${JSON.stringify(body)}\n`)
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
export interface ClassroomServerOptions {
|
|
274
|
-
port?: number
|
|
275
|
-
host?: string
|
|
276
|
-
deck?: string
|
|
277
|
-
onDropped?(room: string, writer: ClassroomWriter, reason: string): void
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
export interface ClassroomServer {
|
|
281
|
-
readonly port: number
|
|
282
|
-
readonly url: string
|
|
283
|
-
readonly archiveDir: string
|
|
284
|
-
close(): Promise<void>
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
function warnDropped(room: string, writer: ClassroomWriter, reason: string): void {
|
|
288
|
-
const who = writer.presenter ? 'the presenter' : writer.participant ? `participant ${writer.participant}` : 'an unidentified participant'
|
|
289
|
-
console.warn(`[classroom] room ${room}: dropped ${reason} from ${who}`)
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
export async function startClassroomServer(options: ClassroomServerOptions = {}): Promise<ClassroomServer> {
|
|
293
|
-
const rooms = new Map<string, Room>()
|
|
294
|
-
const archivedNames = new Set<string>()
|
|
295
|
-
const archiveDir = resolve(options.deck ?? '.', ARCHIVE_DIR)
|
|
296
|
-
const isArchived = (name: string) => [...archivedNames].some(archived => name === archived || name.startsWith(`${archived}.`))
|
|
297
|
-
const onDropped = options.onDropped ?? warnDropped
|
|
298
|
-
|
|
299
|
-
async function archive(name: string, key: string): Promise<{ status: number, body: Record<string, unknown> }> {
|
|
300
|
-
if (archivedNames.has(name)) return { status: 410, body: { error: `课堂 ${name} 已归档` } }
|
|
301
|
-
const room = rooms.get(name)
|
|
302
|
-
if (!room) return { status: 404, body: { error: `没有课堂 ${name}` } }
|
|
303
|
-
if (!sameKey(room.claim, key)) return { status: 403, body: { error: '只有创建课堂的主讲可以归档' } }
|
|
304
|
-
const now = Date.now()
|
|
305
|
-
room.transact(() => {
|
|
306
|
-
if (!room.ended) {
|
|
307
|
-
room.session.set('status', 'ended')
|
|
308
|
-
room.session.set('endedAt', now)
|
|
309
|
-
}
|
|
310
|
-
})
|
|
311
|
-
const file = join(ARCHIVE_DIR, `${name}.json`)
|
|
312
|
-
await mkdir(archiveDir, { recursive: true })
|
|
313
|
-
await writeFile(join(archiveDir, `${name}.json`), `${JSON.stringify({ room: name, archivedAt: new Date(now).toISOString(), document: room.content() }, null, 2)}\n`)
|
|
314
|
-
room.transact(() => {
|
|
315
|
-
room.session.set('archivedAt', now)
|
|
316
|
-
room.session.set('archive', file)
|
|
317
|
-
})
|
|
318
|
-
archivedNames.add(name)
|
|
319
|
-
for (const [key, freed] of rooms) {
|
|
320
|
-
if (!isArchived(key)) continue
|
|
321
|
-
rooms.delete(key)
|
|
322
|
-
freed.archived = true
|
|
323
|
-
if (freed.conns.size === 0) freed.destroy()
|
|
324
|
-
for (const conn of freed.conns.keys()) conn.close(ARCHIVED_CLOSE_CODE, 'classroom archived')
|
|
325
|
-
}
|
|
326
|
-
console.log(`[classroom] room ${name} archived to ${join(archiveDir, `${name}.json`)}`)
|
|
327
|
-
return { status: 200, body: { file } }
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
const http = createServer((request, response) => {
|
|
331
|
-
response.setHeader('Access-Control-Allow-Origin', '*')
|
|
332
|
-
const target = /^\/archive\/([^/?]+)(?:\?|$)/.exec(request.url ?? '')
|
|
333
|
-
if (request.method === 'OPTIONS') {
|
|
334
|
-
response.writeHead(204, { 'Access-Control-Allow-Methods': 'POST' })
|
|
335
|
-
response.end()
|
|
336
|
-
} else if (request.method === 'POST' && target && ROOM.test(target[1]!)) {
|
|
337
|
-
readBody(request).then(key => archive(target[1]!, key.trim())).then(
|
|
338
|
-
({ status, body }) => reply(response, status, body),
|
|
339
|
-
(error: unknown) => {
|
|
340
|
-
console.error(`[classroom] room ${target[1]}: archive failed`, error)
|
|
341
|
-
reply(response, 500, { error: errorMessage(error) })
|
|
342
|
-
},
|
|
343
|
-
)
|
|
344
|
-
} else {
|
|
345
|
-
response.writeHead(200, { 'Content-Type': 'text/plain' })
|
|
346
|
-
response.end('presmium classroom server\n')
|
|
347
|
-
}
|
|
348
|
-
})
|
|
349
|
-
const wss = new WebSocketServer({ noServer: true, maxPayload: MAX_MESSAGE })
|
|
350
|
-
|
|
351
|
-
function connect(conn: WebSocket, request: IncomingMessage): void {
|
|
352
|
-
conn.on('error', error => console.error('[classroom] connection error:', error.message))
|
|
353
|
-
const name = roomName(request.url)
|
|
354
|
-
if (!name) {
|
|
355
|
-
conn.close(1008, 'invalid room name')
|
|
356
|
-
return
|
|
357
|
-
}
|
|
358
|
-
if (isArchived(name)) {
|
|
359
|
-
conn.close(ARCHIVED_CLOSE_CODE, 'classroom archived')
|
|
360
|
-
return
|
|
361
|
-
}
|
|
362
|
-
let room = rooms.get(name)
|
|
363
|
-
if (!room) rooms.set(name, room = new Room(name))
|
|
364
|
-
const current = room
|
|
365
|
-
const params = paramsOf(request.url)
|
|
366
|
-
const claim = params.get('claim')
|
|
367
|
-
if (claim && !current.claim) current.claim = digest(claim)
|
|
368
|
-
const writer: ClassroomWriter = { presenter: sameKey(current.claim, claim) }
|
|
369
|
-
const participant = params.get('participant')
|
|
370
|
-
const key = params.get('key')
|
|
371
|
-
if (participant && key && ROOM.test(participant)) {
|
|
372
|
-
if (!current.participants.has(participant)) current.participants.set(participant, digest(key))
|
|
373
|
-
if (sameKey(current.participants.get(participant), key)) writer.participant = participant
|
|
374
|
-
}
|
|
375
|
-
current.conns.set(conn, new Set())
|
|
376
|
-
current.writers.set(conn, writerKey(writer))
|
|
377
|
-
conn.binaryType = 'arraybuffer'
|
|
378
|
-
conn.on('message', (data: ArrayBuffer) => {
|
|
379
|
-
try {
|
|
380
|
-
handle(current, conn, new Uint8Array(data), writer, reason => onDropped(name, writer, reason))
|
|
381
|
-
} catch (error) {
|
|
382
|
-
console.error(`[classroom] room ${name}: bad message`, error)
|
|
383
|
-
}
|
|
384
|
-
})
|
|
385
|
-
let alive = true
|
|
386
|
-
conn.on('pong', () => alive = true)
|
|
387
|
-
const ping = setInterval(() => {
|
|
388
|
-
if (!alive || conn.readyState !== WebSocket.OPEN) {
|
|
389
|
-
conn.terminate()
|
|
390
|
-
return
|
|
391
|
-
}
|
|
392
|
-
alive = false
|
|
393
|
-
conn.ping()
|
|
394
|
-
}, PING_INTERVAL)
|
|
395
|
-
conn.on('close', () => {
|
|
396
|
-
clearInterval(ping)
|
|
397
|
-
const controlled = current.conns.get(conn)
|
|
398
|
-
current.conns.delete(conn)
|
|
399
|
-
current.writers.delete(conn)
|
|
400
|
-
if (current.archived) {
|
|
401
|
-
if (current.conns.size === 0) current.destroy()
|
|
402
|
-
return
|
|
403
|
-
}
|
|
404
|
-
if (controlled?.size && current.awareness) awarenessProtocol.removeAwarenessStates(current.awareness, [...controlled], null)
|
|
405
|
-
if (current.conns.size === 0 && current.store.clients.size === 0 && rooms.get(name) === current) {
|
|
406
|
-
rooms.delete(name)
|
|
407
|
-
current.destroy()
|
|
408
|
-
}
|
|
409
|
-
})
|
|
410
|
-
const encoder = encoding.createEncoder()
|
|
411
|
-
encoding.writeVarUint(encoder, MESSAGE_SYNC)
|
|
412
|
-
syncProtocol.writeSyncStep1(encoder, current)
|
|
413
|
-
send(conn, encoding.toUint8Array(encoder))
|
|
414
|
-
const states = current.awareness ? [...current.awareness.getStates().keys()] : []
|
|
415
|
-
if (current.awareness && states.length > 0) send(conn, awarenessMessage(current.awareness, states))
|
|
416
|
-
}
|
|
417
|
-
|
|
418
|
-
wss.on('connection', (conn: WebSocket, request: IncomingMessage) => {
|
|
419
|
-
try {
|
|
420
|
-
connect(conn, request)
|
|
421
|
-
} catch (error) {
|
|
422
|
-
console.error('[classroom] connection failed', error)
|
|
423
|
-
conn.terminate()
|
|
424
|
-
}
|
|
425
|
-
})
|
|
426
|
-
http.on('upgrade', (request, socket, head) => {
|
|
427
|
-
wss.handleUpgrade(request, socket, head, conn => wss.emit('connection', conn, request))
|
|
428
|
-
})
|
|
429
|
-
await new Promise<void>((resolve, reject) => {
|
|
430
|
-
http.once('error', reject)
|
|
431
|
-
http.listen(options.port ?? 1234, options.host ?? '0.0.0.0', () => resolve())
|
|
432
|
-
})
|
|
433
|
-
const { port } = http.address() as AddressInfo
|
|
434
|
-
const shown = !options.host || options.host === '0.0.0.0' || options.host === '::' ? 'localhost' : options.host
|
|
435
|
-
return {
|
|
436
|
-
port,
|
|
437
|
-
url: `ws://${shown.includes(':') ? `[${shown}]` : shown}:${port}`,
|
|
438
|
-
archiveDir,
|
|
439
|
-
async close() {
|
|
440
|
-
for (const client of wss.clients) client.terminate()
|
|
441
|
-
await new Promise<void>(resolve => wss.close(() => resolve()))
|
|
442
|
-
await new Promise<void>(resolve => http.close(() => resolve()))
|
|
443
|
-
for (const room of rooms.values()) room.destroy()
|
|
444
|
-
rooms.clear()
|
|
445
|
-
},
|
|
446
|
-
}
|
|
447
|
-
}
|
|
1
|
+
export type { ClassroomWriter } from './permissions.ts'
|
|
2
|
+
export { ARCHIVE_DIR, ARCHIVED_CLOSE_CODE, REFUSED_CLOSE_CODE, startClassroomServer } from './server.ts'
|
|
3
|
+
export type { ClassroomServer, ClassroomServerOptions } from './server.ts'
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import * as decoding from 'lib0/decoding'
|
|
2
|
+
import * as encoding from 'lib0/encoding'
|
|
3
|
+
import type { WebSocket } from 'ws'
|
|
4
|
+
import type * as awarenessProtocol from 'y-protocols/awareness'
|
|
5
|
+
import * as Y from 'yjs'
|
|
6
|
+
import type { Room } from './room.ts'
|
|
7
|
+
|
|
8
|
+
const MAX_ENTRY = 16 << 10
|
|
9
|
+
|
|
10
|
+
export interface ClassroomWriter {
|
|
11
|
+
presenter: boolean
|
|
12
|
+
participant?: string
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function writerKey(writer: ClassroomWriter): string {
|
|
16
|
+
return writer.presenter ? 'presenter' : `participant:${writer.participant ?? ''}`
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function structClients(update: Uint8Array): number[] {
|
|
20
|
+
return [...Y.parseUpdateMeta(update).from.keys()]
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const PARTICIPANT_FIRST = new Set(['store:questions'])
|
|
24
|
+
|
|
25
|
+
function isOwnKey(name: string, key: string, participant: string): boolean {
|
|
26
|
+
return PARTICIPANT_FIRST.has(name) ? key.slice(0, key.indexOf(':')) === participant : key.slice(key.lastIndexOf(':') + 1) === participant
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Questions of earlier versions name their participant `author`.
|
|
30
|
+
function namesParticipant(value: unknown, participant: string): boolean {
|
|
31
|
+
const entry = value as { participant?: unknown, author?: unknown } | null
|
|
32
|
+
return typeof entry === 'object' && entry !== null && !(entry instanceof Y.AbstractType) && (entry.participant === participant || entry.author === participant)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function entryRefusal(name: string, key: string | null, before: unknown, value: unknown, participant: string | undefined): string | undefined {
|
|
36
|
+
if (!participant) return 'a write from a connection that proved no participant'
|
|
37
|
+
if (key === null || !name.startsWith('store:') || name === 'store:quizzes') return `a write to ${name}, which is the presenter's`
|
|
38
|
+
if (!key.includes(':') || !isOwnKey(name, key, participant)) return `a write to ${name} under another participant's key`
|
|
39
|
+
if (before !== undefined && !namesParticipant(before, participant)) return `a change to another participant's entry in ${name}`
|
|
40
|
+
if (value === undefined) return undefined
|
|
41
|
+
if (!namesParticipant(value, participant)) return `a value in ${name} that does not name its participant`
|
|
42
|
+
return JSON.stringify(value).length > MAX_ENTRY ? `a value in ${name} over ${MAX_ENTRY} bytes` : undefined
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function entryBefore(room: Y.Doc, name: string, key: string | null): unknown {
|
|
46
|
+
if (key === null || !room.share.has(name)) return undefined
|
|
47
|
+
try {
|
|
48
|
+
return room.getMap(name).get(key)
|
|
49
|
+
} catch {
|
|
50
|
+
return undefined
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function pendingUpdates(doc: Y.Doc): [structs: Uint8Array, deletions: Uint8Array] {
|
|
55
|
+
return [doc.store.pendingStructs?.update ?? new Uint8Array(), doc.store.pendingDs ?? new Uint8Array()]
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function updateRefusal(room: Room, update: Uint8Array, writer: ClassroomWriter): string | undefined {
|
|
59
|
+
const own = writerKey(writer)
|
|
60
|
+
if (structClients(update).some(client => (room.clientWriters.get(client) ?? own) !== own)) return 'an update in the name of another writer\'s client'
|
|
61
|
+
const probe = new Y.Doc()
|
|
62
|
+
Y.applyUpdate(probe, Y.encodeStateAsUpdate(room))
|
|
63
|
+
const before = pendingUpdates(probe)
|
|
64
|
+
const changes: [type: unknown, keys: Set<string | null>][] = []
|
|
65
|
+
probe.on('afterTransaction', (transaction: Y.Transaction) => changes.push(...transaction.changed))
|
|
66
|
+
Y.applyUpdate(probe, update)
|
|
67
|
+
const after = pendingUpdates(probe)
|
|
68
|
+
let refused = before.every((bytes, i) => Buffer.compare(bytes, after[i]!) === 0) ? undefined : 'an update that does not integrate into the room'
|
|
69
|
+
for (const [type, keys] of changes) {
|
|
70
|
+
const name = [...probe.share].find(([, shared]) => shared === type)?.[0]
|
|
71
|
+
for (const key of keys) {
|
|
72
|
+
refused ??= name === undefined ? 'a change inside a nested type' : entryRefusal(name, key, entryBefore(room, name, key), key === null ? undefined : probe.getMap(name).get(key), writer.participant)
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
probe.destroy()
|
|
76
|
+
return refused
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function hasChanges(update: Uint8Array): boolean {
|
|
80
|
+
const { structs, ds } = Y.decodeUpdate(update)
|
|
81
|
+
return structs.length > 0 || ds.clients.size > 0
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function ownAwarenessUpdate(room: Room, awareness: awarenessProtocol.Awareness, conn: WebSocket, update: Uint8Array, writer: ClassroomWriter): { update: Uint8Array, dropped: number } {
|
|
85
|
+
const decoder = decoding.createDecoder(update)
|
|
86
|
+
const kept: [client: number, clock: number, state: string][] = []
|
|
87
|
+
let dropped = 0
|
|
88
|
+
const count = decoding.readVarUint(decoder)
|
|
89
|
+
for (let i = 0; i < count; i++) {
|
|
90
|
+
const entry: [number, number, string] = [decoding.readVarUint(decoder), decoding.readVarUint(decoder), decoding.readVarString(decoder)]
|
|
91
|
+
const [client, clock, state] = entry
|
|
92
|
+
const known = awareness.meta.get(client)?.clock ?? -1
|
|
93
|
+
if (clock < known || (clock === known && !(state === 'null' && awareness.getStates().has(client)))) continue
|
|
94
|
+
if (isOwnClient(room, conn, client, writer) && isOwnState(state, writer)) kept.push(entry)
|
|
95
|
+
else dropped++
|
|
96
|
+
}
|
|
97
|
+
const encoder = encoding.createEncoder()
|
|
98
|
+
encoding.writeVarUint(encoder, kept.length)
|
|
99
|
+
for (const [client, clock, state] of kept) {
|
|
100
|
+
encoding.writeVarUint(encoder, client)
|
|
101
|
+
encoding.writeVarUint(encoder, clock)
|
|
102
|
+
encoding.writeVarString(encoder, state)
|
|
103
|
+
}
|
|
104
|
+
return { update: encoding.toUint8Array(encoder), dropped }
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function isOwnClient(room: Room, conn: WebSocket, client: number, writer: ClassroomWriter): boolean {
|
|
108
|
+
const own = writerKey(writer)
|
|
109
|
+
return (room.clientWriters.get(client) ?? own) === own && ![...room.conns].some(([other, clients]) => other !== conn && clients.has(client))
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function isOwnState(json: string, writer: ClassroomWriter): boolean {
|
|
113
|
+
if (writer.presenter) return true
|
|
114
|
+
try {
|
|
115
|
+
const participant = (JSON.parse(json) as { participant?: { id?: unknown, role?: unknown } | null } | null)?.participant
|
|
116
|
+
return participant == null || (writer.participant !== undefined && participant.id === writer.participant && participant.role === 'learner')
|
|
117
|
+
} catch {
|
|
118
|
+
return false
|
|
119
|
+
}
|
|
120
|
+
}
|
package/src/room.ts
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import * as encoding from 'lib0/encoding'
|
|
2
|
+
import { WebSocket } from 'ws'
|
|
3
|
+
import * as awarenessProtocol from 'y-protocols/awareness'
|
|
4
|
+
import * as syncProtocol from 'y-protocols/sync'
|
|
5
|
+
import * as Y from 'yjs'
|
|
6
|
+
|
|
7
|
+
export const MESSAGE_SYNC = 0
|
|
8
|
+
export const MESSAGE_AWARENESS = 1
|
|
9
|
+
export const ROOM = /^[\w.-]{1,128}$/
|
|
10
|
+
|
|
11
|
+
export class Room extends Y.Doc {
|
|
12
|
+
readonly conns = new Map<WebSocket, Set<number>>()
|
|
13
|
+
readonly writers = new Map<WebSocket, string>()
|
|
14
|
+
readonly clientWriters = new Map<number, string>()
|
|
15
|
+
readonly awareness?: awarenessProtocol.Awareness
|
|
16
|
+
archived = false
|
|
17
|
+
claim?: Buffer
|
|
18
|
+
readonly participants = new Map<string, Buffer>()
|
|
19
|
+
readonly personal: boolean
|
|
20
|
+
|
|
21
|
+
constructor(name: string) {
|
|
22
|
+
super({ gc: true })
|
|
23
|
+
this.clientWriters.set(this.clientID, '')
|
|
24
|
+
this.personal = name.includes('.')
|
|
25
|
+
this.on('update', (update: Uint8Array) => {
|
|
26
|
+
const encoder = encoding.createEncoder()
|
|
27
|
+
encoding.writeVarUint(encoder, MESSAGE_SYNC)
|
|
28
|
+
syncProtocol.writeUpdate(encoder, update)
|
|
29
|
+
this.broadcast(encoding.toUint8Array(encoder))
|
|
30
|
+
})
|
|
31
|
+
if (this.personal) return
|
|
32
|
+
const awareness = this.awareness = new awarenessProtocol.Awareness(this)
|
|
33
|
+
awareness.setLocalState(null)
|
|
34
|
+
awareness.on('update', ({ added, updated, removed }: Record<'added' | 'updated' | 'removed', number[]>, conn: WebSocket | null) => {
|
|
35
|
+
const changed = [...added, ...updated, ...removed]
|
|
36
|
+
const controlled = conn && this.conns.get(conn)
|
|
37
|
+
if (controlled) {
|
|
38
|
+
added.forEach(id => controlled.add(id))
|
|
39
|
+
removed.forEach(id => controlled.delete(id))
|
|
40
|
+
const writer = this.writers.get(conn)!
|
|
41
|
+
for (const id of added) if (!this.clientWriters.has(id)) this.clientWriters.set(id, writer)
|
|
42
|
+
}
|
|
43
|
+
this.broadcast(awarenessMessage(awareness, changed))
|
|
44
|
+
})
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
get session(): Y.Map<unknown> {
|
|
48
|
+
return this.getMap('session')
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
get ended(): boolean {
|
|
52
|
+
return this.session.get('status') === 'ended'
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
broadcast(message: Uint8Array): void {
|
|
56
|
+
for (const conn of this.conns.keys()) send(conn, message)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
content(): Record<string, unknown> {
|
|
60
|
+
return Object.fromEntries([...this.share.keys()].map(key => [key, this.getMap(key).toJSON()]))
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
override destroy(): void {
|
|
64
|
+
this.awareness?.destroy()
|
|
65
|
+
super.destroy()
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function awarenessMessage(awareness: awarenessProtocol.Awareness, clients: number[]): Uint8Array {
|
|
70
|
+
const encoder = encoding.createEncoder()
|
|
71
|
+
encoding.writeVarUint(encoder, MESSAGE_AWARENESS)
|
|
72
|
+
encoding.writeVarUint8Array(encoder, awarenessProtocol.encodeAwarenessUpdate(awareness, clients))
|
|
73
|
+
return encoding.toUint8Array(encoder)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function send(conn: WebSocket, message: Uint8Array): void {
|
|
77
|
+
if (conn.readyState !== WebSocket.OPEN) return
|
|
78
|
+
conn.send(message, error => error && conn.close())
|
|
79
|
+
}
|
package/src/server.ts
ADDED
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
import { createHash, timingSafeEqual } from 'node:crypto'
|
|
2
|
+
import { mkdir, writeFile } from 'node:fs/promises'
|
|
3
|
+
import { createServer } from 'node:http'
|
|
4
|
+
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
5
|
+
import type { AddressInfo } from 'node:net'
|
|
6
|
+
import { join, resolve } from 'node:path'
|
|
7
|
+
import { errorMessage } from '@lincyaw/kernel'
|
|
8
|
+
import { bracketIPv6 } from '@lincyaw/kernel/node'
|
|
9
|
+
import * as decoding from 'lib0/decoding'
|
|
10
|
+
import * as encoding from 'lib0/encoding'
|
|
11
|
+
import { WebSocket, WebSocketServer } from 'ws'
|
|
12
|
+
import * as awarenessProtocol from 'y-protocols/awareness'
|
|
13
|
+
import * as syncProtocol from 'y-protocols/sync'
|
|
14
|
+
import { hasChanges, ownAwarenessUpdate, structClients, updateRefusal, writerKey } from './permissions.ts'
|
|
15
|
+
import type { ClassroomWriter } from './permissions.ts'
|
|
16
|
+
import { awarenessMessage, MESSAGE_AWARENESS, MESSAGE_SYNC, Room, ROOM, send } from './room.ts'
|
|
17
|
+
|
|
18
|
+
const PING_INTERVAL = 30_000
|
|
19
|
+
export const ARCHIVED_CLOSE_CODE = 4410
|
|
20
|
+
export const REFUSED_CLOSE_CODE = 4403
|
|
21
|
+
export const ARCHIVE_DIR = '.presmium/classroom'
|
|
22
|
+
const MAX_MESSAGE = 1 << 20
|
|
23
|
+
|
|
24
|
+
function roomName(url = '/'): string | undefined {
|
|
25
|
+
const name = url.slice(1).split('?')[0]!
|
|
26
|
+
return ROOM.test(name) ? name : undefined
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function paramsOf(url = '/'): URLSearchParams {
|
|
30
|
+
return new URL(url, 'http://classroom').searchParams
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function digest(key: string): Buffer {
|
|
34
|
+
return createHash('sha256').update(key).digest()
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function sameKey(known: Buffer | undefined, key: string | null): boolean {
|
|
38
|
+
return Boolean(known && key && timingSafeEqual(known, digest(key)))
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function readBody(request: IncomingMessage, limit = 1024): Promise<string> {
|
|
42
|
+
return new Promise((resolve, reject) => {
|
|
43
|
+
let text = ''
|
|
44
|
+
request.setEncoding('utf8')
|
|
45
|
+
request.on('data', (chunk: string) => {
|
|
46
|
+
text += chunk
|
|
47
|
+
if (text.length > limit) request.destroy(new Error('request too large'))
|
|
48
|
+
})
|
|
49
|
+
request.on('end', () => resolve(text))
|
|
50
|
+
request.on('error', reject)
|
|
51
|
+
})
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function handleMessage(room: Room, conn: WebSocket, data: Uint8Array, writer: ClassroomWriter, drop: (reason: string) => void): void {
|
|
55
|
+
const decoder = decoding.createDecoder(data)
|
|
56
|
+
const type = decoding.readVarUint(decoder)
|
|
57
|
+
if (type === MESSAGE_SYNC) {
|
|
58
|
+
if (decoding.peekVarUint(decoder) !== syncProtocol.messageYjsSyncStep1) {
|
|
59
|
+
const message = decoding.clone(decoder)
|
|
60
|
+
decoding.readVarUint(message)
|
|
61
|
+
const update = decoding.readVarUint8Array(message)
|
|
62
|
+
if (room.ended) {
|
|
63
|
+
if (hasChanges(update)) drop('an update after the classroom ended')
|
|
64
|
+
return
|
|
65
|
+
}
|
|
66
|
+
const refused = writer.presenter || room.personal ? undefined : updateRefusal(room, update, writer)
|
|
67
|
+
if (refused) {
|
|
68
|
+
drop(refused)
|
|
69
|
+
conn.close(REFUSED_CLOSE_CODE, 'update refused')
|
|
70
|
+
return
|
|
71
|
+
}
|
|
72
|
+
const key = writerKey(writer)
|
|
73
|
+
for (const client of structClients(update)) if (!room.clientWriters.has(client)) room.clientWriters.set(client, key)
|
|
74
|
+
}
|
|
75
|
+
const encoder = encoding.createEncoder()
|
|
76
|
+
encoding.writeVarUint(encoder, MESSAGE_SYNC)
|
|
77
|
+
syncProtocol.readSyncMessage(decoder, encoder, room, conn)
|
|
78
|
+
if (encoding.length(encoder) > 1) send(conn, encoding.toUint8Array(encoder))
|
|
79
|
+
} else if (type === MESSAGE_AWARENESS && room.awareness) {
|
|
80
|
+
const own = ownAwarenessUpdate(room, room.awareness, conn, decoding.readVarUint8Array(decoder), writer)
|
|
81
|
+
if (own.dropped > 0) drop(`${own.dropped} awareness state(s) of other clients or participants`)
|
|
82
|
+
awarenessProtocol.applyAwarenessUpdate(room.awareness, own.update, conn)
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function reply(response: ServerResponse, status: number, body: Record<string, unknown>): void {
|
|
87
|
+
response.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8' })
|
|
88
|
+
response.end(`${JSON.stringify(body)}\n`)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export interface ClassroomServerOptions {
|
|
92
|
+
port?: number
|
|
93
|
+
listenAddress?: string
|
|
94
|
+
deck?: string
|
|
95
|
+
onDropped?(room: string, writer: ClassroomWriter, reason: string): void
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export interface ClassroomServer {
|
|
99
|
+
readonly port: number
|
|
100
|
+
readonly url: string
|
|
101
|
+
readonly archiveDir: string
|
|
102
|
+
close(): Promise<void>
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function warnDropped(room: string, writer: ClassroomWriter, reason: string): void {
|
|
106
|
+
const who = writer.presenter ? 'the presenter' : writer.participant ? `participant ${writer.participant}` : 'an unidentified participant'
|
|
107
|
+
console.warn(`[classroom] room ${room}: dropped ${reason} from ${who}`)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export async function startClassroomServer(options: ClassroomServerOptions = {}): Promise<ClassroomServer> {
|
|
111
|
+
const rooms = new Map<string, Room>()
|
|
112
|
+
const archivedNames = new Set<string>()
|
|
113
|
+
const archiveDir = resolve(options.deck ?? '.', ARCHIVE_DIR)
|
|
114
|
+
const isArchived = (name: string) => [...archivedNames].some(archived => name === archived || name.startsWith(`${archived}.`))
|
|
115
|
+
const onDropped = options.onDropped ?? warnDropped
|
|
116
|
+
|
|
117
|
+
async function archive(name: string, key: string): Promise<{ status: number, body: Record<string, unknown> }> {
|
|
118
|
+
if (archivedNames.has(name)) return { status: 410, body: { error: `classroom ${name} is archived` } }
|
|
119
|
+
const room = rooms.get(name)
|
|
120
|
+
if (!room) return { status: 404, body: { error: `no classroom ${name}` } }
|
|
121
|
+
if (!sameKey(room.claim, key)) return { status: 403, body: { error: 'only the presenter who created the classroom can archive it' } }
|
|
122
|
+
const now = Date.now()
|
|
123
|
+
room.transact(() => {
|
|
124
|
+
if (!room.ended) {
|
|
125
|
+
room.session.set('status', 'ended')
|
|
126
|
+
room.session.set('endedAt', now)
|
|
127
|
+
}
|
|
128
|
+
})
|
|
129
|
+
const file = join(ARCHIVE_DIR, `${name}.json`)
|
|
130
|
+
await mkdir(archiveDir, { recursive: true })
|
|
131
|
+
await writeFile(join(archiveDir, `${name}.json`), `${JSON.stringify({ room: name, archivedAt: new Date(now).toISOString(), document: room.content() }, null, 2)}\n`)
|
|
132
|
+
room.transact(() => {
|
|
133
|
+
room.session.set('archivedAt', now)
|
|
134
|
+
room.session.set('archive', file)
|
|
135
|
+
})
|
|
136
|
+
archivedNames.add(name)
|
|
137
|
+
for (const [key, freed] of rooms) {
|
|
138
|
+
if (!isArchived(key)) continue
|
|
139
|
+
rooms.delete(key)
|
|
140
|
+
freed.archived = true
|
|
141
|
+
if (freed.conns.size === 0) freed.destroy()
|
|
142
|
+
for (const conn of freed.conns.keys()) conn.close(ARCHIVED_CLOSE_CODE, 'classroom archived')
|
|
143
|
+
}
|
|
144
|
+
console.log(`[classroom] room ${name} archived to ${join(archiveDir, `${name}.json`)}`)
|
|
145
|
+
return { status: 200, body: { file } }
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const http = createServer((request, response) => {
|
|
149
|
+
response.setHeader('Access-Control-Allow-Origin', '*')
|
|
150
|
+
const target = /^\/archive\/([^/?]+)(?:\?|$)/.exec(request.url ?? '')
|
|
151
|
+
if (request.method === 'OPTIONS') {
|
|
152
|
+
response.writeHead(204, { 'Access-Control-Allow-Methods': 'POST' })
|
|
153
|
+
response.end()
|
|
154
|
+
} else if (request.method === 'POST' && target && ROOM.test(target[1]!)) {
|
|
155
|
+
readBody(request).then(key => archive(target[1]!, key.trim())).then(
|
|
156
|
+
({ status, body }) => reply(response, status, body),
|
|
157
|
+
(error: unknown) => {
|
|
158
|
+
console.error(`[classroom] room ${target[1]}: archive failed`, error)
|
|
159
|
+
reply(response, 500, { error: errorMessage(error) })
|
|
160
|
+
},
|
|
161
|
+
)
|
|
162
|
+
} else {
|
|
163
|
+
response.writeHead(200, { 'Content-Type': 'text/plain' })
|
|
164
|
+
response.end('presmium classroom server\n')
|
|
165
|
+
}
|
|
166
|
+
})
|
|
167
|
+
const wss = new WebSocketServer({ noServer: true, maxPayload: MAX_MESSAGE })
|
|
168
|
+
|
|
169
|
+
function connect(conn: WebSocket, request: IncomingMessage): void {
|
|
170
|
+
conn.on('error', error => console.error('[classroom] connection error:', error.message))
|
|
171
|
+
const name = roomName(request.url)
|
|
172
|
+
if (!name) {
|
|
173
|
+
conn.close(1008, 'invalid room name')
|
|
174
|
+
return
|
|
175
|
+
}
|
|
176
|
+
if (isArchived(name)) {
|
|
177
|
+
conn.close(ARCHIVED_CLOSE_CODE, 'classroom archived')
|
|
178
|
+
return
|
|
179
|
+
}
|
|
180
|
+
let room = rooms.get(name)
|
|
181
|
+
if (!room) rooms.set(name, room = new Room(name))
|
|
182
|
+
const current = room
|
|
183
|
+
const params = paramsOf(request.url)
|
|
184
|
+
const claim = params.get('claim')
|
|
185
|
+
if (claim && !current.claim) current.claim = digest(claim)
|
|
186
|
+
const writer: ClassroomWriter = { presenter: sameKey(current.claim, claim) }
|
|
187
|
+
const participant = params.get('participant')
|
|
188
|
+
const key = params.get('key')
|
|
189
|
+
if (participant && key && ROOM.test(participant)) {
|
|
190
|
+
if (!current.participants.has(participant)) current.participants.set(participant, digest(key))
|
|
191
|
+
if (sameKey(current.participants.get(participant), key)) writer.participant = participant
|
|
192
|
+
}
|
|
193
|
+
current.conns.set(conn, new Set())
|
|
194
|
+
current.writers.set(conn, writerKey(writer))
|
|
195
|
+
conn.binaryType = 'arraybuffer'
|
|
196
|
+
conn.on('message', (data: ArrayBuffer) => {
|
|
197
|
+
try {
|
|
198
|
+
handleMessage(current, conn, new Uint8Array(data), writer, reason => onDropped(name, writer, reason))
|
|
199
|
+
} catch (error) {
|
|
200
|
+
console.error(`[classroom] room ${name}: bad message`, error)
|
|
201
|
+
}
|
|
202
|
+
})
|
|
203
|
+
let alive = true
|
|
204
|
+
conn.on('pong', () => alive = true)
|
|
205
|
+
const ping = setInterval(() => {
|
|
206
|
+
if (!alive || conn.readyState !== WebSocket.OPEN) {
|
|
207
|
+
conn.terminate()
|
|
208
|
+
return
|
|
209
|
+
}
|
|
210
|
+
alive = false
|
|
211
|
+
conn.ping()
|
|
212
|
+
}, PING_INTERVAL)
|
|
213
|
+
conn.on('close', () => {
|
|
214
|
+
clearInterval(ping)
|
|
215
|
+
const controlled = current.conns.get(conn)
|
|
216
|
+
current.conns.delete(conn)
|
|
217
|
+
current.writers.delete(conn)
|
|
218
|
+
if (current.archived) {
|
|
219
|
+
if (current.conns.size === 0) current.destroy()
|
|
220
|
+
return
|
|
221
|
+
}
|
|
222
|
+
if (controlled?.size && current.awareness) awarenessProtocol.removeAwarenessStates(current.awareness, [...controlled], null)
|
|
223
|
+
if (current.conns.size === 0 && current.store.clients.size === 0 && rooms.get(name) === current) {
|
|
224
|
+
rooms.delete(name)
|
|
225
|
+
current.destroy()
|
|
226
|
+
}
|
|
227
|
+
})
|
|
228
|
+
const encoder = encoding.createEncoder()
|
|
229
|
+
encoding.writeVarUint(encoder, MESSAGE_SYNC)
|
|
230
|
+
syncProtocol.writeSyncStep1(encoder, current)
|
|
231
|
+
send(conn, encoding.toUint8Array(encoder))
|
|
232
|
+
const states = current.awareness ? [...current.awareness.getStates().keys()] : []
|
|
233
|
+
if (current.awareness && states.length > 0) send(conn, awarenessMessage(current.awareness, states))
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
wss.on('connection', (conn: WebSocket, request: IncomingMessage) => {
|
|
237
|
+
try {
|
|
238
|
+
connect(conn, request)
|
|
239
|
+
} catch (error) {
|
|
240
|
+
console.error('[classroom] connection failed', error)
|
|
241
|
+
conn.terminate()
|
|
242
|
+
}
|
|
243
|
+
})
|
|
244
|
+
http.on('upgrade', (request, socket, head) => {
|
|
245
|
+
wss.handleUpgrade(request, socket, head, conn => wss.emit('connection', conn, request))
|
|
246
|
+
})
|
|
247
|
+
await new Promise<void>((resolve, reject) => {
|
|
248
|
+
http.once('error', reject)
|
|
249
|
+
http.listen(options.port ?? 1234, options.listenAddress ?? '0.0.0.0', () => resolve())
|
|
250
|
+
})
|
|
251
|
+
const { port } = http.address() as AddressInfo
|
|
252
|
+
const { listenAddress } = options
|
|
253
|
+
const shown = !listenAddress || listenAddress === '0.0.0.0' || listenAddress === '::' ? 'localhost' : listenAddress
|
|
254
|
+
return {
|
|
255
|
+
port,
|
|
256
|
+
url: `ws://${bracketIPv6(shown)}:${port}`,
|
|
257
|
+
archiveDir,
|
|
258
|
+
async close() {
|
|
259
|
+
for (const client of wss.clients) client.terminate()
|
|
260
|
+
await new Promise<void>(resolve => wss.close(() => resolve()))
|
|
261
|
+
await new Promise<void>(resolve => http.close(() => resolve()))
|
|
262
|
+
for (const room of rooms.values()) room.destroy()
|
|
263
|
+
rooms.clear()
|
|
264
|
+
},
|
|
265
|
+
}
|
|
266
|
+
}
|
|
File without changes
|