From 66d6f92d1e34ed538aa8460679003899ee131cdd Mon Sep 17 00:00:00 2001 From: stary <834207172@qq.com> Date: Tue, 2 Jun 2026 02:34:40 +0800 Subject: [PATCH] =?UTF-8?q?=E3=80=90m=E3=80=91=E4=BF=AE=E6=94=B9=E4=B8=BA?= =?UTF-8?q?=E6=9C=8D=E5=8A=A1=E5=99=A8=E5=BD=95=E5=B1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/public/call/media/meeting-recorder.js | 76 +- .../call/media/server-recording-peer.js | 175 +++ client/public/call/store.js | 222 +++ client/src/core/signaling.js | 36 + .../test/unit/server-recording-peer.test.js | 108 ++ package-lock.json | 1335 ++++++++++++++++- package.json | 1 + src/class/websockethandler.ts | 234 ++- src/recording/agent.ts | 226 +++ src/recording/composer.ts | 324 ++++ src/recording/session-manager.ts | 92 ++ src/recording/storage.ts | 257 ++++ src/recording/werift-adapter.ts | 284 ++++ src/server-recording-plan.md | 131 ++ src/server.ts | 157 +- src/websocket.ts | 13 + test/recording-agent.test.ts | 114 ++ test/recording-composer.test.ts | 85 ++ test/recording-session-manager.test.ts | 46 + test/recording-storage.test.ts | 131 ++ test/websockethandler.test.ts | 38 + 21 files changed, 4053 insertions(+), 32 deletions(-) create mode 100644 client/public/call/media/server-recording-peer.js create mode 100644 client/test/unit/server-recording-peer.test.js create mode 100644 src/recording/agent.ts create mode 100644 src/recording/composer.ts create mode 100644 src/recording/session-manager.ts create mode 100644 src/recording/storage.ts create mode 100644 src/recording/werift-adapter.ts create mode 100644 src/server-recording-plan.md create mode 100644 test/recording-agent.test.ts create mode 100644 test/recording-composer.test.ts create mode 100644 test/recording-session-manager.test.ts create mode 100644 test/recording-storage.test.ts diff --git a/client/public/call/media/meeting-recorder.js b/client/public/call/media/meeting-recorder.js index 3286cb8..85ca954 100644 --- a/client/public/call/media/meeting-recorder.js +++ b/client/public/call/media/meeting-recorder.js @@ -124,6 +124,11 @@ export class MeetingRecorder { this.audioSources = []; this.recordingStream = null; this.connectionId = ''; + this.layout = 'grid'; + this.onChunk = null; + this.storeChunks = true; + this.mixedAudioDestination = null; + this.mixedAudioTrackIds = new Set(); } isSupported() { @@ -137,7 +142,7 @@ export class MeetingRecorder { return Boolean(this.mediaRecorder && this.mediaRecorder.state !== 'inactive'); } - async start({ localStream, remoteStream, remoteStreams, connectionId } = {}) { + async start({ localStream, remoteStream, remoteStreams, connectionId, layout, onChunk, storeChunks } = {}) { if (this.isRecording()) { throw new Error('会议正在录制中'); } @@ -156,7 +161,11 @@ export class MeetingRecorder { } this.connectionId = connectionId || ''; + this.layout = layout || 'grid'; + this.onChunk = typeof onChunk === 'function' ? onChunk : null; + this.storeChunks = storeChunks !== false; this.chunks = []; + this.mixedAudioTrackIds = new Set(); this.canvas = canvas; this.context = context; @@ -179,6 +188,16 @@ export class MeetingRecorder { } } + syncAudio({ localStream, remoteStream, remoteStreams } = {}) { + if (!this.isRecording() || !this.audioContext || !this.mixedAudioDestination) { + return; + } + + const streams = collectStreams({ localStream, remoteStream, remoteStreams }); + const audioTracks = collectLiveAudioTracks(streams); + audioTracks.forEach(track => this._connectAudioTrack(track)); + } + stop() { if (!this.isRecording()) { return Promise.resolve(null); @@ -203,16 +222,26 @@ export class MeetingRecorder { } this.audioContext = new AudioContextCtor(); - const destination = this.audioContext.createMediaStreamDestination(); + this.mixedAudioDestination = this.audioContext.createMediaStreamDestination(); + audioTracks.forEach(track => this._connectAudioTrack(track)); - audioTracks.forEach(track => { - const sourceStream = new this.window.MediaStream([track]); - const source = this.audioContext.createMediaStreamSource(sourceStream); - source.connect(destination); - this.audioSources.push(source); - }); + return this.mixedAudioDestination.stream.getAudioTracks()[0] || null; + } - return destination.stream.getAudioTracks()[0] || null; + _connectAudioTrack(track) { + if (!track || track.readyState === 'ended') { + return; + } + const trackId = track.id || `${track.kind}-${Date.now()}`; + if (this.mixedAudioTrackIds.has(trackId)) { + return; + } + this.mixedAudioTrackIds.add(trackId); + + const sourceStream = new this.window.MediaStream([track]); + const source = this.audioContext.createMediaStreamSource(sourceStream); + source.connect(this.mixedAudioDestination); + this.audioSources.push(source); } startMediaRecorder(stream) { @@ -224,7 +253,17 @@ export class MeetingRecorder { this.mediaRecorder = new MediaRecorderCtor(stream, options); this.mediaRecorder.ondataavailable = (event) => { if (event.data && event.data.size > 0) { - this.chunks.push(event.data); + if (this.storeChunks) { + this.chunks.push(event.data); + } + if (this.onChunk) { + try { + this.onChunk(event.data); + } + catch (_error) { + // Ignore chunk callback failures so recording can continue. + } + } } }; this.mediaRecorder.onerror = (event) => { @@ -235,9 +274,9 @@ export class MeetingRecorder { this.cleanup(); }; this.mediaRecorder.onstop = () => { - const blob = new Blob(this.chunks, { type: this.mediaRecorder.mimeType || 'video/webm' }); const filename = this.buildFilename(); - const mimeType = blob.type || this.mediaRecorder.mimeType || 'video/webm'; + const mimeType = this.mediaRecorder.mimeType || 'video/webm'; + const blob = this.storeChunks ? new Blob(this.chunks, { type: mimeType }) : null; this.cleanup(); if (this.pendingStop) { this.pendingStop.resolve({ blob, filename, mimeType }); @@ -267,6 +306,15 @@ export class MeetingRecorder { context.fillStyle = '#020617'; context.fillRect(0, 0, canvas.width, canvas.height); + if (this.layout === 'host-only') { + if (localVideo) { + drawVideoCover(context, localVideo, 0, 0, canvas.width, canvas.height); + return; + } + drawEmptyFrame(context, canvas); + return; + } + if (remoteVideos.length > 0) { drawGrid(context, remoteVideos, canvas); if (localVideo) { @@ -328,9 +376,13 @@ export class MeetingRecorder { } this.audioSources = []; + this.mixedAudioDestination = null; + this.mixedAudioTrackIds = new Set(); this.mediaRecorder = null; this.canvas = null; this.context = null; this.chunks = []; + this.onChunk = null; + this.storeChunks = true; } } diff --git a/client/public/call/media/server-recording-peer.js b/client/public/call/media/server-recording-peer.js new file mode 100644 index 0000000..19b5596 --- /dev/null +++ b/client/public/call/media/server-recording-peer.js @@ -0,0 +1,175 @@ +import { createLogger } from '../../shared/logger.js'; + +const logger = createLogger('server-recording-peer'); + +export class ServerRecordingPeer { + constructor({ + rtcConfiguration, + getLocalStream, + getSignaling, + getConnectionId, + getParticipantId + }) { + this.rtcConfiguration = rtcConfiguration; + this.getLocalStream = getLocalStream; + this.getSignaling = getSignaling; + this.getConnectionId = getConnectionId; + this.getParticipantId = getParticipantId; + this.peers = new Map(); + } + + async start(request) { + if (!request || !request.recordingId) { + return; + } + + this.stop(request.recordingId); + + const localStream = this.getLocalStream(); + const tracks = localStream ? localStream.getTracks().filter(track => track.readyState !== 'ended') : []; + if (tracks.length === 0) { + this._sendStatus(request, 'no-local-media'); + return; + } + + const pc = new RTCPeerConnection(this.rtcConfiguration); + const state = { + pc, + recordingId: request.recordingId, + connectionId: request.connectionId || this.getConnectionId(), + pendingCandidates: [] + }; + this.peers.set(request.recordingId, state); + + pc.onicecandidate = (event) => { + if (!event.candidate) { + return; + } + + this._sendCandidate(state, event.candidate); + }; + pc.onconnectionstatechange = () => { + logger.debug(`recording peer ${request.recordingId} state: ${pc.connectionState}`); + }; + + tracks.forEach(track => { + pc.addTransceiver(track, { + direction: 'sendonly', + streams: localStream ? [localStream] : [] + }); + }); + + const offer = await pc.createOffer(); + await pc.setLocalDescription(offer); + this._sendOffer(state); + } + + async applyAnswer(answer) { + const state = this.peers.get(answer?.recordingId); + if (!state || !answer?.sdp) { + return; + } + + await state.pc.setRemoteDescription(new RTCSessionDescription({ + type: 'answer', + sdp: answer.sdp + })); + await this._flushPendingCandidates(state); + } + + async addIceCandidate(candidate) { + const state = this.peers.get(candidate?.recordingId); + if (!state || !candidate?.candidate) { + return; + } + + const iceCandidate = new RTCIceCandidate({ + candidate: candidate.candidate, + sdpMid: candidate.sdpMid, + sdpMLineIndex: candidate.sdpMLineIndex + }); + + if (!state.pc.remoteDescription) { + state.pendingCandidates.push(iceCandidate); + return; + } + + await state.pc.addIceCandidate(iceCandidate); + } + + stop(recordingId) { + if (!recordingId) { + this.peers.forEach(peerState => this._closePeer(peerState)); + this.peers.clear(); + return; + } + + const state = this.peers.get(recordingId); + if (!state) { + return; + } + + this._closePeer(state); + this.peers.delete(recordingId); + } + + _closePeer(state) { + state.pendingCandidates = []; + state.pc.close(); + } + + async _flushPendingCandidates(state) { + if (!state?.pc?.remoteDescription || !state.pendingCandidates.length) { + return; + } + + const pendingCandidates = state.pendingCandidates.splice(0, state.pendingCandidates.length); + for (const candidate of pendingCandidates) { + await state.pc.addIceCandidate(candidate); + } + } + + _sendOffer(state) { + const signaling = this.getSignaling(); + if (!signaling || typeof signaling.sendRecordingOffer !== 'function') { + return; + } + + signaling.sendRecordingOffer({ + recordingId: state.recordingId, + connectionId: state.connectionId, + participantId: this.getParticipantId() || '', + sdp: state.pc.localDescription?.sdp || '' + }); + } + + _sendCandidate(state, candidate) { + const signaling = this.getSignaling(); + if (!signaling || typeof signaling.sendRecordingCandidate !== 'function') { + return; + } + + signaling.sendRecordingCandidate({ + recordingId: state.recordingId, + connectionId: state.connectionId, + participantId: this.getParticipantId() || '', + candidate: candidate.candidate, + sdpMid: candidate.sdpMid, + sdpMLineIndex: candidate.sdpMLineIndex + }); + } + + _sendStatus(request, status) { + const signaling = this.getSignaling(); + if (!signaling || typeof signaling.sendRecordingStatus !== 'function') { + return; + } + + signaling.sendRecordingStatus({ + recordingId: request.recordingId, + connectionId: request.connectionId || this.getConnectionId(), + participantId: this.getParticipantId() || '', + status + }); + } +} diff --git a/client/public/call/store.js b/client/public/call/store.js index ea83db1..89adf3c 100644 --- a/client/public/call/store.js +++ b/client/public/call/store.js @@ -10,6 +10,7 @@ import { bindInviteSocketEvents, buildSocketUserInfoPayload, createSignalingInst import { getNetworkQualityFromSummary, summarizeInboundStats } from './media/webrtc-stats.js'; import { createLogger } from '../shared/logger.js'; import { MeetingRecorder } from './media/meeting-recorder.js'; +import { ServerRecordingPeer } from './media/server-recording-peer.js'; const logger = createLogger('store'); class CallStateManager { @@ -28,6 +29,9 @@ class CallStateManager { this.listeners = []; this.socketEventHandlers = {}; this._inviteEventSignaling = null; + this._recordingEventSignaling = null; + this.serverRecordingSession = null; + this.serverRecordingPeer = null; this.meetingRecorder = new MeetingRecorder(); } subscribe(callback) { @@ -112,12 +116,73 @@ class CallStateManager { async toggleRecording() { const isRecording = this.state.session.localUser.mediaState.recording || false; + if (this.useWebSocket && this.connectionId) { + return isRecording ? this.stopServerRecording() : this.startServerRecording(); + } + if (isRecording) { return this.stopRecording(); } return this.startRecording(); } + async startServerRecording() { + if (this.state.session.status !== 'ongoing') { + throw new Error('会议连接成功后才能开始录制'); + } + + const response = await fetch('/api/recording-sessions', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + connectionId: this.connectionId, + layout: 'grid', + format: 'webm' + }) + }); + const responseBody = await response.json().catch(() => ({})); + if (!response.ok || responseBody.success === false) { + throw new Error(responseBody.message || '服务端录制启动失败'); + } + + this.serverRecordingSession = responseBody.session; + this._setRecordingMediaState(true); + return { + recording: true, + message: '服务端录制已开始' + }; + } + async stopServerRecording() { + const recordingId = this.serverRecordingSession?.id; + if (!recordingId) { + this._setRecordingMediaState(false); + return { + recording: false, + message: '服务端录制已停止' + }; + } + + const response = await fetch(`/api/recording-sessions/${encodeURIComponent(recordingId)}`, { + method: 'DELETE' + }); + const responseBody = await response.json().catch(() => ({})); + if (!response.ok || responseBody.success === false) { + throw new Error(responseBody.message || '服务端录制停止失败'); + } + + this.serverRecordingSession = responseBody.session; + this._setRecordingMediaState(false); + return { + recording: false, + message: '服务端录制已停止' + }; + } + _setRecordingMediaState(value) { + this.state.session.localUser.mediaState.recording = value; + this._notifyLocalMediaChange('recording', value); + this.emitMediaStateChange(); + this._notifyUserListUpdate(); + } async startRecording() { if (this.state.session.status !== 'ongoing') { throw new Error('会议连接成功后才能开始录制'); @@ -229,6 +294,7 @@ class CallStateManager { async _updateLocalMediaRefactored(mediaType, value) { if (mediaType === 'video' && value) { await this._enableLocalVideo(); + this._refreshServerRecordingPeer(); this._notifyUserListUpdate(); return; } @@ -241,6 +307,7 @@ class CallStateManager { if (mediaType === 'audio') { this._setLocalAudioTrackEnabled(value); } + this._refreshServerRecordingPeer(); this._notifyUserListUpdate(); } async _enableLocalVideo() { @@ -441,6 +508,8 @@ class CallStateManager { await this._startConnection(connectionId); } _registerCallbacks() { + this._ensureServerRecordingPeer(); + this._bindRecordingSignalHandlers(); this.renderstreaming.onNewPeer = (participantId) => { logger.debug(`New peer created for ${participantId}, adding local tracks`); if (this.state.localStream) { @@ -534,6 +603,46 @@ class CallStateManager { this._handleRenderStreamingMessage(data); }; } + _ensureServerRecordingPeer() { + if (this.serverRecordingPeer) { + return this.serverRecordingPeer; + } + + this.serverRecordingPeer = new ServerRecordingPeer({ + rtcConfiguration: getRTCConfiguration(), + getLocalStream: () => this.state.localStream, + getSignaling: () => this.getActiveSignaling(), + getConnectionId: () => this.connectionId, + getParticipantId: () => this.selfParticipantId || (this.role === 'host' ? 'host' : '') + }); + return this.serverRecordingPeer; + } + _bindRecordingSignalHandlers() { + const signaling = this.renderstreaming?._signaling; + if (!signaling || signaling === this._recordingEventSignaling || typeof signaling.addEventListener !== 'function') { + return; + } + + signaling.addEventListener('recording-started', (event) => { + this._handleRecordingStarted(event.detail); + }); + signaling.addEventListener('recording-peer-request', (event) => { + this._handleRecordingPeerRequest(event.detail); + }); + signaling.addEventListener('recording-stopped', (event) => { + this._handleRecordingStopped(event.detail); + }); + signaling.addEventListener('recording-status', (event) => { + this._handleRecordingStatus(event.detail); + }); + signaling.addEventListener('recording-answer', (event) => { + this._handleRecordingAnswer(event.detail); + }); + signaling.addEventListener('recording-candidate', (event) => { + this._handleRecordingCandidate(event.detail); + }); + this._recordingEventSignaling = signaling; + } async _startConnection(connectionId) { await this.renderstreaming.start(); await this.renderstreaming.createConnection(connectionId); @@ -552,6 +661,9 @@ class CallStateManager { } this.clearStatsMessage(); this.stopNetworkQualityDetection(); + if (this.serverRecordingPeer) { + this.serverRecordingPeer.stop(); + } if (this.durationInterval) { clearInterval(this.durationInterval); this.durationInterval = null; @@ -674,6 +786,116 @@ class CallStateManager { break; } } + _isCurrentRecordingEvent(data) { + return data && (!data.connectionId || data.connectionId === this.connectionId); + } + _handleRecordingStarted(data) { + if (!this._isCurrentRecordingEvent(data)) { + return; + } + + this.serverRecordingSession = { + id: data.recordingId, + connectionId: data.connectionId, + status: data.status, + layout: data.layout, + format: data.format, + startedAt: data.startedAt + }; + this._setRecordingMediaState(true); + showNotification('服务端录制已开始', 'success'); + } + _handleRecordingStopped(data) { + if (!this._isCurrentRecordingEvent(data)) { + return; + } + + if (this.serverRecordingSession && this.serverRecordingSession.id === data.recordingId) { + this.serverRecordingSession = { + ...this.serverRecordingSession, + status: data.status, + stoppedAt: data.stoppedAt + }; + } + if (this.serverRecordingPeer) { + this.serverRecordingPeer.stop(data.recordingId); + } + this._setRecordingMediaState(false); + showNotification('服务端录制已停止', 'success'); + } + async _handleRecordingPeerRequest(data) { + if (!this._isCurrentRecordingEvent(data)) { + return; + } + + logger.debug('收到服务端录制媒体请求:', data); + this.notify({ + type: 'RECORDING_PEER_REQUEST', + recordingId: data.recordingId, + mediaMode: data.mediaMode + }); + try { + await this._ensureServerRecordingPeer().start(data); + } + catch (error) { + logger.error('服务端录制 PeerConnection 创建失败:', error); + showNotification('服务端录制媒体连接失败', 'error'); + } + } + _isServerRecordingActive() { + return this.useWebSocket + && this.serverRecordingSession + && this.serverRecordingSession.status === 'recording'; + } + _refreshServerRecordingPeer() { + if (!this._isServerRecordingActive() || !this.serverRecordingPeer) { + return; + } + + this.serverRecordingPeer.start({ + recordingId: this.serverRecordingSession.id, + connectionId: this.connectionId, + mediaMode: 'webrtc-sendonly' + }).catch((error) => { + logger.error('服务端录制媒体重协商失败:', error); + }); + } + _handleRecordingStatus(data) { + if (!this._isCurrentRecordingEvent(data)) { + return; + } + + logger.debug('收到服务端录制状态:', data); + this.notify({ + type: 'RECORDING_STATUS', + status: data.status, + recordingId: data.recordingId + }); + } + async _handleRecordingAnswer(data) { + if (!this._isCurrentRecordingEvent(data) || !this.serverRecordingPeer) { + return; + } + + try { + await this.serverRecordingPeer.applyAnswer(data); + } + catch (error) { + logger.error('服务端录制 answer 处理失败:', error); + } + } + async _handleRecordingCandidate(data) { + if (!this._isCurrentRecordingEvent(data) || !this.serverRecordingPeer) { + return; + } + + try { + await this.serverRecordingPeer.addIceCandidate(data); + } + catch (error) { + logger.error('服务端录制 candidate 处理失败:', error); + } + } _handleChatMessage(data) { const chatPayload = data.data || data.message; if (!chatPayload) { diff --git a/client/src/core/signaling.js b/client/src/core/signaling.js index b6536db..eb3fe34 100644 --- a/client/src/core/signaling.js +++ b/client/src/core/signaling.js @@ -235,6 +235,24 @@ export class WebSocketSignaling extends EventTarget { case "invite-failed": this.dispatchEvent(new CustomEvent('invite-failed', { detail: msg.data })); break; + case "recording-started": + this.dispatchEvent(new CustomEvent('recording-started', { detail: msg })); + break; + case "recording-peer-request": + this.dispatchEvent(new CustomEvent('recording-peer-request', { detail: msg })); + break; + case "recording-stopped": + this.dispatchEvent(new CustomEvent('recording-stopped', { detail: msg })); + break; + case "recording-status": + this.dispatchEvent(new CustomEvent('recording-status', { detail: msg })); + break; + case "recording-answer": + this.dispatchEvent(new CustomEvent('recording-answer', { detail: msg })); + break; + case "recording-candidate": + this.dispatchEvent(new CustomEvent('recording-candidate', { detail: msg })); + break; default: break; } @@ -326,4 +344,22 @@ export class WebSocketSignaling extends EventTarget { Logger.log(sendJson); this.websocket.send(sendJson); } + + sendRecordingOffer(payload) { + const sendJson = JSON.stringify({ type: 'recording-offer', data: payload }); + Logger.log(sendJson); + this.websocket.send(sendJson); + } + + sendRecordingCandidate(payload) { + const sendJson = JSON.stringify({ type: 'recording-candidate', data: payload }); + Logger.log(sendJson); + this.websocket.send(sendJson); + } + + sendRecordingStatus(payload) { + const sendJson = JSON.stringify({ type: 'recording-status', data: payload }); + Logger.log(sendJson); + this.websocket.send(sendJson); + } } diff --git a/client/test/unit/server-recording-peer.test.js b/client/test/unit/server-recording-peer.test.js new file mode 100644 index 0000000..9315569 --- /dev/null +++ b/client/test/unit/server-recording-peer.test.js @@ -0,0 +1,108 @@ +import { jest } from '@jest/globals'; +import { ServerRecordingPeer } from '../../public/call/media/server-recording-peer.js'; + +function createTrack(kind, id) { + return { + kind, + id, + readyState: 'live' + }; +} + +function createStream(tracks) { + return { + getTracks() { + return tracks; + } + }; +} + +describe('ServerRecordingPeer', () => { + test('queues remote candidates until answer is applied', async () => { + const originalRTCPeerConnection = window.RTCPeerConnection; + const originalRTCSessionDescription = window.RTCSessionDescription; + const originalRTCIceCandidate = window.RTCIceCandidate; + class FakeRTCPeerConnection { + constructor() { + this.localDescription = null; + this.remoteDescription = null; + this.candidates = []; + } + + addTransceiver() {} + + async createOffer() { + return { type: 'offer', sdp: 'test-offer-sdp' }; + } + + async setLocalDescription(description) { + this.localDescription = description; + } + + async setRemoteDescription(description) { + this.remoteDescription = description; + } + + async addIceCandidate(candidate) { + if (!this.remoteDescription) { + throw new Error('remote description missing'); + } + this.candidates.push(candidate); + } + + close() {} + } + + window.RTCPeerConnection = FakeRTCPeerConnection; + window.RTCSessionDescription = class { + constructor(init) { + Object.assign(this, init); + } + }; + window.RTCIceCandidate = class { + constructor(init) { + Object.assign(this, init); + } + }; + + const signaling = { + sendRecordingOffer: jest.fn(), + sendRecordingCandidate: jest.fn() + }; + const peer = new ServerRecordingPeer({ + rtcConfiguration: {}, + getLocalStream: () => createStream([createTrack('video', 'video-1')]), + getSignaling: () => signaling, + getConnectionId: () => 'room-1', + getParticipantId: () => 'participant-1' + }); + + await peer.start({ + recordingId: 'recording-1', + connectionId: 'room-1' + }); + + await expect(peer.addIceCandidate({ + recordingId: 'recording-1', + candidate: 'candidate:1', + sdpMid: '0', + sdpMLineIndex: 0 + })).resolves.toBeUndefined(); + + const state = peer.peers.get('recording-1'); + expect(state.pendingCandidates).toHaveLength(1); + expect(state.pc.candidates).toHaveLength(0); + + await peer.applyAnswer({ + recordingId: 'recording-1', + sdp: 'test-answer-sdp' + }); + + expect(state.pendingCandidates).toHaveLength(0); + expect(state.pc.candidates).toHaveLength(1); + + window.RTCPeerConnection = originalRTCPeerConnection; + window.RTCSessionDescription = originalRTCSessionDescription; + window.RTCIceCandidate = originalRTCIceCandidate; + }); +}); diff --git a/package-lock.json b/package-lock.json index 4679f12..4a0c3c2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,7 @@ "swagger-jsdoc": "^6.2.1", "swagger-ui-express": "^4.5.0", "uuid": "^9.0.0", + "werift": "^0.23.0", "ws": "^8.8.1" }, "bin": { @@ -739,6 +740,15 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.22.15", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.22.15.tgz", @@ -944,6 +954,34 @@ "integrity": "sha512-R11tGE6yIFwqpaIqcfkcg7AICXzFg14+5h5v0TfF/9+RMDL6jhzCy/pxHVOfbALGdtVYdt6JdR21tuxEgl34dw==", "dev": true }, + "node_modules/@fidm/asn1": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@fidm/asn1/-/asn1-1.0.4.tgz", + "integrity": "sha512-esd1jyNvRb2HVaQGq2Gg8Z0kbQPXzV9Tq5Z14KNIov6KfFD6PTaRIO8UpcsYiTNzOqJpmyzWgVTrUwFV3UF4TQ==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@fidm/x509": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@fidm/x509/-/x509-1.2.1.tgz", + "integrity": "sha512-nwc2iesjyc9hkuzcrMCBXQRn653XuAUKorfWM8PZyJawiy1QzLj4vahwzaI25+pfpwOLvMzbJ0uKpWLDNmo16w==", + "license": "MIT", + "dependencies": { + "@fidm/asn1": "^1.0.4", + "tweetnacl": "^1.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@fidm/x509/node_modules/tweetnacl": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-1.0.3.tgz", + "integrity": "sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==", + "license": "Unlicense" + }, "node_modules/@humanwhocodes/config-array": { "version": "0.10.4", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.10.4.tgz", @@ -1616,6 +1654,45 @@ "resolved": "https://registry.npmjs.org/@jsdevtools/ono/-/ono-7.1.3.tgz", "integrity": "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==" }, + "node_modules/@leichtgewicht/ip-codec": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@leichtgewicht/ip-codec/-/ip-codec-2.0.5.tgz", + "integrity": "sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw==", + "license": "MIT" + }, + "node_modules/@minhducsun2002/leb128": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@minhducsun2002/leb128/-/leb128-1.0.0.tgz", + "integrity": "sha512-eFrYUPDVHeuwWHluTG1kwNQUEUcFjVKYwPkU8z9DR1JH3AW7JtJsG9cRVGmwz809kKtGfwGJj58juCZxEvnI/g==", + "license": "MIT" + }, + "node_modules/@noble/curves": { + "version": "1.9.7", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.7.tgz", + "integrity": "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "1.8.0" + }, + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -1651,6 +1728,235 @@ "node": ">= 8" } }, + "node_modules/@peculiar/asn1-cms": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-cms/-/asn1-cms-2.7.0.tgz", + "integrity": "sha512-hew63shtzzvBcSHbhm+cyAmKe6AIfinT9hzEqSPjDC6opTTMKmTkQ0gHuN2KsWlvqiKw1S/fS94fhag/FJkioQ==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.7.0", + "@peculiar/asn1-x509": "^2.7.0", + "@peculiar/asn1-x509-attr": "^2.7.0", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-cms/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@peculiar/asn1-csr": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-csr/-/asn1-csr-2.7.0.tgz", + "integrity": "sha512-VVsAyGqErT9D1SY4aEqozThXMVI+ssVRiv2DDeYuvpBKLIgZ3hYs3Ay3u/VSoKq6ESFi9cf6rf3IOOzfwh7oMA==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.7.0", + "@peculiar/asn1-x509": "^2.7.0", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-csr/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@peculiar/asn1-ecc": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-ecc/-/asn1-ecc-2.7.0.tgz", + "integrity": "sha512-n7KEs/Q/wrB415cxy4fHOBhegp4NdJ15fkJPwcB/3/8iNBQC2L/N7SChJPKDJPZGYH0jD4Tg4/0vnHmwghnbKw==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.7.0", + "@peculiar/asn1-x509": "^2.7.0", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-ecc/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@peculiar/asn1-pfx": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-pfx/-/asn1-pfx-2.7.0.tgz", + "integrity": "sha512-V/nrlQVmhg7lYAsM7E13UDL5erAwFv6kCIVFqNaMIHSVi7dngcT839JkRTkQBqznMG98l2XjxYk74ZztAohZzA==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-cms": "^2.7.0", + "@peculiar/asn1-pkcs8": "^2.7.0", + "@peculiar/asn1-rsa": "^2.7.0", + "@peculiar/asn1-schema": "^2.7.0", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-pfx/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@peculiar/asn1-pkcs8": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs8/-/asn1-pkcs8-2.7.0.tgz", + "integrity": "sha512-9GTl1nE8Mx1kTZ+7QyYatDyKsm34QcWRBFkY1iPvWC3X4Dona5s/tlLiQsx5WzVdZqiMBZNYT0buyw4/vbhnjw==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.7.0", + "@peculiar/asn1-x509": "^2.7.0", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-pkcs8/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@peculiar/asn1-pkcs9": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs9/-/asn1-pkcs9-2.7.0.tgz", + "integrity": "sha512-Bh7m+OuIaSEllPQcSd9OSp93F4ROWH7sbITWV8MI+8dwsjE5111/87VxiWVvYFKyww3vp39geLv9ENqhwWHcew==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-cms": "^2.7.0", + "@peculiar/asn1-pfx": "^2.7.0", + "@peculiar/asn1-pkcs8": "^2.7.0", + "@peculiar/asn1-schema": "^2.7.0", + "@peculiar/asn1-x509": "^2.7.0", + "@peculiar/asn1-x509-attr": "^2.7.0", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-pkcs9/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@peculiar/asn1-rsa": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-rsa/-/asn1-rsa-2.7.0.tgz", + "integrity": "sha512-/qvENQrXyTZURjMqSeofHul0JJt2sNSzSwk36pl2olkHbaioMQgrASDZAlHXl0xUlnVbHj0uGgOrBMTb5x2aJQ==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.7.0", + "@peculiar/asn1-x509": "^2.7.0", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-rsa/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@peculiar/asn1-schema": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.7.0.tgz", + "integrity": "sha512-W8ZfWzLmQnrcky+eh3tni4IozMdqBDiHWU0N+vve/UGjMaUs8c0L7A2oEdkBXS8rTpWDpK/aoI3DG/L/hxmxPg==", + "license": "MIT", + "dependencies": { + "@peculiar/utils": "^2.0.2", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-schema/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@peculiar/asn1-x509": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509/-/asn1-x509-2.7.0.tgz", + "integrity": "sha512-mUn9RRrkGDnG4ALfunDmzyRW5dg+sWCj/pfnCCqEHYbkGxEpvUt6iVJv8Yw1cyp6SWZ26ZE5oSmI5SqEaen15g==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.7.0", + "@peculiar/utils": "^2.0.2", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-x509-attr": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509-attr/-/asn1-x509-attr-2.7.0.tgz", + "integrity": "sha512-NS8e7SOgXipkzUPLF/sce7ukpMpWjhxYsH0n6Y+bHYo4TTxOb95Zv7hqwSuL212mj5YxovjdOKQOgH1As3E94w==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.7.0", + "@peculiar/asn1-x509": "^2.7.0", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-x509-attr/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@peculiar/asn1-x509/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@peculiar/utils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@peculiar/utils/-/utils-2.0.3.tgz", + "integrity": "sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ==", + "license": "MIT", + "dependencies": { + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/utils/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@peculiar/x509": { + "version": "1.14.3", + "resolved": "https://registry.npmjs.org/@peculiar/x509/-/x509-1.14.3.tgz", + "integrity": "sha512-C2Xj8FZ0uHWeCXXqX5B4/gVFQmtSkiuOolzAgutjTfseNOHT3pUjljDZsTSxXFGgio54bCzVFqmEOUrIVk8RDA==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-cms": "^2.6.0", + "@peculiar/asn1-csr": "^2.6.0", + "@peculiar/asn1-ecc": "^2.6.0", + "@peculiar/asn1-pkcs9": "^2.6.0", + "@peculiar/asn1-rsa": "^2.6.0", + "@peculiar/asn1-schema": "^2.6.0", + "@peculiar/asn1-x509": "^2.6.0", + "pvtsutils": "^1.3.6", + "reflect-metadata": "^0.2.2", + "tslib": "^2.8.1", + "tsyringe": "^4.10.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@peculiar/x509/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, "node_modules/@postman/form-data": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/@postman/form-data/-/form-data-3.1.1.tgz", @@ -1707,6 +2013,24 @@ "integrity": "sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==", "hasInstallScript": true }, + "node_modules/@shinyoshiaki/binary-data": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/@shinyoshiaki/binary-data/-/binary-data-0.6.1.tgz", + "integrity": "sha512-7HDb/fQAop2bCmvDIzU5+69i+UJaFgIVp99h1VzK1mpg1JwSODOkjbqD7ilTYnqlnadF8C4XjpwpepxDsGY6+w==", + "license": "MIT", + "dependencies": { + "generate-function": "^2.3.1", + "is-plain-object": "^2.0.3" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@shinyoshiaki/jspack": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@shinyoshiaki/jspack/-/jspack-0.0.6.tgz", + "integrity": "sha512-SdsNhLjQh4onBlyPrn4ia1Pdx5bXT88G/LIEpOYAjx2u4xeY/m/HB5yHqlkJB1uQR3Zw4R3hBWLj46STRAN0rg==" + }, "node_modules/@sinclair/typebox": { "version": "0.24.35", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.24.35.tgz", @@ -2198,6 +2522,12 @@ "node": ">=0.4.0" } }, + "node_modules/aes-js": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/aes-js/-/aes-js-3.1.2.tgz", + "integrity": "sha512-e5pEa2kBnBOgR4Y/p20pskXI74UEz7de8ZGVo58asOtvSVG5YAbJeELPZxOmt+Bnz3rX753YKhfIn4X4l1PPRQ==", + "license": "MIT" + }, "node_modules/agent-base": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", @@ -2345,6 +2675,26 @@ "safer-buffer": "~2.1.0" } }, + "node_modules/asn1js": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.10.tgz", + "integrity": "sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg==", + "license": "BSD-3-Clause", + "dependencies": { + "pvtsutils": "^1.3.6", + "pvutils": "^1.1.5", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/asn1js/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, "node_modules/assert-plus": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", @@ -2438,7 +2788,6 @@ "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "dev": true, "funding": [ { "type": "github", @@ -2656,6 +3005,15 @@ "ieee754": "^1.1.13" } }, + "node_modules/buffer-crc32": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-1.0.0.tgz", + "integrity": "sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -3060,6 +3418,22 @@ "node": ">=0.10" } }, + "node_modules/date-fns": { + "version": "2.30.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.30.0.tgz", + "integrity": "sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.21.0" + }, + "engines": { + "node": ">=0.11" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/date-fns" + } + }, "node_modules/debug": { "version": "4.3.4", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", @@ -3211,6 +3585,18 @@ "node": ">=8" } }, + "node_modules/dns-packet": { + "version": "5.6.1", + "resolved": "https://registry.npmjs.org/dns-packet/-/dns-packet-5.6.1.tgz", + "integrity": "sha512-l4gcSouhcgIKRvyy99RNVOgxXiicE+2jZoNmaNmZ6JXiGajBOJAesk1OBlJuM5k2c+eudGdLxDqXuPCKIj6kpw==", + "license": "MIT", + "dependencies": { + "@leichtgewicht/ip-codec": "^2.0.1" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/doctrine": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", @@ -3781,8 +4167,7 @@ "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" }, "node_modules/fast-glob": { "version": "3.2.11", @@ -4106,6 +4491,15 @@ "node": ">=0.10.0" } }, + "node_modules/generate-function": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz", + "integrity": "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==", + "license": "MIT", + "dependencies": { + "is-property": "^1.0.2" + } + }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -4445,7 +4839,6 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", - "dev": true, "funding": [ { "type": "github", @@ -4534,6 +4927,12 @@ "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", "dev": true }, + "node_modules/int64-buffer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/int64-buffer/-/int64-buffer-1.1.0.tgz", + "integrity": "sha512-94smTCQOvigN4d/2R/YDjz8YVG0Sufvv2aAh8P5m42gwhCsDAJqnbNOrxJsrADuAFAA69Q/ptGzxvNcNuIJcvw==", + "license": "MIT" + }, "node_modules/into-stream": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/into-stream/-/into-stream-6.0.0.tgz", @@ -4550,6 +4949,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/ip": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ip/-/ip-2.0.1.tgz", + "integrity": "sha512-lJUL9imLTNi1ZfXT+DU6rBBdbiKGBuay9B6xGSPVjUeQwaH1RIGqef8RZkUtHioLmSNpPR5M4HVKJGm1j8FWVQ==", + "license": "MIT" + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -4624,6 +5029,24 @@ "node": ">=0.12.0" } }, + "node_modules/is-plain-object": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz", + "integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==", + "license": "MIT", + "dependencies": { + "isobject": "^3.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-property": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz", + "integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==", + "license": "MIT" + }, "node_modules/is-stream": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", @@ -4654,6 +5077,15 @@ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "dev": true }, + "node_modules/isobject": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", + "integrity": "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/isstream": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz", @@ -6009,8 +6441,7 @@ "node_modules/lodash": { "version": "4.17.21", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", - "dev": true + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" }, "node_modules/lodash.get": { "version": "4.4.2", @@ -6282,6 +6713,12 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" }, + "node_modules/mp4box": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/mp4box/-/mp4box-0.5.4.tgz", + "integrity": "sha512-GcCH0fySxBurJtvr0dfhz0IxHZjc1RP+F+I8xw+LIwkU1a+7HJx8NCDiww1I5u4Hz6g4eR1JlGADEGJ9r4lSfA==", + "license": "BSD-3-Clause" + }, "node_modules/ms": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", @@ -6305,6 +6742,19 @@ "url": "https://opencollective.com/express" } }, + "node_modules/multicast-dns": { + "version": "7.2.5", + "resolved": "https://registry.npmjs.org/multicast-dns/-/multicast-dns-7.2.5.tgz", + "integrity": "sha512-2eznPJP8z2BFLX50tf0LuODrpINqP1RVIm/CObbTcBRITQgmC/TjcREF1NeTBzIcR5XO/ukWo+YHOjBbFwIupg==", + "license": "MIT", + "dependencies": { + "dns-packet": "^5.2.2", + "thunky": "^1.0.2" + }, + "bin": { + "multicast-dns": "cli.js" + } + }, "node_modules/multistream": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/multistream/-/multistream-4.1.0.tgz", @@ -6601,6 +7051,15 @@ "node": ">= 0.8.0" } }, + "node_modules/p-cancelable": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-2.1.1.tgz", + "integrity": "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/p-is-promise": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/p-is-promise/-/p-is-promise-3.0.0.tgz", @@ -7281,6 +7740,30 @@ "node": ">=6" } }, + "node_modules/pvtsutils": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz", + "integrity": "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.8.1" + } + }, + "node_modules/pvtsutils/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/pvutils": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.1.5.tgz", + "integrity": "sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA==", + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/qs": { "version": "6.10.3", "resolved": "https://registry.npmjs.org/qs/-/qs-6.10.3.tgz", @@ -7388,6 +7871,12 @@ "util-deprecate": "~1.0.1" } }, + "node_modules/reflect-metadata": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", + "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", + "license": "Apache-2.0" + }, "node_modules/regexpp": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/regexpp/-/regexpp-3.2.0.tgz", @@ -7519,6 +8008,11 @@ "queue-microtask": "^1.2.2" } }, + "node_modules/rx.mini": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/rx.mini/-/rx.mini-1.4.0.tgz", + "integrity": "sha512-8w5cSc1mwNja7fl465DXOkVvIOkpvh2GW4jo31nAIvX4WTXCsRnKJGUfiDBzWtYRInEcHAUYIZfzusjIrea8gA==" + }, "node_modules/safe-buffer": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", @@ -8120,6 +8614,12 @@ "integrity": "sha1-f17oI66AUgfACvLfSoTsP8+lcLQ=", "dev": true }, + "node_modules/thunky": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/thunky/-/thunky-1.1.0.tgz", + "integrity": "sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA==", + "license": "MIT" + }, "node_modules/tmpl": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", @@ -8250,8 +8750,7 @@ "node_modules/tslib": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", - "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", - "dev": true + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==" }, "node_modules/tsutils": { "version": "3.21.0", @@ -8268,6 +8767,18 @@ "typescript": ">=2.8.0 || >= 3.2.0-dev || >= 3.3.0-dev || >= 3.4.0-dev || >= 3.5.0-dev || >= 3.6.0-dev || >= 3.6.0-beta || >= 3.7.0-dev || >= 3.7.0-beta" } }, + "node_modules/tsyringe": { + "version": "4.10.0", + "resolved": "https://registry.npmjs.org/tsyringe/-/tsyringe-4.10.0.tgz", + "integrity": "sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==", + "license": "MIT", + "dependencies": { + "tslib": "^1.9.3" + }, + "engines": { + "node": ">= 6.0.0" + } + }, "node_modules/tunnel-agent": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", @@ -8508,6 +9019,220 @@ "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", "dev": true }, + "node_modules/werift": { + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/werift/-/werift-0.23.0.tgz", + "integrity": "sha512-/WcIN5DHFG9Ri4anGOmIkp8gxBGFMWSIB/m4sfZ5CWlLfD3iMhiaAUuTBuc+KV3SY9NDmvmLtiN2uaM7k3lVzw==", + "license": "MIT", + "dependencies": { + "@fidm/x509": "^1.2.1", + "@minhducsun2002/leb128": "^1.0.0", + "@noble/curves": "^1.8.1", + "@peculiar/x509": "^1.12.3", + "@shinyoshiaki/binary-data": "^0.6.1", + "@shinyoshiaki/jspack": "^0.0.6", + "aes-js": "^3.1.2", + "buffer": "^6.0.3", + "debug": "4.4.0", + "fast-deep-equal": "^3.1.3", + "int64-buffer": "1.1.0", + "ip": "^2.0.1", + "mp4box": "^0.5.3", + "multicast-dns": "^7.2.5", + "tweetnacl": "^1.0.3", + "werift-common": "*", + "werift-dtls": "*", + "werift-ice": "*", + "werift-rtp": "*", + "werift-sctp": "*" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/werift-common": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/werift-common/-/werift-common-0.0.3.tgz", + "integrity": "sha512-ma3E4BqKTyZVLhrdfTVs2T1tg9seeUtKMRn5e64LwgrogWa62+3LAUoLBUSl1yPWhgSkXId7GmcHuWDen9IJeQ==", + "license": "MIT", + "dependencies": { + "@shinyoshiaki/jspack": "^0.0.6", + "debug": "^4.4.0" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/werift-common/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/werift-common/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/werift-dtls": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/werift-dtls/-/werift-dtls-0.5.7.tgz", + "integrity": "sha512-z2fjbP7fFUFmu/Ky4bCKXzdgPTtmSY1DYi0TUf3GG2zJT4jMQ3TQmGY8y7BSSNGetvL4h3pRZ5un0EcSOWpPog==", + "license": "MIT", + "dependencies": { + "@fidm/x509": "^1.2.1", + "@noble/curves": "^1.3.0", + "@peculiar/x509": "^1.9.2", + "@shinyoshiaki/binary-data": "^0.6.1", + "date-fns": "^2.29.3", + "lodash": "^4.17.21", + "rx.mini": "^1.2.2", + "tweetnacl": "^1.0.3" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/werift-dtls/node_modules/tweetnacl": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-1.0.3.tgz", + "integrity": "sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==", + "license": "Unlicense" + }, + "node_modules/werift-ice": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/werift-ice/-/werift-ice-0.2.2.tgz", + "integrity": "sha512-td52pHp+JmFnUn5jfDr/SSNO0dMCbknhuPdN1tFp9cfRj5jaktN63qnAdUuZC20QCC3ETWdsOthcm+RalHpFCQ==", + "license": "MIT", + "dependencies": { + "@shinyoshiaki/jspack": "^0.0.6", + "buffer-crc32": "^1.0.0", + "debug": "^4.3.4", + "int64-buffer": "^1.0.1", + "ip": "^2.0.1", + "lodash": "^4.17.21", + "multicast-dns": "^7.2.5", + "p-cancelable": "^2.1.1", + "rx.mini": "^1.2.2" + } + }, + "node_modules/werift-rtp": { + "version": "0.8.8", + "resolved": "https://registry.npmjs.org/werift-rtp/-/werift-rtp-0.8.8.tgz", + "integrity": "sha512-GiYMSdvCyScQaw5bnEsraSoHUVZpjfokJAiLV4R1FsiB06t6XiebPYPpkqB9nYNNKiA8Z/cYWsym7wISq1sYSQ==", + "license": "MIT", + "dependencies": { + "@minhducsun2002/leb128": "^1.0.0", + "@shinyoshiaki/jspack": "^0.0.6", + "aes-js": "^3.1.2", + "buffer": "^6.0.3", + "mp4box": "^0.5.3" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/werift-rtp/node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/werift-sctp": { + "version": "0.0.11", + "resolved": "https://registry.npmjs.org/werift-sctp/-/werift-sctp-0.0.11.tgz", + "integrity": "sha512-7109yuI5U7NTEHjqjn0A8VeynytkgVaxM6lRr1Ziv0D8bPcaB8A7U/P88M7WaCpWDoELHoXiRUjQycMWStIgjQ==", + "license": "MIT", + "dependencies": { + "@shinyoshiaki/jspack": "^0.0.6" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/werift/node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/werift/node_modules/debug": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", + "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/werift/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/werift/node_modules/tweetnacl": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-1.0.3.tgz", + "integrity": "sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==", + "license": "Unlicense" + }, "node_modules/whatwg-url": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", @@ -9258,6 +9983,11 @@ "@babel/helper-plugin-utils": "^7.18.6" } }, + "@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==" + }, "@babel/template": { "version": "7.22.15", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.22.15.tgz", @@ -9421,6 +10151,27 @@ "integrity": "sha512-R11tGE6yIFwqpaIqcfkcg7AICXzFg14+5h5v0TfF/9+RMDL6jhzCy/pxHVOfbALGdtVYdt6JdR21tuxEgl34dw==", "dev": true }, + "@fidm/asn1": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@fidm/asn1/-/asn1-1.0.4.tgz", + "integrity": "sha512-esd1jyNvRb2HVaQGq2Gg8Z0kbQPXzV9Tq5Z14KNIov6KfFD6PTaRIO8UpcsYiTNzOqJpmyzWgVTrUwFV3UF4TQ==" + }, + "@fidm/x509": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@fidm/x509/-/x509-1.2.1.tgz", + "integrity": "sha512-nwc2iesjyc9hkuzcrMCBXQRn653XuAUKorfWM8PZyJawiy1QzLj4vahwzaI25+pfpwOLvMzbJ0uKpWLDNmo16w==", + "requires": { + "@fidm/asn1": "^1.0.4", + "tweetnacl": "^1.0.1" + }, + "dependencies": { + "tweetnacl": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-1.0.3.tgz", + "integrity": "sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==" + } + } + }, "@humanwhocodes/config-array": { "version": "0.10.4", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.10.4.tgz", @@ -9953,6 +10704,29 @@ "resolved": "https://registry.npmjs.org/@jsdevtools/ono/-/ono-7.1.3.tgz", "integrity": "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==" }, + "@leichtgewicht/ip-codec": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@leichtgewicht/ip-codec/-/ip-codec-2.0.5.tgz", + "integrity": "sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw==" + }, + "@minhducsun2002/leb128": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@minhducsun2002/leb128/-/leb128-1.0.0.tgz", + "integrity": "sha512-eFrYUPDVHeuwWHluTG1kwNQUEUcFjVKYwPkU8z9DR1JH3AW7JtJsG9cRVGmwz809kKtGfwGJj58juCZxEvnI/g==" + }, + "@noble/curves": { + "version": "1.9.7", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.7.tgz", + "integrity": "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==", + "requires": { + "@noble/hashes": "1.8.0" + } + }, + "@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==" + }, "@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -9979,6 +10753,232 @@ "fastq": "^1.6.0" } }, + "@peculiar/asn1-cms": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-cms/-/asn1-cms-2.7.0.tgz", + "integrity": "sha512-hew63shtzzvBcSHbhm+cyAmKe6AIfinT9hzEqSPjDC6opTTMKmTkQ0gHuN2KsWlvqiKw1S/fS94fhag/FJkioQ==", + "requires": { + "@peculiar/asn1-schema": "^2.7.0", + "@peculiar/asn1-x509": "^2.7.0", + "@peculiar/asn1-x509-attr": "^2.7.0", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" + }, + "dependencies": { + "tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" + } + } + }, + "@peculiar/asn1-csr": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-csr/-/asn1-csr-2.7.0.tgz", + "integrity": "sha512-VVsAyGqErT9D1SY4aEqozThXMVI+ssVRiv2DDeYuvpBKLIgZ3hYs3Ay3u/VSoKq6ESFi9cf6rf3IOOzfwh7oMA==", + "requires": { + "@peculiar/asn1-schema": "^2.7.0", + "@peculiar/asn1-x509": "^2.7.0", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" + }, + "dependencies": { + "tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" + } + } + }, + "@peculiar/asn1-ecc": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-ecc/-/asn1-ecc-2.7.0.tgz", + "integrity": "sha512-n7KEs/Q/wrB415cxy4fHOBhegp4NdJ15fkJPwcB/3/8iNBQC2L/N7SChJPKDJPZGYH0jD4Tg4/0vnHmwghnbKw==", + "requires": { + "@peculiar/asn1-schema": "^2.7.0", + "@peculiar/asn1-x509": "^2.7.0", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" + }, + "dependencies": { + "tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" + } + } + }, + "@peculiar/asn1-pfx": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-pfx/-/asn1-pfx-2.7.0.tgz", + "integrity": "sha512-V/nrlQVmhg7lYAsM7E13UDL5erAwFv6kCIVFqNaMIHSVi7dngcT839JkRTkQBqznMG98l2XjxYk74ZztAohZzA==", + "requires": { + "@peculiar/asn1-cms": "^2.7.0", + "@peculiar/asn1-pkcs8": "^2.7.0", + "@peculiar/asn1-rsa": "^2.7.0", + "@peculiar/asn1-schema": "^2.7.0", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" + }, + "dependencies": { + "tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" + } + } + }, + "@peculiar/asn1-pkcs8": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs8/-/asn1-pkcs8-2.7.0.tgz", + "integrity": "sha512-9GTl1nE8Mx1kTZ+7QyYatDyKsm34QcWRBFkY1iPvWC3X4Dona5s/tlLiQsx5WzVdZqiMBZNYT0buyw4/vbhnjw==", + "requires": { + "@peculiar/asn1-schema": "^2.7.0", + "@peculiar/asn1-x509": "^2.7.0", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" + }, + "dependencies": { + "tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" + } + } + }, + "@peculiar/asn1-pkcs9": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs9/-/asn1-pkcs9-2.7.0.tgz", + "integrity": "sha512-Bh7m+OuIaSEllPQcSd9OSp93F4ROWH7sbITWV8MI+8dwsjE5111/87VxiWVvYFKyww3vp39geLv9ENqhwWHcew==", + "requires": { + "@peculiar/asn1-cms": "^2.7.0", + "@peculiar/asn1-pfx": "^2.7.0", + "@peculiar/asn1-pkcs8": "^2.7.0", + "@peculiar/asn1-schema": "^2.7.0", + "@peculiar/asn1-x509": "^2.7.0", + "@peculiar/asn1-x509-attr": "^2.7.0", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" + }, + "dependencies": { + "tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" + } + } + }, + "@peculiar/asn1-rsa": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-rsa/-/asn1-rsa-2.7.0.tgz", + "integrity": "sha512-/qvENQrXyTZURjMqSeofHul0JJt2sNSzSwk36pl2olkHbaioMQgrASDZAlHXl0xUlnVbHj0uGgOrBMTb5x2aJQ==", + "requires": { + "@peculiar/asn1-schema": "^2.7.0", + "@peculiar/asn1-x509": "^2.7.0", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" + }, + "dependencies": { + "tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" + } + } + }, + "@peculiar/asn1-schema": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.7.0.tgz", + "integrity": "sha512-W8ZfWzLmQnrcky+eh3tni4IozMdqBDiHWU0N+vve/UGjMaUs8c0L7A2oEdkBXS8rTpWDpK/aoI3DG/L/hxmxPg==", + "requires": { + "@peculiar/utils": "^2.0.2", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" + }, + "dependencies": { + "tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" + } + } + }, + "@peculiar/asn1-x509": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509/-/asn1-x509-2.7.0.tgz", + "integrity": "sha512-mUn9RRrkGDnG4ALfunDmzyRW5dg+sWCj/pfnCCqEHYbkGxEpvUt6iVJv8Yw1cyp6SWZ26ZE5oSmI5SqEaen15g==", + "requires": { + "@peculiar/asn1-schema": "^2.7.0", + "@peculiar/utils": "^2.0.2", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" + }, + "dependencies": { + "tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" + } + } + }, + "@peculiar/asn1-x509-attr": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509-attr/-/asn1-x509-attr-2.7.0.tgz", + "integrity": "sha512-NS8e7SOgXipkzUPLF/sce7ukpMpWjhxYsH0n6Y+bHYo4TTxOb95Zv7hqwSuL212mj5YxovjdOKQOgH1As3E94w==", + "requires": { + "@peculiar/asn1-schema": "^2.7.0", + "@peculiar/asn1-x509": "^2.7.0", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" + }, + "dependencies": { + "tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" + } + } + }, + "@peculiar/utils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@peculiar/utils/-/utils-2.0.3.tgz", + "integrity": "sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ==", + "requires": { + "tslib": "^2.8.1" + }, + "dependencies": { + "tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" + } + } + }, + "@peculiar/x509": { + "version": "1.14.3", + "resolved": "https://registry.npmjs.org/@peculiar/x509/-/x509-1.14.3.tgz", + "integrity": "sha512-C2Xj8FZ0uHWeCXXqX5B4/gVFQmtSkiuOolzAgutjTfseNOHT3pUjljDZsTSxXFGgio54bCzVFqmEOUrIVk8RDA==", + "requires": { + "@peculiar/asn1-cms": "^2.6.0", + "@peculiar/asn1-csr": "^2.6.0", + "@peculiar/asn1-ecc": "^2.6.0", + "@peculiar/asn1-pkcs9": "^2.6.0", + "@peculiar/asn1-rsa": "^2.6.0", + "@peculiar/asn1-schema": "^2.6.0", + "@peculiar/asn1-x509": "^2.6.0", + "pvtsutils": "^1.3.6", + "reflect-metadata": "^0.2.2", + "tslib": "^2.8.1", + "tsyringe": "^4.10.0" + }, + "dependencies": { + "tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" + } + } + }, "@postman/form-data": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/@postman/form-data/-/form-data-3.1.1.tgz", @@ -10024,6 +11024,20 @@ "resolved": "https://registry.npmjs.org/@scarf/scarf/-/scarf-1.4.0.tgz", "integrity": "sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==" }, + "@shinyoshiaki/binary-data": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/@shinyoshiaki/binary-data/-/binary-data-0.6.1.tgz", + "integrity": "sha512-7HDb/fQAop2bCmvDIzU5+69i+UJaFgIVp99h1VzK1mpg1JwSODOkjbqD7ilTYnqlnadF8C4XjpwpepxDsGY6+w==", + "requires": { + "generate-function": "^2.3.1", + "is-plain-object": "^2.0.3" + } + }, + "@shinyoshiaki/jspack": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@shinyoshiaki/jspack/-/jspack-0.0.6.tgz", + "integrity": "sha512-SdsNhLjQh4onBlyPrn4ia1Pdx5bXT88G/LIEpOYAjx2u4xeY/m/HB5yHqlkJB1uQR3Zw4R3hBWLj46STRAN0rg==" + }, "@sinclair/typebox": { "version": "0.24.35", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.24.35.tgz", @@ -10412,6 +11426,11 @@ "integrity": "sha512-k+iyHEuPgSw6SbuDpGQM+06HQUa04DZ3o+F6CSzXMvvI5KMvnaEqXe+YVe555R9nn6GPt404fos4wcgpw12SDA==", "dev": true }, + "aes-js": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/aes-js/-/aes-js-3.1.2.tgz", + "integrity": "sha512-e5pEa2kBnBOgR4Y/p20pskXI74UEz7de8ZGVo58asOtvSVG5YAbJeELPZxOmt+Bnz3rX753YKhfIn4X4l1PPRQ==" + }, "agent-base": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", @@ -10527,6 +11546,23 @@ "safer-buffer": "~2.1.0" } }, + "asn1js": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.10.tgz", + "integrity": "sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg==", + "requires": { + "pvtsutils": "^1.3.6", + "pvutils": "^1.1.5", + "tslib": "^2.8.1" + }, + "dependencies": { + "tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" + } + } + }, "assert-plus": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", @@ -10604,8 +11640,7 @@ "base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", - "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "dev": true + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==" }, "basic-auth": { "version": "2.0.1", @@ -10764,6 +11799,11 @@ "ieee754": "^1.1.13" } }, + "buffer-crc32": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-1.0.0.tgz", + "integrity": "sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==" + }, "buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -11070,6 +12110,14 @@ "assert-plus": "^1.0.0" } }, + "date-fns": { + "version": "2.30.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.30.0.tgz", + "integrity": "sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==", + "requires": { + "@babel/runtime": "^7.21.0" + } + }, "debug": { "version": "4.3.4", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", @@ -11176,6 +12224,14 @@ "path-type": "^4.0.0" } }, + "dns-packet": { + "version": "5.6.1", + "resolved": "https://registry.npmjs.org/dns-packet/-/dns-packet-5.6.1.tgz", + "integrity": "sha512-l4gcSouhcgIKRvyy99RNVOgxXiicE+2jZoNmaNmZ6JXiGajBOJAesk1OBlJuM5k2c+eudGdLxDqXuPCKIj6kpw==", + "requires": { + "@leichtgewicht/ip-codec": "^2.0.1" + } + }, "doctrine": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", @@ -11591,8 +12647,7 @@ "fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" }, "fast-glob": { "version": "3.2.11", @@ -11861,6 +12916,14 @@ } } }, + "generate-function": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz", + "integrity": "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==", + "requires": { + "is-property": "^1.0.2" + } + }, "gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -12102,8 +13165,7 @@ "ieee754": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", - "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", - "dev": true + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==" }, "ignore": { "version": "5.2.0", @@ -12157,6 +13219,11 @@ "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", "dev": true }, + "int64-buffer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/int64-buffer/-/int64-buffer-1.1.0.tgz", + "integrity": "sha512-94smTCQOvigN4d/2R/YDjz8YVG0Sufvv2aAh8P5m42gwhCsDAJqnbNOrxJsrADuAFAA69Q/ptGzxvNcNuIJcvw==" + }, "into-stream": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/into-stream/-/into-stream-6.0.0.tgz", @@ -12167,6 +13234,11 @@ "p-is-promise": "^3.0.0" } }, + "ip": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ip/-/ip-2.0.1.tgz", + "integrity": "sha512-lJUL9imLTNi1ZfXT+DU6rBBdbiKGBuay9B6xGSPVjUeQwaH1RIGqef8RZkUtHioLmSNpPR5M4HVKJGm1j8FWVQ==" + }, "ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -12220,6 +13292,19 @@ "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", "dev": true }, + "is-plain-object": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz", + "integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==", + "requires": { + "isobject": "^3.0.1" + } + }, + "is-property": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz", + "integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==" + }, "is-stream": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", @@ -12244,6 +13329,11 @@ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "dev": true }, + "isobject": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", + "integrity": "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==" + }, "isstream": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz", @@ -13298,8 +14388,7 @@ "lodash": { "version": "4.17.21", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", - "dev": true + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" }, "lodash.get": { "version": "4.4.2", @@ -13510,6 +14599,11 @@ } } }, + "mp4box": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/mp4box/-/mp4box-0.5.4.tgz", + "integrity": "sha512-GcCH0fySxBurJtvr0dfhz0IxHZjc1RP+F+I8xw+LIwkU1a+7HJx8NCDiww1I5u4Hz6g4eR1JlGADEGJ9r4lSfA==" + }, "ms": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", @@ -13526,6 +14620,15 @@ "type-is": "^1.6.18" } }, + "multicast-dns": { + "version": "7.2.5", + "resolved": "https://registry.npmjs.org/multicast-dns/-/multicast-dns-7.2.5.tgz", + "integrity": "sha512-2eznPJP8z2BFLX50tf0LuODrpINqP1RVIm/CObbTcBRITQgmC/TjcREF1NeTBzIcR5XO/ukWo+YHOjBbFwIupg==", + "requires": { + "dns-packet": "^5.2.2", + "thunky": "^1.0.2" + } + }, "multistream": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/multistream/-/multistream-4.1.0.tgz", @@ -13750,6 +14853,11 @@ "word-wrap": "^1.2.3" } }, + "p-cancelable": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-2.1.1.tgz", + "integrity": "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==" + }, "p-is-promise": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/p-is-promise/-/p-is-promise-3.0.0.tgz", @@ -14277,6 +15385,26 @@ "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==", "dev": true }, + "pvtsutils": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz", + "integrity": "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==", + "requires": { + "tslib": "^2.8.1" + }, + "dependencies": { + "tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" + } + } + }, + "pvutils": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.1.5.tgz", + "integrity": "sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA==" + }, "qs": { "version": "6.10.3", "resolved": "https://registry.npmjs.org/qs/-/qs-6.10.3.tgz", @@ -14354,6 +15482,11 @@ "util-deprecate": "~1.0.1" } }, + "reflect-metadata": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", + "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==" + }, "regexpp": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/regexpp/-/regexpp-3.2.0.tgz", @@ -14436,6 +15569,11 @@ "queue-microtask": "^1.2.2" } }, + "rx.mini": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/rx.mini/-/rx.mini-1.4.0.tgz", + "integrity": "sha512-8w5cSc1mwNja7fl465DXOkVvIOkpvh2GW4jo31nAIvX4WTXCsRnKJGUfiDBzWtYRInEcHAUYIZfzusjIrea8gA==" + }, "safe-buffer": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", @@ -14905,6 +16043,11 @@ "integrity": "sha1-f17oI66AUgfACvLfSoTsP8+lcLQ=", "dev": true }, + "thunky": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/thunky/-/thunky-1.1.0.tgz", + "integrity": "sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA==" + }, "tmpl": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", @@ -14977,8 +16120,7 @@ "tslib": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", - "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", - "dev": true + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==" }, "tsutils": { "version": "3.21.0", @@ -14989,6 +16131,14 @@ "tslib": "^1.8.1" } }, + "tsyringe": { + "version": "4.10.0", + "resolved": "https://registry.npmjs.org/tsyringe/-/tsyringe-4.10.0.tgz", + "integrity": "sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==", + "requires": { + "tslib": "^1.9.3" + } + }, "tunnel-agent": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", @@ -15173,6 +16323,155 @@ "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", "dev": true }, + "werift": { + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/werift/-/werift-0.23.0.tgz", + "integrity": "sha512-/WcIN5DHFG9Ri4anGOmIkp8gxBGFMWSIB/m4sfZ5CWlLfD3iMhiaAUuTBuc+KV3SY9NDmvmLtiN2uaM7k3lVzw==", + "requires": { + "@fidm/x509": "^1.2.1", + "@minhducsun2002/leb128": "^1.0.0", + "@noble/curves": "^1.8.1", + "@peculiar/x509": "^1.12.3", + "@shinyoshiaki/binary-data": "^0.6.1", + "@shinyoshiaki/jspack": "^0.0.6", + "aes-js": "^3.1.2", + "buffer": "^6.0.3", + "debug": "4.4.0", + "fast-deep-equal": "^3.1.3", + "int64-buffer": "1.1.0", + "ip": "^2.0.1", + "mp4box": "^0.5.3", + "multicast-dns": "^7.2.5", + "tweetnacl": "^1.0.3", + "werift-common": "*", + "werift-dtls": "*", + "werift-ice": "*", + "werift-rtp": "*", + "werift-sctp": "*" + }, + "dependencies": { + "buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "requires": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "debug": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", + "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", + "requires": { + "ms": "^2.1.3" + } + }, + "ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + }, + "tweetnacl": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-1.0.3.tgz", + "integrity": "sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==" + } + } + }, + "werift-common": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/werift-common/-/werift-common-0.0.3.tgz", + "integrity": "sha512-ma3E4BqKTyZVLhrdfTVs2T1tg9seeUtKMRn5e64LwgrogWa62+3LAUoLBUSl1yPWhgSkXId7GmcHuWDen9IJeQ==", + "requires": { + "@shinyoshiaki/jspack": "^0.0.6", + "debug": "^4.4.0" + }, + "dependencies": { + "debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "requires": { + "ms": "^2.1.3" + } + }, + "ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + } + } + }, + "werift-dtls": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/werift-dtls/-/werift-dtls-0.5.7.tgz", + "integrity": "sha512-z2fjbP7fFUFmu/Ky4bCKXzdgPTtmSY1DYi0TUf3GG2zJT4jMQ3TQmGY8y7BSSNGetvL4h3pRZ5un0EcSOWpPog==", + "requires": { + "@fidm/x509": "^1.2.1", + "@noble/curves": "^1.3.0", + "@peculiar/x509": "^1.9.2", + "@shinyoshiaki/binary-data": "^0.6.1", + "date-fns": "^2.29.3", + "lodash": "^4.17.21", + "rx.mini": "^1.2.2", + "tweetnacl": "^1.0.3" + }, + "dependencies": { + "tweetnacl": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-1.0.3.tgz", + "integrity": "sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==" + } + } + }, + "werift-ice": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/werift-ice/-/werift-ice-0.2.2.tgz", + "integrity": "sha512-td52pHp+JmFnUn5jfDr/SSNO0dMCbknhuPdN1tFp9cfRj5jaktN63qnAdUuZC20QCC3ETWdsOthcm+RalHpFCQ==", + "requires": { + "@shinyoshiaki/jspack": "^0.0.6", + "buffer-crc32": "^1.0.0", + "debug": "^4.3.4", + "int64-buffer": "^1.0.1", + "ip": "^2.0.1", + "lodash": "^4.17.21", + "multicast-dns": "^7.2.5", + "p-cancelable": "^2.1.1", + "rx.mini": "^1.2.2" + } + }, + "werift-rtp": { + "version": "0.8.8", + "resolved": "https://registry.npmjs.org/werift-rtp/-/werift-rtp-0.8.8.tgz", + "integrity": "sha512-GiYMSdvCyScQaw5bnEsraSoHUVZpjfokJAiLV4R1FsiB06t6XiebPYPpkqB9nYNNKiA8Z/cYWsym7wISq1sYSQ==", + "requires": { + "@minhducsun2002/leb128": "^1.0.0", + "@shinyoshiaki/jspack": "^0.0.6", + "aes-js": "^3.1.2", + "buffer": "^6.0.3", + "mp4box": "^0.5.3" + }, + "dependencies": { + "buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "requires": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + } + } + }, + "werift-sctp": { + "version": "0.0.11", + "resolved": "https://registry.npmjs.org/werift-sctp/-/werift-sctp-0.0.11.tgz", + "integrity": "sha512-7109yuI5U7NTEHjqjn0A8VeynytkgVaxM6lRr1Ziv0D8bPcaB8A7U/P88M7WaCpWDoELHoXiRUjQycMWStIgjQ==", + "requires": { + "@shinyoshiaki/jspack": "^0.0.6" + } + }, "whatwg-url": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", diff --git a/package.json b/package.json index 68524d8..c6505d4 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "swagger-jsdoc": "^6.2.1", "swagger-ui-express": "^4.5.0", "uuid": "^9.0.0", + "werift": "^0.23.0", "ws": "^8.8.1" }, "devDependencies": { diff --git a/src/class/websockethandler.ts b/src/class/websockethandler.ts index b40f54b..02a90ee 100644 --- a/src/class/websockethandler.ts +++ b/src/class/websockethandler.ts @@ -6,6 +6,10 @@ import Offer from './offer'; import Answer from './answer'; import Candidate from './candidate'; import { log, LogLevel } from '../log'; +import { RecordingSession, listRecordingSessions, stopRecordingSession } from '../recording/session-manager'; +import { registerRecordingPeerCandidate, registerRecordingPeerOffer, stopRecordingAgent } from '../recording/agent'; +import { startRecordingCompositionJob } from '../recording/composer'; +import { acceptRecordingOffer, addRecordingIceCandidate, stopRecordingPeer } from '../recording/werift-adapter'; /** * 是否为私有模式 @@ -68,6 +72,18 @@ interface RoomSnapshot { userCount: number; } +type RecordingBroadcastPayload = { + type: 'recording-started' | 'recording-stopped' | 'recording-status' | 'recording-peer-request'; + connectionId: string; + recordingId: string; + status: string; + layout?: string; + format?: string; + startedAt?: string; + stoppedAt?: string; + mediaMode?: string; +}; + interface StoredRoom { roomId: string; connectionId: string; @@ -317,6 +333,58 @@ function broadcastToGroup(connectionId: string, senderWs: WebSocket, message: an } } +function sendToEntireGroup(connectionId: string, message: any): boolean { + const group = connectionGroup.get(connectionId); + if (!group) { + return false; + } + + safeSend(group.host, message); + group.participants.forEach(participantWs => { + safeSend(participantWs, message); + }); + return true; +} + +function getActiveRecordingSessions(connectionId: string): RecordingSession[] { + return listRecordingSessions(connectionId).filter((session) => session.status === 'recording'); +} + +function stopRecordingPeersForSocket(ws: WebSocket, connectionId: string): void { + const participantId = getParticipantId(ws); + if (!participantId) { + return; + } + + getActiveRecordingSessions(connectionId).forEach((session) => { + stopRecordingPeer(session.id, participantId).catch((error) => { + log(LogLevel.warn, 'Failed to stop participant recording peer:', error); + }); + }); +} + +function stopActiveRecordingSessions(connectionId: string): void { + getActiveRecordingSessions(connectionId).forEach((session) => { + const stoppedSession = stopRecordingSession(session.id); + if (stoppedSession) { + broadcastRecordingStopped(stoppedSession); + stopRecordingAgent(stoppedSession.id); + } + stopRecordingPeer(session.id) + .then(() => { + startRecordingCompositionJob({ + meetingId: session.connectionId, + recordingId: session.id, + layout: session.layout, + format: session.format + }); + }) + .catch((error) => { + log(LogLevel.warn, 'Failed to stop room recording peers:', error); + }); + }); +} + /** * 移除WebSocket连接 * @param ws WebSocket连接实例 @@ -329,12 +397,14 @@ function remove(ws: WebSocket): void { const group = connectionGroup.get(connectionId); if (group) { if (group.host === ws) { + stopActiveRecordingSessions(connectionId); group.participants.forEach(participantWs => { safeSend(participantWs, { type: "disconnect", connectionId: connectionId, reason: "host-left" }); }); rooms.delete(connectionId); connectionGroup.delete(connectionId); } else { + stopRecordingPeersForSocket(ws, connectionId); group.participants.delete(ws); removeRoomMember(ws, connectionId); // 包含participantId,让host能识别是哪个participant离开 @@ -379,6 +449,7 @@ function onConnect(ws: WebSocket, connectionId: string): void { const role = polite ? 'participant' : 'host'; saveRoomMember(ws, connectionId); safeSend(ws, { type: "connect", connectionId: connectionId, polite: polite, role: role, participantId: participantId }); + sendActiveRecordingRequests(ws, connectionId); } /** @@ -399,6 +470,7 @@ function onDisconnect(ws: WebSocket, connectionId: string): void { if (group) { if (group.host === ws) { // host断开连接,通知所有participants房间已关闭,并删除连接组 + stopActiveRecordingSessions(connectionId); group.participants.forEach(participantWs => { safeSend(participantWs, { type: "disconnect", connectionId: connectionId, reason: "host-left" }); }); @@ -407,6 +479,7 @@ function onDisconnect(ws: WebSocket, connectionId: string): void { log(LogLevel.log, `Host disconnected, room ${connectionId} deleted, notified ${group.participants.size} participants`); } else { // participant断开连接,从组中移除并通知host(使用participant-left类型,host不会关闭房间) + stopRecordingPeersForSocket(ws, connectionId); group.participants.delete(ws); removeRoomMember(ws, connectionId); safeSend(group.host, { type: "participant-left", connectionId: connectionId, participantId: getParticipantId(ws) }); @@ -677,6 +750,164 @@ function onBroadcast(ws: WebSocket, message: any): void { } } +function toRecordingBroadcastPayload(type: RecordingBroadcastPayload['type'], session: RecordingSession): RecordingBroadcastPayload { + return { + type, + connectionId: session.connectionId, + recordingId: session.id, + status: session.status, + layout: session.layout, + format: session.format, + startedAt: session.startedAt, + stoppedAt: session.stoppedAt + }; +} + +function broadcastRecordingStarted(session: RecordingSession): boolean { + return sendToEntireGroup( + session.connectionId, + toRecordingBroadcastPayload('recording-started', session) + ); +} + +function broadcastRecordingPeerRequest(session: RecordingSession): boolean { + const payload = toRecordingBroadcastPayload('recording-peer-request', session); + payload.mediaMode = 'webrtc-sendonly'; + return sendToEntireGroup(session.connectionId, payload); +} + +function broadcastRecordingStopped(session: RecordingSession): boolean { + return sendToEntireGroup( + session.connectionId, + toRecordingBroadcastPayload('recording-stopped', session) + ); +} + +function sendActiveRecordingRequests(ws: WebSocket, connectionId: string): void { + const activeSessions = getActiveRecordingSessions(connectionId); + activeSessions.forEach((session) => { + safeSend(ws, toRecordingBroadcastPayload('recording-started', session)); + safeSend(ws, { + ...toRecordingBroadcastPayload('recording-peer-request', session), + mediaMode: 'webrtc-sendonly' + }); + }); +} + +async function onRecordingOffer(ws: WebSocket, message: any): Promise { + const recordingId = typeof message.recordingId === 'string' ? message.recordingId : ''; + const connectionId = typeof message.connectionId === 'string' ? message.connectionId : ''; + const sdp = typeof message.sdp === 'string' ? message.sdp : ''; + if (!recordingId || !connectionId || !sdp) { + safeSend(ws, { type: 'recording-status', recordingId, connectionId, status: 'invalid-offer' }); + return; + } + + const offer = registerRecordingPeerOffer({ + recordingId, + connectionId, + sdp, + participantId: getParticipantId(ws) || 'unknown' + }); + + if (!offer) { + safeSend(ws, { + type: 'recording-status', + recordingId, + connectionId, + status: 'recorder-unavailable', + participantId: getParticipantId(ws) + }); + return; + } + + try { + const participantId = getParticipantId(ws) || 'unknown'; + const role = getSocketRoleInRoom(ws, connectionId); + const answerSdp = await acceptRecordingOffer({ + recordingId, + connectionId, + sdp, + participantId, + role, + onLocalCandidate: (candidate) => { + const json = typeof candidate.toJSON === 'function' ? candidate.toJSON() : candidate; + safeSend(ws, { + type: 'recording-candidate', + recordingId, + connectionId, + participantId, + candidate: json.candidate, + sdpMid: json.sdpMid, + sdpMLineIndex: json.sdpMLineIndex + }); + } + }); + + safeSend(ws, { + type: 'recording-answer', + recordingId, + connectionId, + participantId, + sdp: answerSdp + }); + } catch (error) { + log(LogLevel.error, 'Failed to accept recording offer:', error); + safeSend(ws, { + type: 'recording-status', + recordingId, + connectionId, + status: 'offer-failed', + participantId: getParticipantId(ws) + }); + return; + } + + safeSend(ws, { + type: 'recording-status', + recordingId, + connectionId, + status: 'offer-received', + participantId: getParticipantId(ws) + }); +} + +async function onRecordingCandidate(ws: WebSocket, message: any): Promise { + const recordingId = typeof message.recordingId === 'string' ? message.recordingId : ''; + const connectionId = typeof message.connectionId === 'string' ? message.connectionId : ''; + const candidateText = typeof message.candidate === 'string' ? message.candidate : ''; + if (!recordingId || !connectionId || !candidateText) { + return; + } + + const candidate = registerRecordingPeerCandidate({ + recordingId, + connectionId, + candidate: candidateText, + participantId: getParticipantId(ws) || message.participantId || 'unknown', + sdpMid: typeof message.sdpMid === 'string' ? message.sdpMid : undefined, + sdpMLineIndex: typeof message.sdpMLineIndex === 'number' ? message.sdpMLineIndex : undefined + }); + + if (!candidate) { + safeSend(ws, { type: 'recording-status', recordingId, connectionId, status: 'candidate-rejected' }); + return; + } + + try { + await addRecordingIceCandidate({ + recordingId, + participantId: candidate.participantId, + candidate: candidate.candidate, + sdpMid: candidate.sdpMid, + sdpMLineIndex: candidate.sdpMLineIndex + }); + } catch (error) { + log(LogLevel.warn, 'Failed to add recording ICE candidate:', error); + safeSend(ws, { type: 'recording-status', recordingId, connectionId, status: 'candidate-rejected' }); + } +} + function AddHeartbeat(ws: WebSocket, connectionId: string) { // 初始化心跳检测 asAppWebSocket(ws).lastActivity = Date.now(); @@ -833,4 +1064,5 @@ function onMessage(ws: WebSocket, message: any): void { */ export { reset, add, remove, onConnect, onDisconnect, onOffer, onAnswer, onCandidate, onCallConnectionId, onBroadcast, onGetAllConnectionIds, onGetOnlineUsers, onGetRooms, AddHeartbeat, RemoveHeartbeat, onMessage, isHost, - broadcastToGroup, connectionGroup, onHostUserInfo, onInviteCall }; + broadcastToGroup, broadcastRecordingStarted, broadcastRecordingPeerRequest, broadcastRecordingStopped, connectionGroup, + onHostUserInfo, onInviteCall, onRecordingCandidate, onRecordingOffer }; diff --git a/src/recording/agent.ts b/src/recording/agent.ts new file mode 100644 index 0000000..e44e5f7 --- /dev/null +++ b/src/recording/agent.ts @@ -0,0 +1,226 @@ +import { RecordingSession } from './session-manager'; + +export type RecordingAgentStatus = 'awaiting-media-adapter' | 'negotiating' | 'receiving-media' | 'stopped'; + +export type RecordingPeerOffer = { + recordingId: string; + connectionId: string; + participantId: string; + sdp: string; + receivedAt: string; +}; + +export type RecordingPeerCandidate = { + recordingId: string; + connectionId: string; + participantId: string; + candidate: string; + sdpMid?: string; + sdpMLineIndex?: number; + receivedAt: string; +}; + +export type RecordingPeerAnswer = { + recordingId: string; + connectionId: string; + participantId: string; + sdp: string; + createdAt: string; +}; + +export type RecordingPeerTrack = { + recordingId: string; + connectionId: string; + participantId: string; + kind: string; + trackId: string; + receivedAt: string; + rtpPackets: number; +}; + +export type RecordingAgent = { + id: string; + recordingId: string; + connectionId: string; + status: RecordingAgentStatus; + mediaMode: 'webrtc-sendonly'; + createdAt: string; + updatedAt: string; + stoppedAt?: string; + peerOffers: Map; + peerAnswers: Map; + peerCandidates: Map; + peerTracks: Map; +}; + +const agents: Map = new Map(); + +function nowIso(): string { + return new Date().toISOString(); +} + +export function startRecordingAgent(session: RecordingSession): RecordingAgent { + const timestamp = nowIso(); + const agent: RecordingAgent = { + id: `recorder_${session.id}`, + recordingId: session.id, + connectionId: session.connectionId, + status: 'awaiting-media-adapter', + mediaMode: 'webrtc-sendonly', + createdAt: timestamp, + updatedAt: timestamp, + peerOffers: new Map(), + peerAnswers: new Map(), + peerCandidates: new Map(), + peerTracks: new Map() + }; + + agents.set(session.id, agent); + return agent; +} + +export function getRecordingAgent(recordingId: string): RecordingAgent | null { + return agents.get(recordingId) || null; +} + +export function stopRecordingAgent(recordingId: string): RecordingAgent | null { + const agent = agents.get(recordingId); + if (!agent) { + return null; + } + + const timestamp = nowIso(); + agent.status = 'stopped'; + agent.updatedAt = timestamp; + agent.stoppedAt = timestamp; + return agent; +} + +export function registerRecordingPeerOffer(input: { + recordingId: string; + connectionId: string; + participantId: string; + sdp: string; +}): RecordingPeerOffer | null { + const agent = agents.get(input.recordingId); + if (!agent || agent.connectionId !== input.connectionId || agent.status === 'stopped') { + return null; + } + + const offer: RecordingPeerOffer = { + recordingId: input.recordingId, + connectionId: input.connectionId, + participantId: input.participantId, + sdp: input.sdp, + receivedAt: nowIso() + }; + agent.peerOffers.set(input.participantId, offer); + agent.status = 'negotiating'; + agent.updatedAt = offer.receivedAt; + return offer; +} + +export function registerRecordingPeerAnswer(input: { + recordingId: string; + connectionId: string; + participantId: string; + sdp: string; +}): RecordingPeerAnswer | null { + const agent = agents.get(input.recordingId); + if (!agent || agent.connectionId !== input.connectionId || agent.status === 'stopped') { + return null; + } + + const answer: RecordingPeerAnswer = { + recordingId: input.recordingId, + connectionId: input.connectionId, + participantId: input.participantId, + sdp: input.sdp, + createdAt: nowIso() + }; + agent.peerAnswers.set(input.participantId, answer); + agent.updatedAt = answer.createdAt; + return answer; +} + +export function registerRecordingPeerCandidate(input: { + recordingId: string; + connectionId: string; + participantId: string; + candidate: string; + sdpMid?: string; + sdpMLineIndex?: number; +}): RecordingPeerCandidate | null { + const agent = agents.get(input.recordingId); + if (!agent || agent.connectionId !== input.connectionId || agent.status === 'stopped') { + return null; + } + + const candidate: RecordingPeerCandidate = { + recordingId: input.recordingId, + connectionId: input.connectionId, + participantId: input.participantId, + candidate: input.candidate, + sdpMid: input.sdpMid, + sdpMLineIndex: input.sdpMLineIndex, + receivedAt: nowIso() + }; + const participantCandidates = agent.peerCandidates.get(input.participantId) || []; + participantCandidates.push(candidate); + agent.peerCandidates.set(input.participantId, participantCandidates); + agent.updatedAt = candidate.receivedAt; + return candidate; +} + +export function registerRecordingPeerTrack(input: { + recordingId: string; + connectionId: string; + participantId: string; + kind: string; + trackId: string; +}): RecordingPeerTrack | null { + const agent = agents.get(input.recordingId); + if (!agent || agent.connectionId !== input.connectionId || agent.status === 'stopped') { + return null; + } + + const track: RecordingPeerTrack = { + recordingId: input.recordingId, + connectionId: input.connectionId, + participantId: input.participantId, + kind: input.kind, + trackId: input.trackId, + receivedAt: nowIso(), + rtpPackets: 0 + }; + const participantTracks = agent.peerTracks.get(input.participantId) || []; + participantTracks.push(track); + agent.peerTracks.set(input.participantId, participantTracks); + agent.status = 'receiving-media'; + agent.updatedAt = track.receivedAt; + return track; +} + +export function incrementRecordingTrackPackets(input: { + recordingId: string; + participantId: string; + trackId: string; +}): void { + const agent = agents.get(input.recordingId); + if (!agent || agent.status === 'stopped') { + return; + } + + const participantTracks = agent.peerTracks.get(input.participantId) || []; + const track = participantTracks.find((item) => item.trackId === input.trackId); + if (!track) { + return; + } + + track.rtpPackets += 1; + agent.updatedAt = nowIso(); +} + +export function resetRecordingAgents(): void { + agents.clear(); +} diff --git a/src/recording/composer.ts b/src/recording/composer.ts new file mode 100644 index 0000000..c28ddff --- /dev/null +++ b/src/recording/composer.ts @@ -0,0 +1,324 @@ +import { spawn } from 'child_process'; +import { v4 as uuid } from 'uuid'; +import { + ServerTrackRecordingFile, + ServerTrackRecordingTarget, + createComposedRecordingTarget, + deleteServerTrackRecordingFiles, + listServerTrackRecordingFiles, + writeComposedRecordingMetadata +} from './storage'; + +export type RecordingCompositionStatus = 'queued' | 'running' | 'completed' | 'failed'; + +export type RecordingCompositionJob = { + id: string; + recordingId: string; + meetingId: string; + status: RecordingCompositionStatus; + layout: string; + format: string; + createdAt: string; + updatedAt: string; + startedAt?: string; + completedAt?: string; + failedAt?: string; + error?: string; + inputFiles: string[]; + deletedInputFiles?: string[]; + output?: { + meetingId: string; + filename: string; + filePath: string; + metadataPath: string; + downloadUrl: string; + streamUrl: string; + }; +}; + +type StartCompositionInput = { + meetingId: string; + recordingId: string; + layout?: string; + format?: string; +}; + +type CompositionInputSets = { + videoInputs: ServerTrackRecordingFile[]; + audioInputs: ServerTrackRecordingFile[]; +}; + +const jobs: Map = new Map(); + +function nowIso(): string { + return new Date().toISOString(); +} + +function normalizeOption(value: unknown, fallback: string): string { + if (typeof value !== 'string') { + return fallback; + } + const trimmed = value.trim(); + return trimmed ? trimmed.slice(0, 40) : fallback; +} + +function normalizeFormat(value: unknown): string { + return normalizeOption(value, 'webm') === 'mp4' ? 'mp4' : 'webm'; +} + +function getFfmpegPath(): string { + return process.env.FFMPEG_PATH || 'ffmpeg'; +} + +function sortInputs(files: ServerTrackRecordingFile[]): ServerTrackRecordingFile[] { + return files.slice().sort((a, b) => { + const participantCompare = a.participantId.localeCompare(b.participantId); + if (participantCompare !== 0) { + return participantCompare; + } + return Date.parse(a.uploadedAt) - Date.parse(b.uploadedAt); + }); +} + +function getInputSets(input: StartCompositionInput): CompositionInputSets { + const files = listServerTrackRecordingFiles({ + meetingId: input.meetingId, + recordingId: input.recordingId + }); + return { + videoInputs: sortInputs(files.filter((file) => file.trackKind === 'video')), + audioInputs: sortInputs(files.filter((file) => file.trackKind === 'audio')) + }; +} + +function isHostInput(file: ServerTrackRecordingFile): boolean { + if (file.metadata && file.metadata.role === 'host') { + return true; + } + + const firstParticipant = file.metadata && Array.isArray(file.metadata.participants) + ? file.metadata.participants[0] + : null; + return Boolean(firstParticipant && firstParticipant.role === 'host'); +} + +function orderVideoInputsForComposition(files: ServerTrackRecordingFile[]): ServerTrackRecordingFile[] { + const hostIndex = files.findIndex(isHostInput); + if (hostIndex <= 0) { + return files.slice(); + } + + return [ + files[hostIndex], + ...files.slice(0, hostIndex), + ...files.slice(hostIndex + 1) + ]; +} + +function getBottomTileWidth(index: number, inputCount: number, outputWidth: number): number { + const sideCount = inputCount - 1; + if (sideCount <= 1) { + return outputWidth; + } + + const rawWidth = Math.floor(outputWidth / sideCount); + const tileWidth = rawWidth % 2 === 0 ? rawWidth : rawWidth - 1; + return index === sideCount - 1 ? outputWidth - (tileWidth * index) : tileWidth; +} + +function createHostBottomLayout(inputCount: number, outputWidth: number, hostHeight: number): string { + const positions = ['0_0']; + const sideCount = inputCount - 1; + let x = 0; + for (let sideIndex = 0; sideIndex < sideCount; sideIndex += 1) { + positions.push(`${x}_${hostHeight}`); + x += getBottomTileWidth(sideIndex, inputCount, outputWidth); + } + return positions.join('|'); +} + +export function buildFfmpegCompositionArgs(input: { + videoInputs: ServerTrackRecordingFile[]; + audioInputs: ServerTrackRecordingFile[]; + outputPath: string; + format: string; +}): string[] { + const outputWidth = 1280; + const outputHeight = 720; + const hostHeight = 540; + const bottomHeight = outputHeight - hostHeight; + const videoInputs = orderVideoInputsForComposition(input.videoInputs); + const args = ['-y']; + const orderedInputs = videoInputs.concat(input.audioInputs); + orderedInputs.forEach((file) => { + args.push('-i', file.filePath); + }); + + const filters: string[] = []; + videoInputs.forEach((_file, index) => { + const width = videoInputs.length === 1 + ? outputWidth + : index === 0 ? outputWidth : getBottomTileWidth(index - 1, videoInputs.length, outputWidth); + const height = videoInputs.length === 1 + ? outputHeight + : index === 0 ? hostHeight : bottomHeight; + filters.push(`[${index}:v]scale=${width}:${height}:force_original_aspect_ratio=decrease,pad=${width}:${height}:(ow-iw)/2:(oh-ih)/2:black,setsar=1[v${index}]`); + }); + + if (videoInputs.length === 1) { + filters.push('[v0]fps=30,format=yuv420p[vout]'); + } else { + const videoLabels = videoInputs.map((_file, index) => `[v${index}]`).join(''); + filters.push(`${videoLabels}xstack=inputs=${videoInputs.length}:layout=${createHostBottomLayout(videoInputs.length, outputWidth, hostHeight)}:fill=black,fps=30,format=yuv420p[vout]`); + } + + if (input.audioInputs.length === 1) { + const audioInputIndex = videoInputs.length; + filters.push(`[${audioInputIndex}:a]aresample=async=1:first_pts=0[aout]`); + } else if (input.audioInputs.length > 1) { + const audioLabels = input.audioInputs + .map((_file, index) => `[${videoInputs.length + index}:a]`) + .join(''); + filters.push(`${audioLabels}amix=inputs=${input.audioInputs.length}:duration=longest:dropout_transition=2[aout]`); + } + + args.push('-filter_complex', filters.join(';'), '-map', '[vout]'); + if (input.audioInputs.length > 0) { + args.push('-map', '[aout]'); + } + + if (input.format === 'mp4') { + args.push('-c:v', 'libx264', '-preset', 'veryfast', '-pix_fmt', 'yuv420p'); + if (input.audioInputs.length > 0) { + args.push('-c:a', 'aac'); + } + } else { + args.push('-c:v', 'libvpx-vp9', '-deadline', 'realtime', '-cpu-used', '4'); + if (input.audioInputs.length > 0) { + args.push('-c:a', 'libopus'); + } + } + + args.push('-shortest', input.outputPath); + return args; +} + +function runFfmpeg(args: string[]): Promise { + return new Promise((resolve, reject) => { + const child = spawn(getFfmpegPath(), args, { windowsHide: true }); + let stderr = ''; + child.stderr.on('data', (chunk) => { + stderr += chunk.toString(); + }); + child.on('error', (error: any) => { + if (error && error.code === 'ENOENT') { + reject(new Error('ffmpeg was not found. Install ffmpeg or set FFMPEG_PATH.')); + return; + } + reject(error); + }); + child.on('close', (code) => { + if (code === 0) { + resolve(); + return; + } + reject(new Error(stderr || `ffmpeg exited with code ${code}`)); + }); + }); +} + +function toOutput(job: RecordingCompositionJob, target: ServerTrackRecordingTarget): RecordingCompositionJob['output'] { + return { + meetingId: target.meetingId, + filename: target.filename, + filePath: target.filePath, + metadataPath: target.metadataPath, + downloadUrl: `/api/recordings/${encodeURIComponent(target.meetingId)}/${encodeURIComponent(target.filename)}/download`, + streamUrl: `/api/recordings/${encodeURIComponent(target.meetingId)}/${encodeURIComponent(target.filename)}/stream` + }; +} + +async function runRecordingCompositionJob(job: RecordingCompositionJob): Promise { + const timestamp = nowIso(); + job.status = 'running'; + job.startedAt = timestamp; + job.updatedAt = timestamp; + + try { + const inputSets = getInputSets(job); + if (inputSets.videoInputs.length === 0) { + throw new Error('No server-side video track files are available for composition.'); + } + + const target = createComposedRecordingTarget({ + meetingId: job.meetingId, + recordingId: job.recordingId, + format: job.format + }); + const compositionInputs = inputSets.videoInputs.concat(inputSets.audioInputs); + const args = buildFfmpegCompositionArgs({ + ...inputSets, + outputPath: target.filePath, + format: job.format + }); + await runFfmpeg(args); + writeComposedRecordingMetadata({ + target, + recordingId: job.recordingId, + inputs: compositionInputs, + layout: job.layout, + format: job.format + }); + const deletedInputFiles = deleteServerTrackRecordingFiles(compositionInputs); + + const completedAt = nowIso(); + job.status = 'completed'; + job.completedAt = completedAt; + job.updatedAt = completedAt; + job.inputFiles = compositionInputs.map((file) => file.filename); + job.deletedInputFiles = deletedInputFiles; + job.output = toOutput(job, target); + } catch (error) { + const failedAt = nowIso(); + job.status = 'failed'; + job.failedAt = failedAt; + job.updatedAt = failedAt; + job.error = error instanceof Error ? error.message : String(error); + } + + return job; +} + +export function startRecordingCompositionJob(input: StartCompositionInput): RecordingCompositionJob { + const timestamp = nowIso(); + const inputSets = getInputSets(input); + const job: RecordingCompositionJob = { + id: uuid(), + recordingId: normalizeOption(input.recordingId, ''), + meetingId: normalizeOption(input.meetingId, ''), + status: 'queued', + layout: normalizeOption(input.layout, 'grid'), + format: normalizeFormat(input.format), + createdAt: timestamp, + updatedAt: timestamp, + inputFiles: inputSets.videoInputs.concat(inputSets.audioInputs).map((file) => file.filename) + }; + jobs.set(job.id, job); + runRecordingCompositionJob(job); + return job; +} + +export function getRecordingCompositionJob(jobId: string): RecordingCompositionJob | null { + return jobs.get(jobId) || null; +} + +export function listRecordingCompositionJobs(meetingId?: string): RecordingCompositionJob[] { + const allJobs = Array.from(jobs.values()); + return meetingId + ? allJobs.filter((job) => job.meetingId === meetingId) + : allJobs; +} + +export function resetRecordingCompositionJobs(): void { + jobs.clear(); +} diff --git a/src/recording/session-manager.ts b/src/recording/session-manager.ts new file mode 100644 index 0000000..627ef96 --- /dev/null +++ b/src/recording/session-manager.ts @@ -0,0 +1,92 @@ +import { v4 as uuid } from 'uuid'; + +export type RecordingSessionStatus = 'recording' | 'stopped' | 'failed'; + +export type RecordingSession = { + id: string; + connectionId: string; + status: RecordingSessionStatus; + layout: string; + format: string; + createdAt: string; + startedAt: string; + updatedAt: string; + stoppedAt?: string; + error?: string; +}; + +export type StartRecordingSessionInput = { + connectionId: string; + layout?: string; + format?: string; +}; + +const sessions: Map = new Map(); + +function nowIso(): string { + return new Date().toISOString(); +} + +function normalizeOption(value: unknown, fallback: string): string { + if (typeof value !== 'string') { + return fallback; + } + + const trimmed = value.trim(); + return trimmed ? trimmed.slice(0, 40) : fallback; +} + +export function startRecordingSession(input: StartRecordingSessionInput): RecordingSession { + const connectionId = normalizeOption(input.connectionId, ''); + if (!connectionId) { + throw new Error('connectionId is required'); + } + + const timestamp = nowIso(); + const session: RecordingSession = { + id: uuid(), + connectionId, + status: 'recording', + layout: normalizeOption(input.layout, 'grid'), + format: normalizeOption(input.format, 'webm'), + createdAt: timestamp, + startedAt: timestamp, + updatedAt: timestamp + }; + + sessions.set(session.id, session); + return session; +} + +export function stopRecordingSession(recordingId: string): RecordingSession | null { + const session = sessions.get(recordingId); + if (!session) { + return null; + } + + const timestamp = nowIso(); + const nextSession: RecordingSession = { + ...session, + status: 'stopped', + stoppedAt: timestamp, + updatedAt: timestamp + }; + + sessions.set(recordingId, nextSession); + return nextSession; +} + +export function getRecordingSession(recordingId: string): RecordingSession | null { + return sessions.get(recordingId) || null; +} + +export function listRecordingSessions(connectionId?: string): RecordingSession[] { + const allSessions = Array.from(sessions.values()); + return connectionId + ? allSessions.filter((session) => session.connectionId === connectionId) + : allSessions; +} + +export function resetRecordingSessions(): void { + sessions.clear(); +} diff --git a/src/recording/storage.ts b/src/recording/storage.ts new file mode 100644 index 0000000..8812673 --- /dev/null +++ b/src/recording/storage.ts @@ -0,0 +1,257 @@ +import * as fs from 'fs'; +import * as path from 'path'; + +export type ServerTrackRecordingTarget = { + meetingId: string; + directory: string; + filename: string; + filePath: string; + metadataPath: string; +}; + +export type ServerTrackRecordingFile = ServerTrackRecordingTarget & { + recordingId: string; + participantId: string; + trackId: string; + trackKind: string; + uploadedAt: string; + metadata: any; +}; + +type CreateTargetInput = { + recordingId: string; + connectionId: string; + participantId: string; + role?: string; + kind: string; + trackId: string; +}; + +type CreateComposedTargetInput = { + recordingId: string; + meetingId: string; + format?: string; +}; + +type WriteMetadataInput = CreateTargetInput & { + target: ServerTrackRecordingTarget; +}; + +type WriteComposedMetadataInput = { + target: ServerTrackRecordingTarget; + recordingId: string; + inputs: ServerTrackRecordingFile[]; + layout: string; + format: string; +}; + +export function getRecordingRoot(): string { + return path.resolve(process.env.RECORDING_DIR || path.join(process.cwd(), 'recordings')); +} + +export function sanitizeRecordingPathSegment(value: string | undefined, fallback: string): string { + const sanitized = (value || fallback) + .replace(/[^a-zA-Z0-9._-]/g, '_') + .replace(/^\.+/, '_') + .slice(0, 120); + return sanitized || fallback; +} + +function isPathInside(parent: string, child: string): boolean { + const relative = path.relative(parent, child); + return relative.length === 0 || (!relative.startsWith('..') && !path.isAbsolute(relative)); +} + +function timestampForFilename(): string { + return new Date().toISOString().replace(/[:.]/g, '-'); +} + +export function createServerTrackRecordingTarget(input: CreateTargetInput): ServerTrackRecordingTarget { + const recordingRoot = getRecordingRoot(); + const meetingId = sanitizeRecordingPathSegment(input.connectionId, 'unknown'); + const recordingId = sanitizeRecordingPathSegment(input.recordingId, 'recording'); + const participantId = sanitizeRecordingPathSegment(input.participantId, 'participant'); + const kind = sanitizeRecordingPathSegment(input.kind, 'media'); + const trackId = sanitizeRecordingPathSegment(input.trackId, 'track'); + const directory = path.join(recordingRoot, meetingId); + const filename = `${timestampForFilename()}-${recordingId}-${participantId}-${kind}-${trackId}.webm`; + const filePath = path.join(directory, filename); + const metadataPath = path.join(directory, `${filename}.json`); + + if (!isPathInside(recordingRoot, filePath) || !isPathInside(recordingRoot, metadataPath)) { + throw new Error('Invalid server recording path'); + } + + fs.mkdirSync(directory, { recursive: true }); + return { meetingId, directory, filename, filePath, metadataPath }; +} + +export function createComposedRecordingTarget(input: CreateComposedTargetInput): ServerTrackRecordingTarget { + const recordingRoot = getRecordingRoot(); + const meetingId = sanitizeRecordingPathSegment(input.meetingId, 'unknown'); + const recordingId = sanitizeRecordingPathSegment(input.recordingId, 'recording'); + const format = input.format === 'mp4' ? 'mp4' : 'webm'; + const directory = path.join(recordingRoot, meetingId); + const filename = `${timestampForFilename()}-${recordingId}-composed.${format}`; + const filePath = path.join(directory, filename); + const metadataPath = path.join(directory, `${filename}.json`); + + if (!isPathInside(recordingRoot, filePath) || !isPathInside(recordingRoot, metadataPath)) { + throw new Error('Invalid composed recording path'); + } + + fs.mkdirSync(directory, { recursive: true }); + return { meetingId, directory, filename, filePath, metadataPath }; +} + +export function writeServerTrackRecordingMetadata(input: WriteMetadataInput): void { + const now = new Date().toISOString(); + const role = input.role === 'host' ? 'host' : 'participant'; + const metadata = { + id: `${input.recordingId}-${input.participantId}-${input.kind}-${input.trackId}`, + meetingId: input.target.meetingId, + filename: input.target.filename, + originalFilename: `server-recording-${input.participantId}-${input.kind}.webm`, + mimetype: 'video/webm', + size: 0, + userId: 'server-recorder', + host: { + userId: 'server-recorder', + id: 'server-recorder', + name: 'Server Recorder', + role: 'recorder' + }, + participants: [ + { + participantId: input.participantId, + id: input.participantId, + role + } + ], + uploadedAt: now, + updatedAt: now, + recordingSource: 'server', + recordingId: input.recordingId, + participantId: input.participantId, + role, + trackId: input.trackId, + trackKind: input.kind + }; + + fs.writeFileSync(input.target.metadataPath, JSON.stringify(metadata, null, 2)); +} + +export function updateServerTrackRecordingMetadataSize(target: ServerTrackRecordingTarget): void { + if (!fs.existsSync(target.metadataPath) || !fs.existsSync(target.filePath)) { + return; + } + + const metadata = JSON.parse(fs.readFileSync(target.metadataPath, 'utf8')); + metadata.size = fs.statSync(target.filePath).size; + metadata.updatedAt = new Date().toISOString(); + fs.writeFileSync(target.metadataPath, JSON.stringify(metadata, null, 2)); +} + +export function listServerTrackRecordingFiles(input: { + meetingId: string; + recordingId?: string; + trackKind?: string; +}): ServerTrackRecordingFile[] { + const recordingRoot = getRecordingRoot(); + const meetingId = sanitizeRecordingPathSegment(input.meetingId, 'unknown'); + const directory = path.join(recordingRoot, meetingId); + if (!fs.existsSync(directory)) { + return []; + } + + return fs.readdirSync(directory) + .filter((filename) => path.extname(filename).toLowerCase() === '.webm') + .map((filename) => { + const filePath = path.join(directory, filename); + const metadataPath = path.join(directory, `${filename}.json`); + if (!fs.statSync(filePath).isFile() || !fs.existsSync(metadataPath)) { + return null; + } + + const metadata = JSON.parse(fs.readFileSync(metadataPath, 'utf8')); + if (metadata.recordingSource !== 'server') { + return null; + } + if (input.recordingId && metadata.recordingId !== input.recordingId) { + return null; + } + if (input.trackKind && metadata.trackKind !== input.trackKind) { + return null; + } + + return { + meetingId, + directory, + filename, + filePath, + metadataPath, + metadata, + recordingId: metadata.recordingId || '', + participantId: metadata.participantId || '', + trackId: metadata.trackId || '', + trackKind: metadata.trackKind || '', + uploadedAt: metadata.uploadedAt || fs.statSync(filePath).birthtime.toISOString() + }; + }) + .filter((file) => Boolean(file)) as ServerTrackRecordingFile[]; +} + +export function deleteServerTrackRecordingFiles(files: ServerTrackRecordingFile[]): string[] { + const deletedFiles: string[] = []; + files.forEach((file) => { + if (fs.existsSync(file.filePath)) { + fs.unlinkSync(file.filePath); + deletedFiles.push(file.filename); + } + if (fs.existsSync(file.metadataPath)) { + fs.unlinkSync(file.metadataPath); + deletedFiles.push(`${file.filename}.json`); + } + }); + return deletedFiles; +} + +export function writeComposedRecordingMetadata(input: WriteComposedMetadataInput): void { + const now = new Date().toISOString(); + const participantsById: { [participantId: string]: any } = {}; + input.inputs.forEach((file) => { + if (!file.participantId || participantsById[file.participantId]) { + return; + } + participantsById[file.participantId] = { + participantId: file.participantId, + id: file.participantId, + role: 'participant' + }; + }); + + const metadata = { + id: `${input.recordingId}-composed`, + meetingId: input.target.meetingId, + filename: input.target.filename, + originalFilename: `server-recording-${input.recordingId}-composed.${input.format}`, + mimetype: input.format === 'mp4' ? 'video/mp4' : 'video/webm', + size: fs.existsSync(input.target.filePath) ? fs.statSync(input.target.filePath).size : 0, + userId: 'server-recorder', + host: { + userId: 'server-recorder', + id: 'server-recorder', + name: 'Server Recorder', + role: 'recorder' + }, + participants: Object.keys(participantsById).map((participantId) => participantsById[participantId]), + uploadedAt: now, + updatedAt: now, + recordingSource: 'server-composed', + recordingId: input.recordingId, + layout: input.layout, + inputFiles: input.inputs.map((file) => file.filename) + }; + + fs.writeFileSync(input.target.metadataPath, JSON.stringify(metadata, null, 2)); +} diff --git a/src/recording/werift-adapter.ts b/src/recording/werift-adapter.ts new file mode 100644 index 0000000..c1c9040 --- /dev/null +++ b/src/recording/werift-adapter.ts @@ -0,0 +1,284 @@ +import { + incrementRecordingTrackPackets, + registerRecordingPeerAnswer, + registerRecordingPeerTrack +} from './agent'; +import { log, LogLevel } from '../log'; +import { + ServerTrackRecordingTarget, + createServerTrackRecordingTarget, + updateServerTrackRecordingMetadataSize, + writeServerTrackRecordingMetadata +} from './storage'; + +type RecordingPeerKey = string; +type RecordingTrackRecorderKey = string; +type WeriftPeerConnection = any; +type WeriftMediaRecorder = any; + +const werift = require('werift'); +const RTCPeerConnection = werift.RTCPeerConnection; +const weriftNonstandard = require('werift/nonstandard'); +const MediaRecorder = weriftNonstandard.MediaRecorder; + +type RecordingPeerState = { + pc: WeriftPeerConnection; + recordingId: string; + connectionId: string; + participantId: string; + pendingCandidates: Array<{ + candidate: string; + sdpMid?: string; + sdpMLineIndex?: number; + }>; +}; + +type RecordingTrackRecorderState = { + recorder: WeriftMediaRecorder; + target: ServerTrackRecordingTarget; + recordingId: string; + participantId: string; + trackId: string; +}; + +type AcceptOfferInput = { + recordingId: string; + connectionId: string; + participantId: string; + role?: string; + sdp: string; + onLocalCandidate?: (candidate: any) => void; +}; + +const peers: Map = new Map(); +const trackRecorders: Map = new Map(); + +function peerKey(recordingId: string, participantId: string): RecordingPeerKey { + return `${recordingId}:${participantId}`; +} + +function trackRecorderKey(recordingId: string, participantId: string, trackId: string): RecordingTrackRecorderKey { + return `${recordingId}:${participantId}:${trackId}`; +} + +function getPeer(recordingId: string, participantId: string): RecordingPeerState | null { + return peers.get(peerKey(recordingId, participantId)) || null; +} + +function createPeer(input: AcceptOfferInput): RecordingPeerState { + const pc = new RTCPeerConnection({ + iceUseIpv4: true, + iceUseIpv6: false + }); + const state: RecordingPeerState = { + pc, + recordingId: input.recordingId, + connectionId: input.connectionId, + participantId: input.participantId, + pendingCandidates: [] + }; + + pc.onicecandidate = (event) => { + if (event.candidate && input.onLocalCandidate) { + input.onLocalCandidate(event.candidate); + } + }; + + pc.ontrack = (event) => { + const trackId = event.track.id || event.track.uuid || `${event.track.kind}-${Date.now()}`; + registerRecordingPeerTrack({ + recordingId: input.recordingId, + connectionId: input.connectionId, + participantId: input.participantId, + kind: event.track.kind, + trackId + }); + startTrackRecorder({ + recordingId: input.recordingId, + connectionId: input.connectionId, + participantId: input.participantId, + role: input.role, + kind: event.track.kind, + trackId, + track: event.track + }); + event.track.onReceiveRtp.subscribe(() => { + incrementRecordingTrackPackets({ + recordingId: input.recordingId, + participantId: input.participantId, + trackId + }); + }); + }; + + peers.set(peerKey(input.recordingId, input.participantId), state); + return state; +} + +async function flushPendingCandidates(state: RecordingPeerState): Promise { + if (!state.pc.remoteDescription || state.pendingCandidates.length === 0) { + return; + } + + const pendingCandidates = state.pendingCandidates.splice(0, state.pendingCandidates.length); + for (const candidate of pendingCandidates) { + await state.pc.addIceCandidate(candidate); + } +} + +function startTrackRecorder(input: { + recordingId: string; + connectionId: string; + participantId: string; + role?: string; + kind: string; + trackId: string; + track: any; +}): void { + const key = trackRecorderKey(input.recordingId, input.participantId, input.trackId); + if (trackRecorders.has(key)) { + return; + } + + try { + const target = createServerTrackRecordingTarget(input); + writeServerTrackRecordingMetadata({ ...input, target }); + const recorder = new MediaRecorder({ + path: target.filePath, + tracks: [input.track], + width: 1280, + height: 720, + disableLipSync: true, + defaultDuration: 24 * 60 * 60 + }); + + if (recorder.onError && typeof recorder.onError.subscribe === 'function') { + recorder.onError.subscribe((error: Error) => { + log(LogLevel.warn, 'Server recording writer failed:', error); + }); + } + + trackRecorders.set(key, { + recorder, + target, + recordingId: input.recordingId, + participantId: input.participantId, + trackId: input.trackId + }); + } catch (error) { + log(LogLevel.error, 'Failed to start server track recorder:', error); + } +} + +async function stopTrackRecorders(recordingId: string, participantId?: string): Promise { + const keys = Array.from(trackRecorders.keys()).filter((key) => { + if (!recordingId) { + return true; + } + if (participantId) { + return key.startsWith(`${recordingId}:${participantId}:`); + } + return key.startsWith(`${recordingId}:`); + }); + + for (const key of keys) { + const state = trackRecorders.get(key); + if (!state) { + continue; + } + + try { + await state.recorder.stop(); + updateServerTrackRecordingMetadataSize(state.target); + } catch (error) { + log(LogLevel.warn, 'Failed to stop server track recorder:', error); + } finally { + trackRecorders.delete(key); + } + } +} + +export async function acceptRecordingOffer(input: AcceptOfferInput): Promise { + const existing = getPeer(input.recordingId, input.participantId); + if (existing) { + await stopTrackRecorders(input.recordingId, input.participantId); + await existing.pc.close(); + peers.delete(peerKey(input.recordingId, input.participantId)); + } + + const state = createPeer(input); + await state.pc.setRemoteDescription({ + type: 'offer', + sdp: input.sdp + }); + await flushPendingCandidates(state); + const answer = await state.pc.createAnswer(); + await state.pc.setLocalDescription(answer); + const sdp = state.pc.localDescription ? state.pc.localDescription.sdp : answer.sdp; + + registerRecordingPeerAnswer({ + recordingId: input.recordingId, + connectionId: input.connectionId, + participantId: input.participantId, + sdp + }); + + return sdp; +} + +export async function addRecordingIceCandidate(input: { + recordingId: string; + participantId: string; + candidate: string; + sdpMid?: string; + sdpMLineIndex?: number; +}): Promise { + const state = getPeer(input.recordingId, input.participantId); + if (!state) { + return false; + } + + const candidate = { + candidate: input.candidate, + sdpMid: input.sdpMid, + sdpMLineIndex: input.sdpMLineIndex + }; + + if (!state.pc.remoteDescription) { + state.pendingCandidates.push(candidate); + return true; + } + + await state.pc.addIceCandidate(candidate); + return true; +} + +export async function stopRecordingPeer(recordingId: string, participantId?: string): Promise { + await stopTrackRecorders(recordingId, participantId); + const keys = Array.from(peers.keys()).filter((key) => { + if (participantId) { + return key === peerKey(recordingId, participantId); + } + return key.startsWith(`${recordingId}:`); + }); + + for (const key of keys) { + const state = peers.get(key); + if (state) { + await state.pc.close(); + peers.delete(key); + } + } +} + +export async function resetRecordingPeers(): Promise { + await stopTrackRecorders(''); + const keys = Array.from(peers.keys()); + for (const key of keys) { + const state = peers.get(key); + if (state) { + await state.pc.close(); + } + peers.delete(key); + } +} diff --git a/src/server-recording-plan.md b/src/server-recording-plan.md new file mode 100644 index 0000000..b9dfb47 --- /dev/null +++ b/src/server-recording-plan.md @@ -0,0 +1,131 @@ +# Server Recording Plan + +## Goal + +Move meeting recording from browser-only `MediaRecorder` to a server-side recorder. The server creates a recording session, asks every client in the room to publish local media to a dedicated recorder peer, and stores received media under the existing recordings directory. + +## Current Implementation + +### HTTP APIs + +- `GET /api/recording-sessions` + - Lists active and historical in-memory recording sessions. + - Optional query: `connectionId`. +- `GET /api/recording-sessions/:recordingId` + - Returns a session plus its recorder agent state. +- `POST /api/recording-sessions` + - Body: `{ "connectionId": "...", "layout": "grid", "format": "webm" }` + - Creates one active server recording session for a room. + - Broadcasts `recording-started` and `recording-peer-request`. +- `DELETE /api/recording-sessions/:recordingId` + - Stops the session, closes recorder peers, finalizes recorder metadata, and broadcasts `recording-stopped`. + - Starts a background composition job by default. Use `?compose=false` to skip composition. +- `GET /api/recording-compositions` + - Lists background composition jobs. + - Optional query: `meetingId`. +- `GET /api/recording-compositions/:compositionId` + - Returns a single composition job. +- `POST /api/recording-compositions` + - Body: `{ "meetingId": "...", "recordingId": "...", "layout": "grid", "format": "webm" }` + - Starts a background composition job manually. + +### WebSocket Messages + +- Server to clients: + - `recording-started` + - `recording-peer-request` + - `recording-stopped` + - `recording-answer` + - `recording-candidate` + - `recording-status` +- Client to server: + - `recording-offer` + - `recording-candidate` + - `recording-status` + +### Media Flow + +1. Host clicks the existing recording button. +2. Client calls `POST /api/recording-sessions`. +3. Server broadcasts a recorder peer request to the whole room. +4. Each client creates an independent `RTCPeerConnection` with local tracks as `sendonly`. +5. Server accepts each offer through `werift`. +6. Each received track is written as an individual WebM file with a metadata JSON file. +7. When recording stops, the server starts a composition job. +8. The composition job uses FFmpeg to create one grid video and mixed audio artifact. +9. Existing `/api/recordings` list, stream, download, patch, and delete APIs can see both raw track files and composed files. + +### Candidate Ordering + +Recorder peer candidates can arrive before the browser has applied the server answer. The client recorder peer buffers those candidates until `setRemoteDescription(answer)` completes, then flushes them in order. This avoids `InvalidStateError: remote description was null`. + +## Storage + +Default root: + +```text +recordings/ +``` + +Override with: + +```text +RECORDING_DIR=/absolute/path/to/recordings +``` + +Current server-side files are stored as: + +```text +recordings//----.webm +recordings//----.webm.json +``` + +Composed files are stored as: + +```text +recordings//--composed.webm +recordings//--composed.webm.json +``` + +## Composition Runtime + +Composition requires FFmpeg on the server. + +Configure either: + +```text +FFMPEG_PATH=/absolute/path/to/ffmpeg +``` + +or put `ffmpeg` on the server `PATH`. + +The default output format is WebM: + +- Video codec: `libvpx-vp9` +- Audio codec: `libopus` + +MP4 output is also supported: + +- Video codec: `libx264` +- Audio codec: `aac` + +## Lifecycle Rules + +- A room can have only one active server recording session. +- New clients joining a room with an active recording immediately receive a recorder peer request. +- If a participant leaves, only that participant's recorder peer is closed. +- If the host leaves, active recording sessions for that room are stopped and all recorder peers are closed. +- If local media changes while recording is active, the client restarts its recorder peer so the server receives the latest track set. +- After a composition job completes successfully, the raw per-track `.webm` files and their `.webm.json` metadata files are deleted. Failed composition jobs keep raw files so they can be retried. + +## Current Limitation + +The first composition layout is a deterministic grid plus audio mix. It does not yet support active-speaker switching, custom branding, timestamp overlays, or per-user name plates. Raw track files are still kept so failed composition jobs can be retried. + +## Validation + +```text +npm.cmd run build +npm.cmd test -- --runInBand +npm.cmd run lint +``` diff --git a/src/server.ts b/src/server.ts index c890bf2..b6df75c 100644 --- a/src/server.ts +++ b/src/server.ts @@ -7,6 +7,29 @@ import signaling from './signaling'; import { log, LogLevel } from './log'; import Options from './class/options'; import { reset as resetHandler } from './class/httphandler'; +import { + broadcastRecordingPeerRequest, + broadcastRecordingStarted, + broadcastRecordingStopped, + onGetRooms as getWebSocketRooms +} from './class/websockethandler'; +import { + getRecordingAgent, + startRecordingAgent, + stopRecordingAgent +} from './recording/agent'; +import { + getRecordingSession, + listRecordingSessions, + startRecordingSession, + stopRecordingSession +} from './recording/session-manager'; +import { + getRecordingCompositionJob, + listRecordingCompositionJobs, + startRecordingCompositionJob +} from './recording/composer'; +import { stopRecordingPeer } from './recording/werift-adapter'; import { initSwagger } from './swagger'; const cors = require('cors'); @@ -139,7 +162,15 @@ function sanitizeMetadataString(value: any, maxLength = 200): string { return ''; } - return String(value).replace(/[\u0000-\u001f\u007f]/g, '').trim().slice(0, maxLength); + return String(value) + .split('') + .filter((character) => { + const code = character.charCodeAt(0); + return code >= 32 && code !== 127; + }) + .join('') + .trim() + .slice(0, maxLength); } function sanitizeRecordingPerson(value: any, fallbackRole: string): RecordingPerson | undefined { @@ -285,6 +316,16 @@ function removeEmptyDirectory(directory: string): void { } } +function getActiveRecordingSession(connectionId: string) { + const sessions = listRecordingSessions(connectionId); + for (const session of sessions) { + if (session.status === 'recording') { + return session; + } + } + return null; +} + export const createServer = (config: Options): express.Express => { const app: express.Express = express(); resetHandler(config.mode); @@ -452,6 +493,120 @@ export const createServer = (config: Options): express.Express => { } }); + app.get('/api/recording-sessions', (req: express.Request, res: express.Response) => { + const connectionId = typeof req.query.connectionId === 'string' + ? sanitizeMetadataString(req.query.connectionId, 120) + : undefined; + const sessions = listRecordingSessions(connectionId); + res.json({ success: true, sessions, totalCount: sessions.length }); + }); + + app.get('/api/recording-sessions/:recordingId', (req: express.Request, res: express.Response) => { + const session = getRecordingSession(req.params.recordingId); + if (!session) { + res.status(404).json({ success: false, message: 'Recording session not found' }); + return; + } + + res.json({ success: true, session, agent: getRecordingAgent(session.id) }); + }); + + app.post('/api/recording-sessions', (req: express.Request, res: express.Response) => { + const connectionId = sanitizeMetadataString(req.body.connectionId, 120); + if (!connectionId) { + res.status(400).json({ success: false, message: 'connectionId is required' }); + return; + } + + if (config.type === 'websocket' && getWebSocketRooms(connectionId).length === 0) { + res.status(404).json({ success: false, message: 'Active WebSocket room not found' }); + return; + } + + const activeSession = getActiveRecordingSession(connectionId); + if (activeSession) { + res.status(409).json({ success: false, message: 'Recording is already running', session: activeSession }); + return; + } + + try { + const session = startRecordingSession({ + connectionId, + layout: req.body.layout, + format: req.body.format + }); + const agent = startRecordingAgent(session); + const notified = broadcastRecordingStarted(session); + const peerRequestNotified = broadcastRecordingPeerRequest(session); + res.status(201).json({ success: true, session, agent, notified, peerRequestNotified }); + } catch (error) { + log(LogLevel.error, 'Failed to start recording session:', error); + res.status(500).json({ success: false, message: 'Failed to start recording session' }); + } + }); + + app.delete('/api/recording-sessions/:recordingId', async (req: express.Request, res: express.Response) => { + const session = stopRecordingSession(req.params.recordingId); + if (!session) { + res.status(404).json({ success: false, message: 'Recording session not found' }); + return; + } + + const notified = broadcastRecordingStopped(session); + const agent = stopRecordingAgent(session.id); + try { + await stopRecordingPeer(session.id); + } catch (error) { + log(LogLevel.warn, 'Failed to stop recording peer:', error); + } + + const shouldCompose = req.query.compose !== 'false'; + const compositionJob = shouldCompose + ? startRecordingCompositionJob({ + meetingId: session.connectionId, + recordingId: session.id, + layout: session.layout, + format: session.format + }) + : null; + res.json({ success: true, session, agent, notified, compositionJob }); + }); + + app.get('/api/recording-compositions', (req: express.Request, res: express.Response) => { + const meetingId = typeof req.query.meetingId === 'string' + ? sanitizePathSegment(req.query.meetingId, 'unknown') + : undefined; + const jobs = listRecordingCompositionJobs(meetingId); + res.json({ success: true, jobs, totalCount: jobs.length }); + }); + + app.get('/api/recording-compositions/:compositionId', (req: express.Request, res: express.Response) => { + const job = getRecordingCompositionJob(req.params.compositionId); + if (!job) { + res.status(404).json({ success: false, message: 'Recording composition job not found' }); + return; + } + + res.json({ success: true, job }); + }); + + app.post('/api/recording-compositions', (req: express.Request, res: express.Response) => { + const meetingId = sanitizeMetadataString(req.body.meetingId, 120); + const recordingId = sanitizeMetadataString(req.body.recordingId, 120); + if (!meetingId || !recordingId) { + res.status(400).json({ success: false, message: 'meetingId and recordingId are required' }); + return; + } + + const job = startRecordingCompositionJob({ + meetingId, + recordingId, + layout: req.body.layout, + format: req.body.format + }); + res.status(202).json({ success: true, job }); + }); + app.get('/api/recordings', (_req: express.Request, res: express.Response) => { try { const recordings = listRecordings(recordingRoot); diff --git a/src/websocket.ts b/src/websocket.ts index 757f34b..3f44396 100644 --- a/src/websocket.ts +++ b/src/websocket.ts @@ -17,6 +17,9 @@ const VALID_MESSAGE_TYPES = new Set([ "host-userInfo", "invite-call", "on-message", + "recording-offer", + "recording-candidate", + "recording-status", ]); function sendJson(ws: WebSocket, payload: unknown): void { @@ -204,6 +207,16 @@ export default class WSSignaling { if (msg.from) msg.data.connectionId = msg.from; handler.onMessage(ws, msg.data); break; + case 'recording-offer': + if (!hasData(msg)) return; + handler.onRecordingOffer(ws, msg.data); + break; + case 'recording-candidate': + if (!hasData(msg)) return; + handler.onRecordingCandidate(ws, msg.data); + break; + case 'recording-status': + break; default: break; } diff --git a/test/recording-agent.test.ts b/test/recording-agent.test.ts new file mode 100644 index 0000000..631fd43 --- /dev/null +++ b/test/recording-agent.test.ts @@ -0,0 +1,114 @@ +import { + incrementRecordingTrackPackets, + registerRecordingPeerCandidate, + registerRecordingPeerOffer, + registerRecordingPeerTrack, + resetRecordingAgents, + startRecordingAgent, + stopRecordingAgent +} from '../src/recording/agent'; +import { RecordingSession } from '../src/recording/session-manager'; + +const session: RecordingSession = { + id: 'recording-1', + connectionId: 'room-1', + status: 'recording', + layout: 'grid', + format: 'webm', + createdAt: '2026-06-01T00:00:00.000Z', + startedAt: '2026-06-01T00:00:00.000Z', + updatedAt: '2026-06-01T00:00:00.000Z' +}; + +describe('recording agent', () => { + beforeEach(() => { + resetRecordingAgents(); + }); + + test('starts an awaiting media adapter agent', () => { + const agent = startRecordingAgent(session); + + expect(agent).toEqual(expect.objectContaining({ + id: 'recorder_recording-1', + recordingId: 'recording-1', + connectionId: 'room-1', + status: 'awaiting-media-adapter', + mediaMode: 'webrtc-sendonly' + })); + }); + + test('stores peer offers for an active agent', () => { + startRecordingAgent(session); + const offer = registerRecordingPeerOffer({ + recordingId: 'recording-1', + connectionId: 'room-1', + participantId: 'participant-1', + sdp: 'test-sdp' + }); + + expect(offer).toEqual(expect.objectContaining({ + recordingId: 'recording-1', + connectionId: 'room-1', + participantId: 'participant-1', + sdp: 'test-sdp' + })); + }); + + test('stores peer candidates for an active agent', () => { + const agent = startRecordingAgent(session); + const candidate = registerRecordingPeerCandidate({ + recordingId: 'recording-1', + connectionId: 'room-1', + participantId: 'participant-1', + candidate: 'candidate:1', + sdpMid: '0', + sdpMLineIndex: 0 + }); + + expect(candidate).toEqual(expect.objectContaining({ + recordingId: 'recording-1', + connectionId: 'room-1', + participantId: 'participant-1', + candidate: 'candidate:1' + })); + expect(agent.peerCandidates.get('participant-1')).toEqual([candidate]); + }); + + test('tracks received media and packet counts', () => { + const agent = startRecordingAgent(session); + const track = registerRecordingPeerTrack({ + recordingId: 'recording-1', + connectionId: 'room-1', + participantId: 'participant-1', + kind: 'video', + trackId: 'track-1' + }); + + incrementRecordingTrackPackets({ + recordingId: 'recording-1', + participantId: 'participant-1', + trackId: 'track-1' + }); + + expect(agent.status).toBe('receiving-media'); + expect(track).toEqual(expect.objectContaining({ + recordingId: 'recording-1', + participantId: 'participant-1', + kind: 'video', + trackId: 'track-1', + rtpPackets: 1 + })); + }); + + test('rejects offers when the agent is stopped', () => { + startRecordingAgent(session); + stopRecordingAgent('recording-1'); + + expect(registerRecordingPeerOffer({ + recordingId: 'recording-1', + connectionId: 'room-1', + participantId: 'participant-1', + sdp: 'test-sdp' + })).toBeNull(); + }); +}); diff --git a/test/recording-composer.test.ts b/test/recording-composer.test.ts new file mode 100644 index 0000000..1b540c5 --- /dev/null +++ b/test/recording-composer.test.ts @@ -0,0 +1,85 @@ +import { buildFfmpegCompositionArgs } from '../src/recording/composer'; +import { ServerTrackRecordingFile } from '../src/recording/storage'; + +function file(filename: string, trackKind: string, participantId: string, role = 'participant'): ServerTrackRecordingFile { + return { + meetingId: 'room-1', + directory: 'recordings/room-1', + filename, + filePath: `recordings/room-1/${filename}`, + metadataPath: `recordings/room-1/${filename}.json`, + recordingId: 'recording-1', + participantId, + trackId: `${participantId}-${trackKind}`, + trackKind, + uploadedAt: '2026-06-01T00:00:00.000Z', + metadata: { role } + }; +} + +describe('recording composer', () => { + test('builds ffmpeg args for host-led video layout and mixed audio', () => { + const args = buildFfmpegCompositionArgs({ + videoInputs: [ + file('p1-video.webm', 'video', 'p1', 'host'), + file('p2-video.webm', 'video', 'p2') + ], + audioInputs: [ + file('p1-audio.webm', 'audio', 'p1'), + file('p2-audio.webm', 'audio', 'p2') + ], + outputPath: 'recordings/room-1/output.webm', + format: 'webm' + }); + + expect(args).toContain('-filter_complex'); + expect(args.join(' ')).toContain('xstack=inputs=2'); + expect(args.join(' ')).toContain('scale=1280:540'); + expect(args.join(' ')).toContain('scale=1280:180'); + expect(args.join(' ')).toContain('layout=0_0|0_540'); + expect(args.join(' ')).toContain('amix=inputs=2'); + expect(args).toContain('libvpx-vp9'); + expect(args).toContain('libopus'); + expect(args[args.length - 1]).toBe('recordings/room-1/output.webm'); + }); + + test('places host in the first row even when host input is not first', () => { + const args = buildFfmpegCompositionArgs({ + videoInputs: [ + file('p1-video.webm', 'video', 'p1'), + file('host-video.webm', 'video', 'host', 'host'), + file('p2-video.webm', 'video', 'p2') + ], + audioInputs: [], + outputPath: 'recordings/room-1/output.webm', + format: 'webm' + }); + + const filter = args[args.indexOf('-filter_complex') + 1]; + expect(args.slice(0, 7)).toEqual([ + '-y', + '-i', + 'recordings/room-1/host-video.webm', + '-i', + 'recordings/room-1/p1-video.webm', + '-i', + 'recordings/room-1/p2-video.webm' + ]); + expect(filter).toContain('scale=1280:540'); + expect(filter).toContain('scale=640:180'); + expect(filter).toContain('layout=0_0|0_540|640_540'); + }); + + test('builds mp4 encoder args', () => { + const args = buildFfmpegCompositionArgs({ + videoInputs: [file('p1-video.webm', 'video', 'p1')], + audioInputs: [], + outputPath: 'recordings/room-1/output.mp4', + format: 'mp4' + }); + + expect(args).toContain('libx264'); + expect(args).toContain('-pix_fmt'); + expect(args).not.toContain('libopus'); + }); +}); diff --git a/test/recording-session-manager.test.ts b/test/recording-session-manager.test.ts new file mode 100644 index 0000000..0817dc2 --- /dev/null +++ b/test/recording-session-manager.test.ts @@ -0,0 +1,46 @@ +import { + getRecordingSession, + listRecordingSessions, + resetRecordingSessions, + startRecordingSession, + stopRecordingSession +} from '../src/recording/session-manager'; + +describe('recording session manager', () => { + beforeEach(() => { + resetRecordingSessions(); + }); + + test('starts and lists a recording session', () => { + const session = startRecordingSession({ + connectionId: 'room-1', + layout: 'speaker', + format: 'mp4' + }); + + expect(session).toEqual(expect.objectContaining({ + connectionId: 'room-1', + status: 'recording', + layout: 'speaker', + format: 'mp4' + })); + expect(getRecordingSession(session.id)).toEqual(session); + expect(listRecordingSessions('room-1')).toEqual([session]); + }); + + test('stops an existing recording session', () => { + const session = startRecordingSession({ connectionId: 'room-1' }); + const stopped = stopRecordingSession(session.id); + + expect(stopped).toEqual(expect.objectContaining({ + id: session.id, + connectionId: 'room-1', + status: 'stopped' + })); + expect(stopped?.stoppedAt).toEqual(expect.any(String)); + }); + + test('rejects missing connection id', () => { + expect(() => startRecordingSession({ connectionId: '' })).toThrow('connectionId is required'); + }); +}); diff --git a/test/recording-storage.test.ts b/test/recording-storage.test.ts new file mode 100644 index 0000000..a251b07 --- /dev/null +++ b/test/recording-storage.test.ts @@ -0,0 +1,131 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { + createComposedRecordingTarget, + createServerTrackRecordingTarget, + deleteServerTrackRecordingFiles, + listServerTrackRecordingFiles, + sanitizeRecordingPathSegment, + updateServerTrackRecordingMetadataSize, + writeComposedRecordingMetadata, + writeServerTrackRecordingMetadata +} from '../src/recording/storage'; + +describe('recording storage', () => { + const originalRecordingDir = process.env.RECORDING_DIR; + let tempDir: string; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'recording-storage-')); + process.env.RECORDING_DIR = tempDir; + }); + + afterEach(() => { + process.env.RECORDING_DIR = originalRecordingDir; + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + test('sanitizes path segments', () => { + expect(sanitizeRecordingPathSegment('../room:name', 'fallback')).toBe('__room_name'); + expect(sanitizeRecordingPathSegment('', 'fallback')).toBe('fallback'); + }); + + test('creates server track target and updates metadata size', () => { + const target = createServerTrackRecordingTarget({ + recordingId: 'recording/1', + connectionId: 'room:1', + participantId: 'participant-1', + kind: 'video', + trackId: 'track-1' + }); + + expect(target.meetingId).toBe('room_1'); + expect(target.filePath.startsWith(path.join(tempDir, 'room_1'))).toBe(true); + expect(target.filename).toContain('recording_1-participant-1-video-track-1.webm'); + + writeServerTrackRecordingMetadata({ + recordingId: 'recording-1', + connectionId: 'room-1', + participantId: 'participant-1', + kind: 'video', + trackId: 'track-1', + target + }); + fs.writeFileSync(target.filePath, Buffer.from('webm')); + updateServerTrackRecordingMetadataSize(target); + + const metadata = JSON.parse(fs.readFileSync(target.metadataPath, 'utf8')); + expect(metadata).toEqual(expect.objectContaining({ + meetingId: 'room_1', + filename: target.filename, + mimetype: 'video/webm', + size: 4, + userId: 'server-recorder', + recordingSource: 'server', + participantId: 'participant-1', + trackKind: 'video' + })); + const files = listServerTrackRecordingFiles({ + meetingId: 'room_1', + recordingId: 'recording-1', + trackKind: 'video' + }); + expect(files).toEqual([ + expect.objectContaining({ + filename: target.filename, + participantId: 'participant-1', + trackKind: 'video' + }) + ]); + + expect(deleteServerTrackRecordingFiles(files)).toEqual([ + target.filename, + `${target.filename}.json` + ]); + expect(fs.existsSync(target.filePath)).toBe(false); + expect(fs.existsSync(target.metadataPath)).toBe(false); + expect(listServerTrackRecordingFiles({ + meetingId: 'room_1', + recordingId: 'recording-1', + trackKind: 'video' + })).toEqual([]); + }); + + test('writes composed recording metadata', () => { + const target = createComposedRecordingTarget({ + meetingId: 'room-1', + recordingId: 'recording-1', + format: 'webm' + }); + fs.writeFileSync(target.filePath, Buffer.from('composed')); + writeComposedRecordingMetadata({ + target, + recordingId: 'recording-1', + layout: 'grid', + format: 'webm', + inputs: [ + { + ...target, + filename: 'p1-video.webm', + recordingId: 'recording-1', + participantId: 'p1', + trackId: 'track-1', + trackKind: 'video', + uploadedAt: '2026-06-01T00:00:00.000Z', + metadata: {} + } + ] + }); + + const metadata = JSON.parse(fs.readFileSync(target.metadataPath, 'utf8')); + expect(metadata).toEqual(expect.objectContaining({ + meetingId: 'room-1', + filename: target.filename, + recordingSource: 'server-composed', + size: 8, + layout: 'grid', + inputFiles: ['p1-video.webm'] + })); + }); +}); diff --git a/test/websockethandler.test.ts b/test/websockethandler.test.ts index fd9ec31..52f2f47 100644 --- a/test/websockethandler.test.ts +++ b/test/websockethandler.test.ts @@ -190,6 +190,44 @@ describe('websocket signaling test in private mode', () => { ]); }); + test('broadcast recording status to room members', async () => { + const session = { + id: 'recording-1', + connectionId: connectionId, + status: 'recording', + layout: 'grid', + format: 'webm', + createdAt: '2026-06-01T00:00:00.000Z', + startedAt: '2026-06-01T00:00:00.000Z', + updatedAt: '2026-06-01T00:00:00.000Z' + } as any; + const expected = { + type: 'recording-started', + connectionId: connectionId, + recordingId: 'recording-1', + status: 'recording', + layout: 'grid', + format: 'webm', + startedAt: '2026-06-01T00:00:00.000Z' + }; + + expect(wsHandler.broadcastRecordingStarted(session)).toBe(true); + await expect(server).toReceiveMessage(expected); + await expect(server).toReceiveMessage(expected); + + expect(wsHandler.broadcastRecordingPeerRequest(session)).toBe(true); + await expect(server).toReceiveMessage({ + ...expected, + type: 'recording-peer-request', + mediaMode: 'webrtc-sendonly' + }); + await expect(server).toReceiveMessage({ + ...expected, + type: 'recording-peer-request', + mediaMode: 'webrtc-sendonly' + }); + }); + test('send offer from session1', async () => { await wsHandler.onOffer(client, { connectionId: connectionId, sdp: testsdp }); const receiveOffer = new Offer(testsdp, Date.now(), true);