@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 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 turns lectures written in MyST Markdown into web pages, slides, Jupyter notebooks and narrated lectures.
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 in their headmatter: `presmium: { presmium.session: { server: ws://<host>:1234 } }`. `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.
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.en.md)
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.0",
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.0"
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 { startClassroomServer } from './index.ts'
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 host: string | undefined
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
- host = values.host
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, host, deck })
46
- const network = !host || host === '0.0.0.0' || host === '::' ? lanAddresses().map(address => `network ws://${address}:${server.port}\n`) : []
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
- 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 * 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
+ }