@vpalmisano/webrtcperf 4.0.0

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.
Files changed (53) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +296 -0
  3. package/app.min.js +2 -0
  4. package/build/src/app.d.ts +6 -0
  5. package/build/src/app.js +207 -0
  6. package/build/src/app.js.map +1 -0
  7. package/build/src/config.d.ts +104 -0
  8. package/build/src/config.js +880 -0
  9. package/build/src/config.js.map +1 -0
  10. package/build/src/generate-config-docs.d.ts +1 -0
  11. package/build/src/generate-config-docs.js +41 -0
  12. package/build/src/generate-config-docs.js.map +1 -0
  13. package/build/src/index.d.ts +9 -0
  14. package/build/src/index.js +26 -0
  15. package/build/src/index.js.map +1 -0
  16. package/build/src/media.d.ts +33 -0
  17. package/build/src/media.js +113 -0
  18. package/build/src/media.js.map +1 -0
  19. package/build/src/rtcstats.d.ts +302 -0
  20. package/build/src/rtcstats.js +418 -0
  21. package/build/src/rtcstats.js.map +1 -0
  22. package/build/src/server.d.ts +173 -0
  23. package/build/src/server.js +639 -0
  24. package/build/src/server.js.map +1 -0
  25. package/build/src/session.d.ts +277 -0
  26. package/build/src/session.js +1552 -0
  27. package/build/src/session.js.map +1 -0
  28. package/build/src/stats.d.ts +243 -0
  29. package/build/src/stats.js +1383 -0
  30. package/build/src/stats.js.map +1 -0
  31. package/build/src/utils.d.ts +249 -0
  32. package/build/src/utils.js +1220 -0
  33. package/build/src/utils.js.map +1 -0
  34. package/build/src/visqol.d.ts +6 -0
  35. package/build/src/visqol.js +61 -0
  36. package/build/src/visqol.js.map +1 -0
  37. package/build/src/vmaf.d.ts +83 -0
  38. package/build/src/vmaf.js +624 -0
  39. package/build/src/vmaf.js.map +1 -0
  40. package/build/tsconfig.tsbuildinfo +1 -0
  41. package/package.json +129 -0
  42. package/src/app.ts +241 -0
  43. package/src/config.ts +852 -0
  44. package/src/generate-config-docs.ts +47 -0
  45. package/src/index.ts +9 -0
  46. package/src/media.ts +151 -0
  47. package/src/rtcstats.ts +507 -0
  48. package/src/server.ts +645 -0
  49. package/src/session.ts +1908 -0
  50. package/src/stats.ts +1668 -0
  51. package/src/utils.ts +1295 -0
  52. package/src/visqol.ts +62 -0
  53. package/src/vmaf.ts +771 -0
package/src/server.ts ADDED
@@ -0,0 +1,645 @@
1
+ import compression from 'compression'
2
+ import { timingSafeEqual } from 'crypto'
3
+ import express, { json } from 'express'
4
+ import fs from 'fs'
5
+ import { Server as HttpServer, createServer } from 'http'
6
+ import { Server as HttpsServer, createServer as _createServer } from 'https'
7
+ import os from 'os'
8
+ import path from 'path'
9
+ import tar from 'tar-fs'
10
+ import { WebSocketServer } from 'ws'
11
+ import zlib from 'zlib'
12
+ import auth from 'basic-auth'
13
+
14
+ import { loadConfig } from './config'
15
+ import { Session, SessionParams } from './session'
16
+ import { Stats } from './stats'
17
+ import { logger, runShellCommand, getDockerLogsPath } from './utils'
18
+
19
+ const log = logger('webrtcperf:server')
20
+
21
+ /**
22
+ * An HTTP server instance that allows to control the tool using a REST
23
+ * interface. Moreover, it allows to aggregate stats data coming from multiple
24
+ * running tool instances.
25
+ */
26
+ export class Server {
27
+ /** The server listening port. */
28
+ readonly serverPort: number
29
+ /** The basic auth secret. */
30
+ readonly serverSecret: string
31
+ /** If HTTPS protocol should be used. */
32
+ readonly serverUseHttps: boolean
33
+ /** An optional path that the HTTP server will expose with the /data endpoint. */
34
+ serverData: string
35
+ /** The file path that will be used to serve the \`/view/page.log\` requests. */
36
+ pageLogPath: string
37
+ /** The path that will be used to serve the \`/cache\` requests. */
38
+ videoCachePath: string
39
+ /** A {@link Stats} class instance. */
40
+ stats: Stats
41
+
42
+ private app: express.Express
43
+ private server: HttpServer | HttpsServer | null = null
44
+ private wss: WebSocketServer | null = null
45
+
46
+ /**
47
+ * Server instance.
48
+ * All the HTTP endpoints are protected by basic authentication with user
49
+ * `admin` and password {@link Server.serverSecret}.
50
+ * @param serverPort The server listening port.
51
+ * @param serverSecret The basic auth secret.
52
+ * @param serverUseHttps If HTTPS protocol should be used.
53
+ * @param serverData An optional path that the HTTP server will expose with the /data endpoint.
54
+ * @param pageLogPath The file path that will be used to serve the \`/view/page.log\` requests.
55
+ * @param videoCachePath The path that will be used to serve the \`/cache\` requests.
56
+ * @param stats A {@link Stats} class instance.
57
+ */
58
+ constructor(
59
+ {
60
+ serverPort = 5000,
61
+ serverSecret = 'secret',
62
+ serverUseHttps = false,
63
+ serverData = '',
64
+ pageLogPath = '',
65
+ videoCachePath = '',
66
+ } = {},
67
+ stats: Stats,
68
+ ) {
69
+ this.serverPort = serverPort
70
+ this.serverSecret = serverSecret
71
+ this.serverUseHttps = serverUseHttps
72
+ this.serverData = serverData
73
+ this.pageLogPath = pageLogPath
74
+ this.videoCachePath = videoCachePath
75
+ this.stats = stats
76
+ //
77
+ this.app = express()
78
+ this.app.use(compression())
79
+ this.app.use(
80
+ json({
81
+ limit: '10mb',
82
+ }),
83
+ )
84
+
85
+ this.app.use((req: express.Request, res: express.Response, next: express.NextFunction) => {
86
+ if (req.query.auth === this.serverSecret) {
87
+ return next()
88
+ }
89
+ const credentials = auth(req)
90
+ if (!credentials || credentials.name !== 'admin' || credentials.pass !== this.serverSecret) {
91
+ res.setHeader('WWW-Authenticate', 'Basic realm="Restricted Area"')
92
+ res.status(401).send('Unauthorized')
93
+ return
94
+ }
95
+ next()
96
+ })
97
+
98
+ this.app.get('/', (_req, res) => {
99
+ res.send('')
100
+ })
101
+
102
+ this.app.get('/stats', this.getStats.bind(this))
103
+ this.app.get('/collected-stats', this.getCollectedStats.bind(this))
104
+ this.app.get('/screenshot/:sessionId', this.getScreenshot.bind(this))
105
+ this.app.put('/collected-stats', this.putCollectedStats.bind(this))
106
+ this.app.put('/session', this.putSession.bind(this))
107
+ this.app.put('/sessions', this.putSessions.bind(this))
108
+ this.app.delete('/session', this.deleteSession.bind(this))
109
+ this.app.delete('/sessions', this.deleteSessions.bind(this))
110
+ this.app.get('/view/page.log', this.getPageLog.bind(this))
111
+ this.app.get('/view/docker.log', this.getDockerLog.bind(this))
112
+ this.app.get('/download/alert-rules', this.getAlertRules.bind(this))
113
+ this.app.get('/download/stats', this.getStatsFile.bind(this))
114
+ this.app.get('/download/detailed-stats', this.getDetailedStatsFile.bind(this))
115
+ this.app.get('/empty-page', this.getEmptyPage.bind(this))
116
+ if (this.serverData) {
117
+ log.debug(`using serverData: ${this.serverData}`)
118
+ fs.promises.mkdir(this.serverData, { recursive: true }).catch(err => {
119
+ log.error(`mkdir ${this.serverData} error: ${err.message}`)
120
+ })
121
+ this.app.get('/data', this.getDataArchive.bind(this))
122
+ this.app.get('/data/*', this.getData.bind(this))
123
+ }
124
+ if (this.videoCachePath) {
125
+ log.debug(`using videoCachePath: ${this.videoCachePath}`)
126
+ fs.promises.mkdir(this.videoCachePath, { recursive: true }).catch(err => {
127
+ log.error(`mkdir ${this.videoCachePath} error: ${err.message}`)
128
+ })
129
+ this.app.get('/cache/*', this.getCache.bind(this))
130
+ }
131
+
132
+ this.app.use((err: Error, req: express.Request, res: express.Response, next: express.NextFunction) => {
133
+ log.error(`request path=${req.path} error:`, err.stack)
134
+ if (res.headersSent) {
135
+ return next(err)
136
+ }
137
+ res.status(500).send(err.message)
138
+ })
139
+ }
140
+
141
+ /*
142
+ * onConnection
143
+ * @param {Socket} socket
144
+ */
145
+ /* onConnection(socket) {
146
+ log.debug('onConnection', socket);
147
+
148
+ socket.on('disconnect', () => {
149
+ log.debug('io socket disconnected');
150
+ });
151
+
152
+ socket.on('message', (msg) => {
153
+ log.debug('message', msg);
154
+ });
155
+ } */
156
+
157
+ /**
158
+ * GET /stats endpoint.
159
+ *
160
+ * Returns a JSON array of the last statistics for each running Session.
161
+ */
162
+ private async getStats(req: express.Request, res: express.Response, next: express.NextFunction): Promise<void> {
163
+ log.debug(`GET /stats`)
164
+ const stats = []
165
+ try {
166
+ for (const session of this.stats.sessions.values()) {
167
+ stats.push(session.stats)
168
+ }
169
+ res.json(stats)
170
+ } catch (err) {
171
+ next(err)
172
+ }
173
+ }
174
+
175
+ /**
176
+ * GET /download/stats endpoint.
177
+ *
178
+ * Returns the {@link Stats.statsWriter} file content.
179
+ */
180
+ private getStatsFile(req: express.Request, res: express.Response, next: express.NextFunction): void {
181
+ log.debug(`/download/stats`, req.query)
182
+ if (!this.stats.statsWriter) {
183
+ return next(new Error('statsPath not set'))
184
+ }
185
+ res.download(this.stats.statsPath)
186
+ }
187
+
188
+ /**
189
+ * GET /download/detailed-stats endpoint.
190
+ *
191
+ * Returns the {@link Stats.detailedStatsWriter} file content.
192
+ */
193
+ private getDetailedStatsFile(req: express.Request, res: express.Response, next: express.NextFunction): void {
194
+ log.debug(`/download/detailed-stats`, req.query)
195
+ if (!this.stats.detailedStatsWriter) {
196
+ return next(new Error('detailedStatsPath not set'))
197
+ }
198
+ res.download(this.stats.detailedStatsPath)
199
+ }
200
+
201
+ /**
202
+ * GET /collected-stats endpoint.
203
+ *
204
+ * Returns a JSON array of the last statistics collected from external running
205
+ * tools.
206
+ */
207
+ private getCollectedStats(req: express.Request, res: express.Response, next: express.NextFunction): void {
208
+ log.debug(`GET /collected-stats`)
209
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
210
+ const stats: Record<string, any> = {}
211
+ try {
212
+ for (const [key, stat] of Object.entries(this.stats.collectedStats)) {
213
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
214
+ stats[key] = (stat as any).data
215
+ }
216
+ res.json(stats)
217
+ } catch (err) {
218
+ next(err)
219
+ }
220
+ }
221
+
222
+ /**
223
+ * GET /screenshot/<sessionID> endpoint.
224
+ *
225
+ * Returns the page screenshot running inside the {@link Session} identified
226
+ * by `sessionID`.
227
+ * Additional query params:
228
+ * - `page`: the page number (starting from `0`) running inside the {@link Session}.
229
+ * - `format`: the image format (`jpeg`, `png`, `webp`). Default: `webp`.
230
+ */
231
+ private async getScreenshot(req: express.Request, res: express.Response, next: express.NextFunction): Promise<void> {
232
+ const sessionId = parseInt(req.params.sessionId)
233
+ const pageId = parseInt((req.query.page as string) || '0')
234
+ const format = (req.query.format as string) || 'webp'
235
+ log.debug(`GET /screenshot/${sessionId} page=${pageId} format=${format}`)
236
+ try {
237
+ const session = this.stats.sessions.get(sessionId)
238
+ if (!session) {
239
+ throw new Error(`Session not found: "${sessionId}"`)
240
+ }
241
+ const filePath = await session.pageScreenshot(pageId, format)
242
+ res.sendFile(path.resolve(filePath))
243
+ } catch (err) {
244
+ next(err)
245
+ }
246
+ }
247
+
248
+ /**
249
+ * PUT /collected-stats endpoint.
250
+ *
251
+ * Allows to inject {@link Stats} metrics coming from an external tool.
252
+ */
253
+ private putCollectedStats(req: express.Request, res: express.Response, next: express.NextFunction): void {
254
+ log.debug(`PUT /collected-stats`)
255
+ const { id, stats, config } = req.body
256
+ try {
257
+ this.stats.addExternalCollectedStats(id, stats, config)
258
+ res.json({
259
+ message: `Collected stats added`,
260
+ })
261
+ } catch (err) {
262
+ next(err)
263
+ }
264
+ }
265
+
266
+ /**
267
+ * PUT /session endpoint.
268
+ *
269
+ * Starts a new {@link Session}.
270
+ * The request body format will be parsed as a {@link SessionParams} object.
271
+ */
272
+ private async putSession(req: express.Request, res: express.Response, next: express.NextFunction): Promise<void> {
273
+ log.debug(`PUT /session`, req.body)
274
+ try {
275
+ const id = this.stats.consumeSessionId()
276
+ await this.startLocalSession(id, req.body)
277
+ res.json({
278
+ message: `Session created`,
279
+ data: { id },
280
+ })
281
+ } catch (err) {
282
+ next(err)
283
+ }
284
+ }
285
+
286
+ /**
287
+ * PUT /sessions endpoint.
288
+ *
289
+ * Starts multiple {@link Session} instances as specified into the
290
+ * `body.sessions` value.
291
+ * The request body will be parsed as a {@link SessionParams} object.
292
+ */
293
+ private async putSessions(req: express.Request, res: express.Response, next: express.NextFunction): Promise<void> {
294
+ log.debug(`PUT /sessions`, req.body)
295
+ try {
296
+ const { sessions } = req.body
297
+ const sessionsIds = []
298
+ for (let i = 0; i < sessions; i++) {
299
+ const id = this.stats.sessions.size
300
+ await this.startLocalSession(id, req.body)
301
+ sessionsIds.push(id)
302
+ }
303
+ res.json({
304
+ message: `${sessions} sessions created`,
305
+ data: { ids: sessionsIds },
306
+ })
307
+ } catch (err) {
308
+ next(err)
309
+ }
310
+ }
311
+
312
+ /**
313
+ * DELETE /session endpoint.
314
+ *
315
+ * Delete the {@link Session} instance identified by the `body.id` param.
316
+ */
317
+ private async deleteSession(req: express.Request, res: express.Response, next: express.NextFunction): Promise<void> {
318
+ log.debug(`DELETE /session`, req.body)
319
+ try {
320
+ const { id } = req.body
321
+ await this.stopLocalSession(id)
322
+ res.json({
323
+ message: `Session deleted`,
324
+ data: { id },
325
+ })
326
+ } catch (err) {
327
+ next(err)
328
+ }
329
+ }
330
+
331
+ /**
332
+ * DELETE /sessions endpoint.
333
+ *
334
+ * Delete the {@link Session} instances specified by the `body.ids` array.
335
+ */
336
+ private async deleteSessions(req: express.Request, res: express.Response, next: express.NextFunction): Promise<void> {
337
+ log.debug(`DELETE /sessions`, req.body)
338
+ try {
339
+ const { ids } = req.body
340
+ for (const id of ids) {
341
+ await this.stopLocalSession(id)
342
+ }
343
+ res.json({
344
+ message: `${ids.length} sessions deleted`,
345
+ data: { ids },
346
+ })
347
+ } catch (err) {
348
+ next(err)
349
+ }
350
+ }
351
+
352
+ /**
353
+ * GET /view/page.log endpoint.
354
+ *
355
+ * Returns the page log file content as specified in {@link Config} `pageLogPath`.
356
+ */
357
+ private getPageLog(req: express.Request, res: express.Response, next: express.NextFunction): void {
358
+ log.debug(`GET /view/page.log`, req.query)
359
+ if (!this.pageLogPath) {
360
+ return next(new Error('pageLogPath not set'))
361
+ }
362
+ if (req.query.range && !req.headers.range) {
363
+ req.headers.range = `bytes=${req.query.range}`
364
+ }
365
+ res.sendFile(path.resolve(this.pageLogPath))
366
+ }
367
+
368
+ /**
369
+ * GET /view/docker.log endpoint.
370
+ *
371
+ * Returns the Docker logs related to the container running the tool.
372
+ * It requires to run the Docker container with the following options:
373
+ * ```
374
+ --cidfile /tmp/docker.id
375
+ -v /tmp/docker.id:/root/.webrtcperf/docker.id:ro
376
+ -v /var/lib/docker:/var/lib/docker:ro
377
+ * ```
378
+ */
379
+ private async getDockerLog(req: express.Request, res: express.Response, next: express.NextFunction): Promise<void> {
380
+ log.debug(`GET /view/docker.log`, req.query)
381
+ try {
382
+ const logPath = await getDockerLogsPath()
383
+ if (req.query.range && !req.headers.range) {
384
+ req.headers.range = `bytes=${req.query.range}`
385
+ }
386
+ res.sendFile(path.resolve(logPath))
387
+ } catch (err) {
388
+ next(err)
389
+ }
390
+ }
391
+
392
+ /**
393
+ * GET /download/alert-rules endpoint.
394
+ *
395
+ * Downloads the alert rules report stored into the {@link Stats.alertRulesFilename}.
396
+ */
397
+ private getAlertRules(req: express.Request, res: express.Response, next: express.NextFunction): void {
398
+ log.debug(`GET /download/alert-rules`, req.query)
399
+ if (!this.stats.alertRulesFilename) {
400
+ return next(new Error('Stats alertRulesFilename not set'))
401
+ }
402
+ res.download(this.stats.alertRulesFilename)
403
+ }
404
+
405
+ /**
406
+ * GET /empty-page endpoint.
407
+ *
408
+ * Returns an empty HTML page. Useful for running tests with raw Javascript
409
+ * content without any DOM rendering.
410
+ */
411
+ private getEmptyPage(req: express.Request, res: express.Response): void {
412
+ log.debug(`GET /empty-page`, req.query)
413
+ const title = req.query.title || 'EmptyPage'
414
+ res.send(`<html lang="en">
415
+ <head>
416
+ <meta charset="UTF-8">
417
+ <meta name="viewport" content="width=device-width, initial-scale=1">
418
+ <title>${title}</title>
419
+ </head>
420
+ <body></body>
421
+ </html>`)
422
+ }
423
+
424
+ /**
425
+ * GET /data/* endpoint.
426
+ *
427
+ * Returns the file content relative to the {@link Config} `serverData` path.
428
+ * If the requested path points to a directory, it returns the directory
429
+ * content in tar.gz format.
430
+ */
431
+ private getData(req: express.Request, res: express.Response, next: express.NextFunction): void {
432
+ const paramPath = path.normalize(req.params[0]).replace(/^(\.\.(\/|\\|$))+/, '')
433
+ log.debug(`GET /data/${paramPath}`, req.query)
434
+ const fpath = path.resolve(this.serverData, paramPath)
435
+ if (!fs.existsSync(fpath)) {
436
+ return next(new Error(`${paramPath} not found`))
437
+ }
438
+ if (req.query.range && !req.headers.range) {
439
+ req.headers.range = `bytes=${req.query.range}`
440
+ }
441
+ res.sendFile(fpath)
442
+ }
443
+
444
+ private getDataArchive(req: express.Request, res: express.Response, next: express.NextFunction): void {
445
+ log.debug(`GET /data`, req.query)
446
+ const fpath = path.resolve(this.serverData)
447
+ if (!fs.lstatSync(fpath).isDirectory()) {
448
+ return next(new Error(`${fpath} is not a directory`))
449
+ }
450
+ res.header('Content-Disposition', `attachment; filename="${path.basename(fpath)}.tar.gz"`)
451
+ res.setHeader('content-type', 'application/gzip')
452
+ tar.pack(fpath).pipe(zlib.createGzip()).pipe(res)
453
+ }
454
+
455
+ private getCache(req: express.Request, res: express.Response, next: express.NextFunction): void {
456
+ const paramPath = path.normalize(req.params[0]).replace(/^(\.\.(\/|\\|$))+/, '')
457
+ log.debug(`GET /cache/${paramPath}`, req.query)
458
+ const fpath = path.resolve(this.videoCachePath, paramPath)
459
+ if (!fs.existsSync(fpath)) {
460
+ return next(new Error(`${paramPath} not found`))
461
+ }
462
+ if (req.query.range && !req.headers.range) {
463
+ req.headers.range = `bytes=${req.query.range}`
464
+ }
465
+ res.sendFile(fpath)
466
+ }
467
+
468
+ /**
469
+ * Starts a new {@link Session} instance.
470
+ * @param id The session unique id.
471
+ * @param config The session configuration.
472
+ */
473
+ private async startLocalSession(id: number, config: SessionParams): Promise<Session> {
474
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
475
+ const sessionConfig = loadConfig(undefined, config) as any
476
+ const session = new Session({ ...sessionConfig, id })
477
+ session.once('stop', () => {
478
+ console.warn(`Session ${id} stopped, reloading...`)
479
+ setTimeout(this.startLocalSession.bind(this), sessionConfig.spawnPeriod, id, config)
480
+ })
481
+ this.stats.addSession(session)
482
+ try {
483
+ await session.start()
484
+ } catch (err) {
485
+ this.stats.removeSession(session.id)
486
+ throw err
487
+ }
488
+ return session
489
+ }
490
+
491
+ /**
492
+ * Stops a new {@link Session} instance.
493
+ * @param {number} id The session unique id.
494
+ */
495
+ private async stopLocalSession(id: number): Promise<void> {
496
+ const session = this.stats.sessions.get(id)
497
+ if (!session) {
498
+ log.warn(`stopLocalSession session ${id} not found`)
499
+ return
500
+ }
501
+ session.removeAllListeners()
502
+ this.stats.removeSession(id)
503
+ await session.stop()
504
+ }
505
+
506
+ /**
507
+ * Starts the {@link Server} instance.
508
+ */
509
+ async start(): Promise<void> {
510
+ log.debug('start')
511
+ if (this.serverUseHttps) {
512
+ const destDir = path.join(os.homedir(), '.webrtcperf/ssl')
513
+ await runShellCommand(
514
+ `mkdir -p ${destDir} && openssl req -newkey rsa:2048 -nodes -keyout ${destDir}/domain.key -x509 -days 365 -out ${destDir}/domain.crt -subj "/C=EU/ST=London/L=London/O=Global Security/OU=IT Department/CN=example.com"`,
515
+ )
516
+ this.server = _createServer(
517
+ {
518
+ key: fs.readFileSync(`${destDir}/domain.key`),
519
+ cert: fs.readFileSync(`${destDir}/domain.crt`),
520
+ },
521
+ this.app,
522
+ )
523
+ } else {
524
+ this.server = createServer(this.app)
525
+ }
526
+
527
+ // WebSocket endpoint.
528
+ const wss = new WebSocketServer({ noServer: true })
529
+ wss.on('connection', (ws, request) => {
530
+ try {
531
+ const query = new URLSearchParams(request.url?.split('?')[1] || '')
532
+ const action = query.get('action') || ''
533
+ log.debug(`ws connection from ${request.socket.remoteAddress} action: ${action}`)
534
+ switch (action) {
535
+ case 'write-stream': {
536
+ if (!this.serverData) {
537
+ throw new Error('serverData option not set')
538
+ }
539
+ const filename = query.get('filename') || ''
540
+ if (!filename) {
541
+ throw new Error('filename not set')
542
+ }
543
+ const paramPath = path.normalize(filename).replace(/^(\.\.(\/|\\|$))+/, '')
544
+
545
+ log.debug(`ws write-stream ${paramPath}`)
546
+ const fpath = path.resolve(this.serverData, paramPath)
547
+ if (fs.existsSync(fpath)) {
548
+ throw new Error(`file already exists: ${fpath}`)
549
+ }
550
+ const stream = fs.createWriteStream(fpath)
551
+
552
+ let headerWritten = false
553
+ let framesWritten = 0
554
+
555
+ const close = async () => {
556
+ stream.close()
557
+ ws.close()
558
+
559
+ try {
560
+ if (!framesWritten) {
561
+ await fs.promises.unlink(fpath)
562
+ }
563
+ } catch (err) {
564
+ log.error(`ws write-stream close error: ${(err as Error).message}`)
565
+ }
566
+ }
567
+
568
+ stream.on('error', (err: Error) => {
569
+ log.error(`ws write-stream error: ${err.message}`)
570
+ void close()
571
+ })
572
+
573
+ ws.on('error', (err: Error) => {
574
+ log.error(`ws write-stream error: ${err.message}`)
575
+ void close()
576
+ })
577
+
578
+ ws.on('close', () => {
579
+ log.debug(`ws write-stream close`)
580
+ void close()
581
+ })
582
+
583
+ ws.on('message', (data: Uint8Array) => {
584
+ if (!data?.byteLength) return
585
+ if (!headerWritten) {
586
+ stream.write(data)
587
+ headerWritten = true
588
+ return
589
+ }
590
+ stream.write(data)
591
+ framesWritten++
592
+ })
593
+
594
+ break
595
+ }
596
+ default:
597
+ throw new Error(`invalid action: ${action}`)
598
+ }
599
+ } catch (err) {
600
+ log.error(`ws connection error: ${(err as Error).message}`)
601
+ ws.close()
602
+ }
603
+ })
604
+ this.wss = wss
605
+
606
+ this.server.on('upgrade', (request, socket, head) => {
607
+ log.debug(`ws upgrade ${request.url}`)
608
+ try {
609
+ const query = new URLSearchParams(request.url?.split('?')[1] || '')
610
+ const auth = query.get('auth')
611
+ if (!auth || !timingSafeEqual(Buffer.from(auth), Buffer.from(this.serverSecret))) {
612
+ throw new Error('invalid auth')
613
+ }
614
+ } catch (err) {
615
+ log.error(`ws upgrade error: ${(err as Error).message}`)
616
+ socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n')
617
+ socket.destroy()
618
+ return
619
+ }
620
+
621
+ wss.handleUpgrade(request, socket, head, ws => {
622
+ wss.emit('connection', ws, request)
623
+ })
624
+ })
625
+
626
+ this.server.listen(this.serverPort, () => {
627
+ log.debug(`HTTPS server listening on port ${this.serverPort}`)
628
+ })
629
+ }
630
+
631
+ /**
632
+ * Stops the {@link Server} instance.
633
+ */
634
+ stop(): void {
635
+ if (this.wss) {
636
+ this.wss.close()
637
+ this.wss = null
638
+ }
639
+ if (this.server) {
640
+ log.debug('stop')
641
+ this.server.close()
642
+ this.server = null
643
+ }
644
+ }
645
+ }