@wetspace/wetrtc 3.0.1 → 4.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +156 -27
- package/dist/es/__test__/codec-preference.test.js +36 -0
- package/dist/es/__test__/codec-preference.test.js.map +1 -0
- package/dist/es/__test__/data-manager.test.js +60 -0
- package/dist/es/__test__/data-manager.test.js.map +1 -0
- package/dist/es/__test__/fsm.test.js +33 -0
- package/dist/es/__test__/fsm.test.js.map +1 -0
- package/dist/es/__test__/media-manager.test.js +41 -0
- package/dist/es/__test__/media-manager.test.js.map +1 -0
- package/dist/es/__test__/signal-manager.test.js +100 -0
- package/dist/es/__test__/signal-manager.test.js.map +1 -0
- package/dist/es/__test__/wetrtc-lifecycle.test.js +152 -0
- package/dist/es/__test__/wetrtc-lifecycle.test.js.map +1 -0
- package/dist/es/data/data-manager.d.ts +37 -0
- package/dist/es/data/data-manager.d.ts.map +1 -0
- package/dist/es/data/data-manager.js +282 -0
- package/dist/es/data/data-manager.js.map +1 -0
- package/dist/es/data/types.d.ts +34 -0
- package/dist/es/data/types.d.ts.map +1 -0
- package/dist/es/data/types.js +0 -0
- package/dist/es/disposable.d.ts +12 -0
- package/dist/es/disposable.d.ts.map +1 -0
- package/dist/es/disposable.js +36 -0
- package/dist/es/disposable.js.map +1 -0
- package/dist/es/fsm.d.ts +26 -0
- package/dist/es/fsm.d.ts.map +1 -0
- package/dist/es/fsm.js +63 -0
- package/dist/es/fsm.js.map +1 -0
- package/dist/es/index.d.ts +22 -0
- package/dist/es/index.d.ts.map +1 -0
- package/dist/es/index.js +48 -0
- package/dist/es/index.js.map +1 -0
- package/dist/es/media/audio-encoding.d.ts +10 -0
- package/dist/es/media/audio-encoding.d.ts.map +1 -0
- package/dist/es/media/audio-encoding.js +41 -0
- package/dist/es/media/audio-encoding.js.map +1 -0
- package/dist/es/media/codec-preference.d.ts +11 -0
- package/dist/es/media/codec-preference.d.ts.map +1 -0
- package/dist/es/media/codec-preference.js +77 -0
- package/dist/es/media/codec-preference.js.map +1 -0
- package/dist/es/media/encoding-utils.d.ts +2 -0
- package/dist/es/media/encoding-utils.d.ts.map +1 -0
- package/dist/es/media/encoding-utils.js +8 -0
- package/dist/es/media/encoding-utils.js.map +1 -0
- package/dist/es/media/media-manager.d.ts +39 -0
- package/dist/es/media/media-manager.d.ts.map +1 -0
- package/dist/es/media/media-manager.js +121 -0
- package/dist/es/media/media-manager.js.map +1 -0
- package/dist/es/media/types.d.ts +25 -0
- package/dist/es/media/types.d.ts.map +1 -0
- package/dist/es/media/types.js +0 -0
- package/dist/es/media/video-encoding.d.ts +12 -0
- package/dist/es/media/video-encoding.d.ts.map +1 -0
- package/dist/es/media/video-encoding.js +60 -0
- package/dist/es/media/video-encoding.js.map +1 -0
- package/dist/es/signal/signal-manager.d.ts +45 -0
- package/dist/es/signal/signal-manager.d.ts.map +1 -0
- package/dist/es/signal/signal-manager.js +250 -0
- package/dist/es/signal/signal-manager.js.map +1 -0
- package/dist/es/signal/types.d.ts +26 -0
- package/dist/es/signal/types.d.ts.map +1 -0
- package/dist/es/signal/types.js +8 -0
- package/dist/es/signal/types.js.map +1 -0
- package/dist/es/stats/stats-monitor.d.ts +32 -0
- package/dist/es/stats/stats-monitor.d.ts.map +1 -0
- package/dist/es/stats/stats-monitor.js +191 -0
- package/dist/es/stats/stats-monitor.js.map +1 -0
- package/dist/es/stats/types.d.ts +33 -0
- package/dist/es/stats/types.d.ts.map +1 -0
- package/dist/es/stats/types.js +0 -0
- package/dist/es/utils/types.d.ts +46 -0
- package/dist/es/utils/types.d.ts.map +1 -0
- package/dist/es/utils/types.js +80 -0
- package/dist/es/utils/types.js.map +1 -0
- package/dist/es/wetrtc.d.ts +92 -0
- package/dist/es/wetrtc.d.ts.map +1 -0
- package/dist/es/wetrtc.js +403 -0
- package/dist/es/wetrtc.js.map +1 -0
- package/dist/lib/__test__/codec-preference.test.js +34 -0
- package/dist/lib/__test__/codec-preference.test.js.map +1 -0
- package/dist/lib/__test__/data-manager.test.js +61 -0
- package/dist/lib/__test__/data-manager.test.js.map +1 -0
- package/dist/lib/__test__/fsm.test.js +34 -0
- package/dist/lib/__test__/fsm.test.js.map +1 -0
- package/dist/lib/__test__/media-manager.test.js +42 -0
- package/dist/lib/__test__/media-manager.test.js.map +1 -0
- package/dist/lib/__test__/signal-manager.test.js +101 -0
- package/dist/lib/__test__/signal-manager.test.js.map +1 -0
- package/dist/lib/__test__/wetrtc-lifecycle.test.js +153 -0
- package/dist/lib/__test__/wetrtc-lifecycle.test.js.map +1 -0
- package/dist/lib/data/data-manager.js +306 -0
- package/dist/lib/data/data-manager.js.map +1 -0
- package/dist/lib/data/types.js +18 -0
- package/dist/lib/data/types.js.map +1 -0
- package/dist/lib/disposable.js +60 -0
- package/dist/lib/disposable.js.map +1 -0
- package/dist/lib/fsm.js +87 -0
- package/dist/lib/fsm.js.map +1 -0
- package/dist/lib/index.js +75 -0
- package/dist/lib/index.js.map +1 -0
- package/dist/lib/media/audio-encoding.js +66 -0
- package/dist/lib/media/audio-encoding.js.map +1 -0
- package/dist/lib/media/codec-preference.js +106 -0
- package/dist/lib/media/codec-preference.js.map +1 -0
- package/dist/lib/media/encoding-utils.js +32 -0
- package/dist/lib/media/encoding-utils.js.map +1 -0
- package/dist/lib/media/media-manager.js +145 -0
- package/dist/lib/media/media-manager.js.map +1 -0
- package/dist/lib/media/types.js +18 -0
- package/dist/lib/media/types.js.map +1 -0
- package/dist/lib/media/video-encoding.js +87 -0
- package/dist/lib/media/video-encoding.js.map +1 -0
- package/dist/lib/signal/signal-manager.js +274 -0
- package/dist/lib/signal/signal-manager.js.map +1 -0
- package/dist/lib/signal/types.js +32 -0
- package/dist/lib/signal/types.js.map +1 -0
- package/dist/lib/stats/stats-monitor.js +215 -0
- package/dist/lib/stats/stats-monitor.js.map +1 -0
- package/dist/lib/stats/types.js +18 -0
- package/dist/lib/stats/types.js.map +1 -0
- package/dist/lib/utils/types.js +108 -0
- package/dist/lib/utils/types.js.map +1 -0
- package/dist/lib/wetrtc.js +415 -0
- package/dist/lib/wetrtc.js.map +1 -0
- package/package.json +38 -43
- package/es/core/constant.d.ts +0 -6
- package/es/core/hook.d.ts +0 -31
- package/es/core/index.d.ts +0 -39
- package/es/index.d.ts +0 -6
- package/es/index.js +0 -1
- package/es/libs/index.d.ts +0 -41
- package/es/libs/record.d.ts +0 -8
- package/lib/core/constant.d.ts +0 -6
- package/lib/core/hook.d.ts +0 -31
- package/lib/core/index.d.ts +0 -39
- package/lib/index.d.ts +0 -6
- package/lib/index.js +0 -1
- package/lib/libs/index.d.ts +0 -41
- package/lib/libs/record.d.ts +0 -8
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
import { createLogger } from "../utils/types";
|
|
2
|
+
const DEFAULT_CONFIG = {
|
|
3
|
+
negotiationTimeout: 3e4,
|
|
4
|
+
iceGatheringTimeout: 1e4
|
|
5
|
+
};
|
|
6
|
+
class SignalManager {
|
|
7
|
+
constructor(channel, pc, fsm, polite, config) {
|
|
8
|
+
this.channel = channel;
|
|
9
|
+
this.pc = pc;
|
|
10
|
+
this.fsm = fsm;
|
|
11
|
+
this.polite = polite;
|
|
12
|
+
this.makingOffer = false;
|
|
13
|
+
this.ignoreOffer = false;
|
|
14
|
+
this.pendingCandidates = [];
|
|
15
|
+
this.cleanups = [];
|
|
16
|
+
this.config = { ...DEFAULT_CONFIG, ...config };
|
|
17
|
+
this.logger = createLogger();
|
|
18
|
+
this.setupListeners();
|
|
19
|
+
}
|
|
20
|
+
/** 切换 PeerConnection(disconnect / reconnect 后 rebind) */
|
|
21
|
+
setPeerConnection(pc) {
|
|
22
|
+
this.teardownListeners();
|
|
23
|
+
this.pc = pc;
|
|
24
|
+
this.pendingCandidates = [];
|
|
25
|
+
this.makingOffer = false;
|
|
26
|
+
this.ignoreOffer = false;
|
|
27
|
+
this.setupListeners();
|
|
28
|
+
}
|
|
29
|
+
/** 发起协商(创建 offer) */
|
|
30
|
+
async createOffer() {
|
|
31
|
+
if (this.fsm.is("connected")) {
|
|
32
|
+
return this.renegotiateAsOfferer();
|
|
33
|
+
}
|
|
34
|
+
this.enterSignaling();
|
|
35
|
+
try {
|
|
36
|
+
this.makingOffer = true;
|
|
37
|
+
const offer = await this.withTimeout(
|
|
38
|
+
this.pc.createOffer(),
|
|
39
|
+
this.config.negotiationTimeout,
|
|
40
|
+
"createOffer timeout"
|
|
41
|
+
);
|
|
42
|
+
await this.pc.setLocalDescription(offer);
|
|
43
|
+
await this.channel.send({
|
|
44
|
+
type: "offer",
|
|
45
|
+
sdp: this.pc.localDescription.sdp
|
|
46
|
+
});
|
|
47
|
+
this.logger.info("Offer sent");
|
|
48
|
+
} catch (err) {
|
|
49
|
+
this.failConnection();
|
|
50
|
+
throw new Error(`Create offer failed: ${err.message}`);
|
|
51
|
+
} finally {
|
|
52
|
+
this.makingOffer = false;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
/** 接收远端 offer 并创建 answer */
|
|
56
|
+
async handleOffer(sdp) {
|
|
57
|
+
const offerCollision = this.makingOffer || this.pc.signalingState !== "stable";
|
|
58
|
+
if (offerCollision) {
|
|
59
|
+
if (!this.polite) {
|
|
60
|
+
this.logger.warn("Ignoring colliding offer (impolite peer)");
|
|
61
|
+
this.ignoreOffer = true;
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
this.logger.info("Rolling back local offer (polite peer)");
|
|
65
|
+
await this.pc.setLocalDescription({ type: "rollback" });
|
|
66
|
+
}
|
|
67
|
+
this.ignoreOffer = false;
|
|
68
|
+
if (this.fsm.is("connected")) {
|
|
69
|
+
return this.renegotiateAsAnswerer(sdp);
|
|
70
|
+
}
|
|
71
|
+
this.enterSignaling();
|
|
72
|
+
try {
|
|
73
|
+
await this.pc.setRemoteDescription({ type: "offer", sdp });
|
|
74
|
+
await this.flushPendingCandidates();
|
|
75
|
+
const answer = await this.withTimeout(
|
|
76
|
+
this.pc.createAnswer(),
|
|
77
|
+
this.config.negotiationTimeout,
|
|
78
|
+
"createAnswer timeout"
|
|
79
|
+
);
|
|
80
|
+
await this.pc.setLocalDescription(answer);
|
|
81
|
+
await this.channel.send({
|
|
82
|
+
type: "answer",
|
|
83
|
+
sdp: this.pc.localDescription.sdp
|
|
84
|
+
});
|
|
85
|
+
this.logger.info("Answer sent");
|
|
86
|
+
} catch (err) {
|
|
87
|
+
this.failConnection();
|
|
88
|
+
throw new Error(`Handle offer failed: ${err.message}`);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
/** 接收远端 answer */
|
|
92
|
+
async handleAnswer(sdp) {
|
|
93
|
+
if (this.ignoreOffer)
|
|
94
|
+
return;
|
|
95
|
+
if (this.pc.signalingState === "stable") {
|
|
96
|
+
this.logger.warn("Received answer in stable state, ignoring");
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
await this.pc.setRemoteDescription({ type: "answer", sdp });
|
|
100
|
+
await this.flushPendingCandidates();
|
|
101
|
+
this.logger.info("Answer received and set");
|
|
102
|
+
}
|
|
103
|
+
/** ICE 候选管理 */
|
|
104
|
+
async addIceCandidate(candidate) {
|
|
105
|
+
if (this.pc.remoteDescription) {
|
|
106
|
+
try {
|
|
107
|
+
await this.pc.addIceCandidate(new RTCIceCandidate(candidate));
|
|
108
|
+
} catch (err) {
|
|
109
|
+
this.logger.warn("Failed to add ICE candidate:", err.message);
|
|
110
|
+
}
|
|
111
|
+
} else {
|
|
112
|
+
this.pendingCandidates.push(candidate);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
dispose() {
|
|
116
|
+
this.teardownListeners();
|
|
117
|
+
this.pendingCandidates.length = 0;
|
|
118
|
+
}
|
|
119
|
+
// ── private ──
|
|
120
|
+
async renegotiateAsOfferer() {
|
|
121
|
+
this.logger.info("Starting renegotiation (offerer)");
|
|
122
|
+
this.makingOffer = true;
|
|
123
|
+
try {
|
|
124
|
+
const offer = await this.pc.createOffer();
|
|
125
|
+
await this.pc.setLocalDescription(offer);
|
|
126
|
+
await this.channel.send({
|
|
127
|
+
type: "offer",
|
|
128
|
+
sdp: this.pc.localDescription.sdp
|
|
129
|
+
});
|
|
130
|
+
} finally {
|
|
131
|
+
this.makingOffer = false;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
async renegotiateAsAnswerer(sdp) {
|
|
135
|
+
this.logger.info("Starting renegotiation (answerer)");
|
|
136
|
+
await this.pc.setRemoteDescription({ type: "offer", sdp });
|
|
137
|
+
await this.flushPendingCandidates();
|
|
138
|
+
const answer = await this.pc.createAnswer();
|
|
139
|
+
await this.pc.setLocalDescription(answer);
|
|
140
|
+
await this.channel.send({
|
|
141
|
+
type: "answer",
|
|
142
|
+
sdp: this.pc.localDescription.sdp
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
enterSignaling() {
|
|
146
|
+
if (this.fsm.is("idle")) {
|
|
147
|
+
this.fsm.transition("signaling");
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
failConnection() {
|
|
151
|
+
if (!this.fsm.is("failed", "disposed")) {
|
|
152
|
+
this.fsm.transition("failed");
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
setupListeners() {
|
|
156
|
+
const onIceCandidate = (ev) => {
|
|
157
|
+
if (ev.candidate) {
|
|
158
|
+
this.channel.send({
|
|
159
|
+
type: "ice-candidate",
|
|
160
|
+
candidate: ev.candidate.toJSON()
|
|
161
|
+
}).catch((err) => this.logger.warn("Failed to send ICE candidate:", err));
|
|
162
|
+
}
|
|
163
|
+
};
|
|
164
|
+
this.pc.addEventListener("icecandidate", onIceCandidate);
|
|
165
|
+
const onIceState = () => {
|
|
166
|
+
this.logger.debug("ICE state:", this.pc.iceConnectionState);
|
|
167
|
+
switch (this.pc.iceConnectionState) {
|
|
168
|
+
case "checking":
|
|
169
|
+
if (this.fsm.is("signaling")) {
|
|
170
|
+
this.fsm.transition("connecting");
|
|
171
|
+
}
|
|
172
|
+
break;
|
|
173
|
+
case "connected":
|
|
174
|
+
case "completed":
|
|
175
|
+
if (this.fsm.is("signaling", "connecting")) {
|
|
176
|
+
if (this.fsm.is("signaling")) {
|
|
177
|
+
this.fsm.transition("connecting");
|
|
178
|
+
}
|
|
179
|
+
this.fsm.transition("connected");
|
|
180
|
+
}
|
|
181
|
+
break;
|
|
182
|
+
case "disconnected":
|
|
183
|
+
if (this.fsm.is("connected")) {
|
|
184
|
+
this.fsm.transition("reconnecting");
|
|
185
|
+
}
|
|
186
|
+
break;
|
|
187
|
+
case "failed":
|
|
188
|
+
this.fsm.transition("failed");
|
|
189
|
+
break;
|
|
190
|
+
}
|
|
191
|
+
};
|
|
192
|
+
this.pc.addEventListener("iceconnectionstatechange", onIceState);
|
|
193
|
+
const unsubMsg = this.channel.onMessage(async (msg) => {
|
|
194
|
+
switch (msg.type) {
|
|
195
|
+
case "offer":
|
|
196
|
+
await this.handleOffer(msg.sdp).catch(
|
|
197
|
+
(err) => this.logger.error("Handle offer error:", err)
|
|
198
|
+
);
|
|
199
|
+
break;
|
|
200
|
+
case "answer":
|
|
201
|
+
await this.handleAnswer(msg.sdp).catch(
|
|
202
|
+
(err) => this.logger.error("Handle answer error:", err)
|
|
203
|
+
);
|
|
204
|
+
break;
|
|
205
|
+
case "ice-candidate":
|
|
206
|
+
if (msg.candidate) {
|
|
207
|
+
await this.addIceCandidate(msg.candidate).catch(() => {
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
break;
|
|
211
|
+
case "bye":
|
|
212
|
+
this.logger.info("Peer sent bye");
|
|
213
|
+
this.fsm.force("idle");
|
|
214
|
+
break;
|
|
215
|
+
}
|
|
216
|
+
});
|
|
217
|
+
this.cleanups.push(
|
|
218
|
+
() => this.pc.removeEventListener("icecandidate", onIceCandidate),
|
|
219
|
+
() => this.pc.removeEventListener("iceconnectionstatechange", onIceState),
|
|
220
|
+
unsubMsg
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
teardownListeners() {
|
|
224
|
+
for (const cleanup of this.cleanups)
|
|
225
|
+
cleanup();
|
|
226
|
+
this.cleanups.length = 0;
|
|
227
|
+
}
|
|
228
|
+
async flushPendingCandidates() {
|
|
229
|
+
if (this.pendingCandidates.length === 0)
|
|
230
|
+
return;
|
|
231
|
+
const batch = [...this.pendingCandidates];
|
|
232
|
+
this.pendingCandidates.length = 0;
|
|
233
|
+
await Promise.allSettled(
|
|
234
|
+
batch.map((c) => this.pc.addIceCandidate(new RTCIceCandidate(c)))
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
withTimeout(promise, ms, msg) {
|
|
238
|
+
return Promise.race([
|
|
239
|
+
promise,
|
|
240
|
+
new Promise(
|
|
241
|
+
(_, reject) => setTimeout(() => reject(new Error(`[SignalManager] ${msg}`)), ms)
|
|
242
|
+
)
|
|
243
|
+
]);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
export {
|
|
247
|
+
SignalManager
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
//# sourceMappingURL=signal-manager.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"mappings":"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","names":[],"ignoreList":[],"sources":["../../../src/signal/signal-manager.ts"],"sourcesContent":["import type { SignalChannel } from './types';\r\nimport { ConnectionStateMachine } from '../fsm';\r\nimport { createLogger, type Logger } from '../utils/types';\r\nimport type { IDisposable } from '../disposable';\r\n\r\nexport interface SignalConfig {\r\n /** SDP 交换超时 (ms),默认 30000 */\r\n negotiationTimeout?: number;\r\n /** ICE 候选收集超时 (ms),默认 10000 */\r\n iceGatheringTimeout?: number;\r\n}\r\n\r\nconst DEFAULT_CONFIG: Required<SignalConfig> = {\r\n negotiationTimeout: 30_000,\r\n iceGatheringTimeout: 10_000,\r\n};\r\n\r\n/**\r\n * 信令管理器 — 负责 SDP / ICE 信令的序列化与协商流程\r\n */\r\nexport class SignalManager implements IDisposable {\r\n private config: Required<SignalConfig>;\r\n private logger: Logger;\r\n private makingOffer = false;\r\n private ignoreOffer = false;\r\n private pendingCandidates: RTCIceCandidateInit[] = [];\r\n private cleanups: (() => void)[] = [];\r\n\r\n constructor(\r\n private channel: SignalChannel,\r\n private pc: RTCPeerConnection,\r\n private fsm: ConnectionStateMachine,\r\n private polite: boolean,\r\n config?: SignalConfig,\r\n ) {\r\n this.config = { ...DEFAULT_CONFIG, ...config };\r\n this.logger = createLogger();\r\n this.setupListeners();\r\n }\r\n\r\n /** 切换 PeerConnection(disconnect / reconnect 后 rebind) */\r\n setPeerConnection(pc: RTCPeerConnection): void {\r\n this.teardownListeners();\r\n this.pc = pc;\r\n this.pendingCandidates = [];\r\n this.makingOffer = false;\r\n this.ignoreOffer = false;\r\n this.setupListeners();\r\n }\r\n\r\n /** 发起协商(创建 offer) */\r\n async createOffer(): Promise<void> {\r\n if (this.fsm.is('connected')) {\r\n return this.renegotiateAsOfferer();\r\n }\r\n\r\n this.enterSignaling();\r\n\r\n try {\r\n this.makingOffer = true;\r\n const offer = await this.withTimeout(\r\n this.pc.createOffer(),\r\n this.config.negotiationTimeout,\r\n 'createOffer timeout',\r\n );\r\n\r\n await this.pc.setLocalDescription(offer);\r\n await this.channel.send({\r\n type: 'offer',\r\n sdp: this.pc.localDescription!.sdp,\r\n });\r\n\r\n this.logger.info('Offer sent');\r\n } catch (err) {\r\n this.failConnection();\r\n throw new Error(`Create offer failed: ${(err as Error).message}`);\r\n } finally {\r\n this.makingOffer = false;\r\n }\r\n }\r\n\r\n /** 接收远端 offer 并创建 answer */\r\n async handleOffer(sdp: string): Promise<void> {\r\n const offerCollision =\r\n this.makingOffer || this.pc.signalingState !== 'stable';\r\n\r\n if (offerCollision) {\r\n if (!this.polite) {\r\n this.logger.warn('Ignoring colliding offer (impolite peer)');\r\n this.ignoreOffer = true;\r\n return;\r\n }\r\n this.logger.info('Rolling back local offer (polite peer)');\r\n await this.pc.setLocalDescription({ type: 'rollback' });\r\n }\r\n\r\n this.ignoreOffer = false;\r\n\r\n if (this.fsm.is('connected')) {\r\n return this.renegotiateAsAnswerer(sdp);\r\n }\r\n\r\n this.enterSignaling();\r\n\r\n try {\r\n await this.pc.setRemoteDescription({ type: 'offer', sdp });\r\n await this.flushPendingCandidates();\r\n\r\n const answer = await this.withTimeout(\r\n this.pc.createAnswer(),\r\n this.config.negotiationTimeout,\r\n 'createAnswer timeout',\r\n );\r\n\r\n await this.pc.setLocalDescription(answer);\r\n await this.channel.send({\r\n type: 'answer',\r\n sdp: this.pc.localDescription!.sdp,\r\n });\r\n\r\n this.logger.info('Answer sent');\r\n } catch (err) {\r\n this.failConnection();\r\n throw new Error(`Handle offer failed: ${(err as Error).message}`);\r\n }\r\n }\r\n\r\n /** 接收远端 answer */\r\n async handleAnswer(sdp: string): Promise<void> {\r\n if (this.ignoreOffer) return;\r\n\r\n if (this.pc.signalingState === 'stable') {\r\n this.logger.warn('Received answer in stable state, ignoring');\r\n return;\r\n }\r\n\r\n await this.pc.setRemoteDescription({ type: 'answer', sdp });\r\n await this.flushPendingCandidates();\r\n this.logger.info('Answer received and set');\r\n }\r\n\r\n /** ICE 候选管理 */\r\n async addIceCandidate(candidate: RTCIceCandidateInit): Promise<void> {\r\n if (this.pc.remoteDescription) {\r\n try {\r\n await this.pc.addIceCandidate(new RTCIceCandidate(candidate));\r\n } catch (err) {\r\n this.logger.warn('Failed to add ICE candidate:', (err as Error).message);\r\n }\r\n } else {\r\n this.pendingCandidates.push(candidate);\r\n }\r\n }\r\n\r\n dispose(): void {\r\n this.teardownListeners();\r\n this.pendingCandidates.length = 0;\r\n }\r\n\r\n // ── private ──\r\n\r\n private async renegotiateAsOfferer(): Promise<void> {\r\n this.logger.info('Starting renegotiation (offerer)');\r\n this.makingOffer = true;\r\n try {\r\n const offer = await this.pc.createOffer();\r\n await this.pc.setLocalDescription(offer);\r\n await this.channel.send({\r\n type: 'offer',\r\n sdp: this.pc.localDescription!.sdp,\r\n });\r\n } finally {\r\n this.makingOffer = false;\r\n }\r\n }\r\n\r\n private async renegotiateAsAnswerer(sdp: string): Promise<void> {\r\n this.logger.info('Starting renegotiation (answerer)');\r\n await this.pc.setRemoteDescription({ type: 'offer', sdp });\r\n await this.flushPendingCandidates();\r\n const answer = await this.pc.createAnswer();\r\n await this.pc.setLocalDescription(answer);\r\n await this.channel.send({\r\n type: 'answer',\r\n sdp: this.pc.localDescription!.sdp,\r\n });\r\n }\r\n\r\n private enterSignaling(): void {\r\n if (this.fsm.is('idle')) {\r\n this.fsm.transition('signaling');\r\n }\r\n }\r\n\r\n private failConnection(): void {\r\n if (!this.fsm.is('failed', 'disposed')) {\r\n this.fsm.transition('failed');\r\n }\r\n }\r\n\r\n private setupListeners(): void {\r\n const onIceCandidate = (ev: RTCPeerConnectionIceEvent) => {\r\n if (ev.candidate) {\r\n this.channel.send({\r\n type: 'ice-candidate',\r\n candidate: ev.candidate.toJSON(),\r\n }).catch(err => this.logger.warn('Failed to send ICE candidate:', err));\r\n }\r\n };\r\n this.pc.addEventListener('icecandidate', onIceCandidate);\r\n\r\n const onIceState = () => {\r\n this.logger.debug('ICE state:', this.pc.iceConnectionState);\r\n switch (this.pc.iceConnectionState) {\r\n case 'checking':\r\n if (this.fsm.is('signaling')) {\r\n this.fsm.transition('connecting');\r\n }\r\n break;\r\n case 'connected':\r\n case 'completed':\r\n if (this.fsm.is('signaling', 'connecting')) {\r\n if (this.fsm.is('signaling')) {\r\n this.fsm.transition('connecting');\r\n }\r\n this.fsm.transition('connected');\r\n }\r\n break;\r\n case 'disconnected':\r\n if (this.fsm.is('connected')) {\r\n this.fsm.transition('reconnecting');\r\n }\r\n break;\r\n case 'failed':\r\n this.fsm.transition('failed');\r\n break;\r\n }\r\n };\r\n this.pc.addEventListener('iceconnectionstatechange', onIceState);\r\n\r\n const unsubMsg = this.channel.onMessage(async (msg) => {\r\n switch (msg.type) {\r\n case 'offer':\r\n await this.handleOffer(msg.sdp!).catch(err =>\r\n this.logger.error('Handle offer error:', err)\r\n );\r\n break;\r\n case 'answer':\r\n await this.handleAnswer(msg.sdp!).catch(err =>\r\n this.logger.error('Handle answer error:', err)\r\n );\r\n break;\r\n case 'ice-candidate':\r\n if (msg.candidate) {\r\n await this.addIceCandidate(msg.candidate).catch(() => {});\r\n }\r\n break;\r\n case 'bye':\r\n this.logger.info('Peer sent bye');\r\n this.fsm.force('idle');\r\n break;\r\n }\r\n });\r\n\r\n this.cleanups.push(\r\n () => this.pc.removeEventListener('icecandidate', onIceCandidate),\r\n () => this.pc.removeEventListener('iceconnectionstatechange', onIceState),\r\n unsubMsg,\r\n );\r\n }\r\n\r\n private teardownListeners(): void {\r\n for (const cleanup of this.cleanups) cleanup();\r\n this.cleanups.length = 0;\r\n }\r\n\r\n private async flushPendingCandidates(): Promise<void> {\r\n if (this.pendingCandidates.length === 0) return;\r\n const batch = [...this.pendingCandidates];\r\n this.pendingCandidates.length = 0;\r\n await Promise.allSettled(\r\n batch.map(c => this.pc.addIceCandidate(new RTCIceCandidate(c)))\r\n );\r\n }\r\n\r\n private withTimeout<T>(promise: Promise<T>, ms: number, msg: string): Promise<T> {\r\n return Promise.race([\r\n promise,\r\n new Promise<T>((_, reject) =>\r\n setTimeout(() => reject(new Error(`[SignalManager] ${msg}`)), ms)\r\n ),\r\n ]);\r\n }\r\n}\r\n//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJtYXBwaW5ncyI6IiIsIm5hbWVzIjpbXSwiaWdub3JlTGlzdCI6W10sInNvdXJjZXMiOltdLCJzb3VyY2VzQ29udGVudCI6W119"]}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { TypedEmitter } from '../utils/types';
|
|
2
|
+
/** 信令消息体 */
|
|
3
|
+
export interface SignalMessage {
|
|
4
|
+
type: 'offer' | 'answer' | 'ice-candidate' | 'bye';
|
|
5
|
+
sdp?: string;
|
|
6
|
+
candidate?: RTCIceCandidateInit;
|
|
7
|
+
}
|
|
8
|
+
/** 信令通道接口 — 由上层实现(Socket.IO / WebSocket / HTTP 长轮询等) */
|
|
9
|
+
export interface SignalChannel {
|
|
10
|
+
/** 发送信令到远端 */
|
|
11
|
+
send(data: SignalMessage): Promise<void>;
|
|
12
|
+
/** 接收远端信令,返回取消监听的函数 */
|
|
13
|
+
onMessage(handler: (data: SignalMessage) => void): () => void;
|
|
14
|
+
}
|
|
15
|
+
/** 信令通道事件 */
|
|
16
|
+
export type SignalChannelEvents = {
|
|
17
|
+
message: (msg: SignalMessage) => void;
|
|
18
|
+
error: (err: Error) => void;
|
|
19
|
+
closed: () => void;
|
|
20
|
+
};
|
|
21
|
+
/** 基于 TypedEmitter 的信令通道基类 */
|
|
22
|
+
export declare abstract class BaseSignalChannel extends TypedEmitter<SignalChannelEvents> implements SignalChannel {
|
|
23
|
+
abstract send(data: SignalMessage): Promise<void>;
|
|
24
|
+
abstract onMessage(handler: (data: SignalMessage) => void): () => void;
|
|
25
|
+
}
|
|
26
|
+
//# sourceMappingURL=types.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../../src/signal/types.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,MAAM,gBAAgB,CAAC;AAE9C,YAAY;AACZ,MAAM,WAAW,aAAa;IAC5B,IAAI,EAAE,OAAO,GAAG,QAAQ,GAAG,eAAe,GAAG,KAAK,CAAC;IACnD,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,SAAS,CAAC,EAAE,mBAAmB,CAAC;CACjC;AAED,wDAAwD;AACxD,MAAM,WAAW,aAAa;IAC5B,cAAc;IACd,IAAI,CAAC,IAAI,EAAE,aAAa,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACzC,uBAAuB;IACvB,SAAS,CAAC,OAAO,EAAE,CAAC,IAAI,EAAE,aAAa,KAAK,IAAI,GAAG,MAAM,IAAI,CAAC;CAC/D;AAED,aAAa;AACb,MAAM,MAAM,mBAAmB,GAAG;IAChC,OAAO,EAAE,CAAC,GAAG,EAAE,aAAa,KAAK,IAAI,CAAC;IACtC,KAAK,EAAE,CAAC,GAAG,EAAE,KAAK,KAAK,IAAI,CAAC;IAC5B,MAAM,EAAE,MAAM,IAAI,CAAC;CACpB,CAAC;AAEF,8BAA8B;AAC9B,8BAAsB,iBACpB,SAAQ,YAAY,CAAC,mBAAmB,CACxC,YAAW,aAAa;IAExB,QAAQ,CAAC,IAAI,CAAC,IAAI,EAAE,aAAa,GAAG,OAAO,CAAC,IAAI,CAAC;IACjD,QAAQ,CAAC,SAAS,CAAC,OAAO,EAAE,CAAC,IAAI,EAAE,aAAa,KAAK,IAAI,GAAG,MAAM,IAAI;CACvE"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"mappings":"AAAA,SAAS,oBAAoB;AAyBtB,MAAe,0BACZ,aAEV;AAGA","names":[],"ignoreList":[],"sources":["../../../src/signal/types.ts"],"sourcesContent":["import { TypedEmitter } from '../utils/types';\r\n\r\n/** 信令消息体 */\r\nexport interface SignalMessage {\r\n type: 'offer' | 'answer' | 'ice-candidate' | 'bye';\r\n sdp?: string;\r\n candidate?: RTCIceCandidateInit;\r\n}\r\n\r\n/** 信令通道接口 — 由上层实现(Socket.IO / WebSocket / HTTP 长轮询等) */\r\nexport interface SignalChannel {\r\n /** 发送信令到远端 */\r\n send(data: SignalMessage): Promise<void>;\r\n /** 接收远端信令,返回取消监听的函数 */\r\n onMessage(handler: (data: SignalMessage) => void): () => void;\r\n}\r\n\r\n/** 信令通道事件 */\r\nexport type SignalChannelEvents = {\r\n message: (msg: SignalMessage) => void;\r\n error: (err: Error) => void;\r\n closed: () => void;\r\n};\r\n\r\n/** 基于 TypedEmitter 的信令通道基类 */\r\nexport abstract class BaseSignalChannel\r\n extends TypedEmitter<SignalChannelEvents>\r\n implements SignalChannel\r\n{\r\n abstract send(data: SignalMessage): Promise<void>;\r\n abstract onMessage(handler: (data: SignalMessage) => void): () => void;\r\n}//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJtYXBwaW5ncyI6IiIsIm5hbWVzIjpbXSwiaWdub3JlTGlzdCI6W10sInNvdXJjZXMiOltdLCJzb3VyY2VzQ29udGVudCI6W119"]}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { IDisposable } from '../disposable';
|
|
2
|
+
import type { StatsMonitorOptions, StatsSnapshot, DiagnosticReport, DiagnosticIssue } from './types';
|
|
3
|
+
export type { StatsMonitorOptions, StatsSnapshot, DiagnosticReport, DiagnosticIssue };
|
|
4
|
+
/**
|
|
5
|
+
* Stats 监控器 — WebRTC internals 采集 + 诊断
|
|
6
|
+
*/
|
|
7
|
+
export declare class StatsMonitor implements IDisposable {
|
|
8
|
+
private logger;
|
|
9
|
+
private options;
|
|
10
|
+
private pc;
|
|
11
|
+
private timer;
|
|
12
|
+
private handlers;
|
|
13
|
+
private lastSnapshot;
|
|
14
|
+
private currentInterval;
|
|
15
|
+
constructor(pc: RTCPeerConnection, options?: StatsMonitorOptions);
|
|
16
|
+
/** 切换 PeerConnection(disconnect / reconnect 后 rebind) */
|
|
17
|
+
setPeerConnection(pc: RTCPeerConnection): void;
|
|
18
|
+
/** 启动采集 */
|
|
19
|
+
start(): void;
|
|
20
|
+
/** 停止采集 */
|
|
21
|
+
stop(): void;
|
|
22
|
+
/** 获取最近一次快照 */
|
|
23
|
+
getSnapshot(): StatsSnapshot | null;
|
|
24
|
+
/** 订阅指标变化 */
|
|
25
|
+
onStats(handler: (snapshot: StatsSnapshot) => void): () => void;
|
|
26
|
+
/** 生成诊断报告 */
|
|
27
|
+
diagnose(): DiagnosticReport;
|
|
28
|
+
dispose(): void;
|
|
29
|
+
private collect;
|
|
30
|
+
private parseStats;
|
|
31
|
+
}
|
|
32
|
+
//# sourceMappingURL=stats-monitor.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"stats-monitor.d.ts","sourceRoot":"","sources":["../../../src/stats/stats-monitor.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,eAAe,CAAC;AACjD,OAAO,KAAK,EACV,mBAAmB,EACnB,aAAa,EACb,gBAAgB,EAChB,eAAe,EAChB,MAAM,SAAS,CAAC;AAEjB,YAAY,EAAE,mBAAmB,EAAE,aAAa,EAAE,gBAAgB,EAAE,eAAe,EAAE,CAAC;AAOtF;;GAEG;AACH,qBAAa,YAAa,YAAW,WAAW;IAC9C,OAAO,CAAC,MAAM,CAAS;IACvB,OAAO,CAAC,OAAO,CAAgC;IAC/C,OAAO,CAAC,EAAE,CAAoB;IAC9B,OAAO,CAAC,KAAK,CAA4B;IACzC,OAAO,CAAC,QAAQ,CAA6C;IAC7D,OAAO,CAAC,YAAY,CAA8B;IAClD,OAAO,CAAC,eAAe,CAAS;gBAEpB,EAAE,EAAE,iBAAiB,EAAE,OAAO,CAAC,EAAE,mBAAmB;IAOhE,yDAAyD;IACzD,iBAAiB,CAAC,EAAE,EAAE,iBAAiB,GAAG,IAAI;IAK9C,WAAW;IACX,KAAK,IAAI,IAAI;IAOb,WAAW;IACX,IAAI,IAAI,IAAI;IAKZ,eAAe;IACf,WAAW,IAAI,aAAa,GAAG,IAAI;IAInC,aAAa;IACb,OAAO,CAAC,OAAO,EAAE,CAAC,QAAQ,EAAE,aAAa,KAAK,IAAI,GAAG,MAAM,IAAI;IAO/D,aAAa;IACb,QAAQ,IAAI,gBAAgB;IAsE5B,OAAO,IAAI,IAAI;YAQD,OAAO;IAwBrB,OAAO,CAAC,UAAU;CAqEnB"}
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { createLogger, createTimer } from "../utils/types";
|
|
2
|
+
const DEFAULT_OPTIONS = {
|
|
3
|
+
interval: 2e3,
|
|
4
|
+
idleInterval: 5e3
|
|
5
|
+
};
|
|
6
|
+
class StatsMonitor {
|
|
7
|
+
constructor(pc, options) {
|
|
8
|
+
this.timer = null;
|
|
9
|
+
this.handlers = [];
|
|
10
|
+
this.lastSnapshot = null;
|
|
11
|
+
this.pc = pc;
|
|
12
|
+
this.options = { ...DEFAULT_OPTIONS, ...options };
|
|
13
|
+
this.currentInterval = this.options.interval;
|
|
14
|
+
this.logger = createLogger();
|
|
15
|
+
}
|
|
16
|
+
/** 切换 PeerConnection(disconnect / reconnect 后 rebind) */
|
|
17
|
+
setPeerConnection(pc) {
|
|
18
|
+
this.pc = pc;
|
|
19
|
+
this.lastSnapshot = null;
|
|
20
|
+
}
|
|
21
|
+
/** 启动采集 */
|
|
22
|
+
start() {
|
|
23
|
+
if (this.timer)
|
|
24
|
+
return;
|
|
25
|
+
this.timer = createTimer(() => this.collect(), this.currentInterval);
|
|
26
|
+
this.collect();
|
|
27
|
+
this.logger.info("StatsMonitor started");
|
|
28
|
+
}
|
|
29
|
+
/** 停止采集 */
|
|
30
|
+
stop() {
|
|
31
|
+
this.timer?.dispose();
|
|
32
|
+
this.timer = null;
|
|
33
|
+
}
|
|
34
|
+
/** 获取最近一次快照 */
|
|
35
|
+
getSnapshot() {
|
|
36
|
+
return this.lastSnapshot;
|
|
37
|
+
}
|
|
38
|
+
/** 订阅指标变化 */
|
|
39
|
+
onStats(handler) {
|
|
40
|
+
this.handlers.push(handler);
|
|
41
|
+
return () => {
|
|
42
|
+
this.handlers = this.handlers.filter((h) => h !== handler);
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
/** 生成诊断报告 */
|
|
46
|
+
diagnose() {
|
|
47
|
+
if (!this.lastSnapshot) {
|
|
48
|
+
return { healthy: true, issues: [], summary: "No data collected yet" };
|
|
49
|
+
}
|
|
50
|
+
const issues = [];
|
|
51
|
+
const s = this.lastSnapshot;
|
|
52
|
+
if (s.packetsLostPercent > 5) {
|
|
53
|
+
issues.push({
|
|
54
|
+
severity: "warning",
|
|
55
|
+
metric: "packetsLostPercent",
|
|
56
|
+
value: `${s.packetsLostPercent.toFixed(1)}%`,
|
|
57
|
+
threshold: "5%",
|
|
58
|
+
suggestion: "考虑降低分辨率或帧率,检查网络稳定性"
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
if (s.packetsLostPercent > 15) {
|
|
62
|
+
issues.push({
|
|
63
|
+
severity: "error",
|
|
64
|
+
metric: "packetsLostPercent",
|
|
65
|
+
value: `${s.packetsLostPercent.toFixed(1)}%`,
|
|
66
|
+
threshold: "15%",
|
|
67
|
+
suggestion: "严重丢包,建议切换到 TURN 中继或检查防火墙设置"
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
if (s.roundTripTime > 300) {
|
|
71
|
+
issues.push({
|
|
72
|
+
severity: "warning",
|
|
73
|
+
metric: "roundTripTime",
|
|
74
|
+
value: `${s.roundTripTime.toFixed(0)}ms`,
|
|
75
|
+
threshold: "300ms",
|
|
76
|
+
suggestion: "网络延迟较高,影响实时交互体验"
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
if (s.iceState === "disconnected" || s.iceState === "failed") {
|
|
80
|
+
issues.push({
|
|
81
|
+
severity: "error",
|
|
82
|
+
metric: "iceState",
|
|
83
|
+
value: s.iceState,
|
|
84
|
+
threshold: "connected",
|
|
85
|
+
suggestion: "连接已断开,检查网络或重启连接"
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
if (s.frameRate > 0 && s.frameRate < 15) {
|
|
89
|
+
issues.push({
|
|
90
|
+
severity: "warning",
|
|
91
|
+
metric: "frameRate",
|
|
92
|
+
value: `${s.frameRate} fps`,
|
|
93
|
+
threshold: "15 fps",
|
|
94
|
+
suggestion: "编码帧率过低,尝试降低分辨率"
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
const healthy = issues.length === 0;
|
|
98
|
+
const summary = healthy ? "所有指标正常" : `检测到 ${issues.length} 个问题`;
|
|
99
|
+
return { healthy, issues, summary };
|
|
100
|
+
}
|
|
101
|
+
dispose() {
|
|
102
|
+
this.stop();
|
|
103
|
+
this.handlers.length = 0;
|
|
104
|
+
this.lastSnapshot = null;
|
|
105
|
+
}
|
|
106
|
+
// ── private ──
|
|
107
|
+
async collect() {
|
|
108
|
+
try {
|
|
109
|
+
const report = await this.pc.getStats();
|
|
110
|
+
const snapshot = this.parseStats(report);
|
|
111
|
+
if (snapshot.connectionState === "connected" && this.currentInterval !== this.options.idleInterval) {
|
|
112
|
+
this.currentInterval = this.options.idleInterval;
|
|
113
|
+
this.timer?.dispose();
|
|
114
|
+
this.timer = createTimer(() => this.collect(), this.currentInterval);
|
|
115
|
+
}
|
|
116
|
+
this.lastSnapshot = snapshot;
|
|
117
|
+
for (const handler of this.handlers) {
|
|
118
|
+
try {
|
|
119
|
+
handler(snapshot);
|
|
120
|
+
} catch {
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
} catch (err) {
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
parseStats(report) {
|
|
127
|
+
let bytesSent = 0;
|
|
128
|
+
let bytesReceived = 0;
|
|
129
|
+
let packetsLost = 0;
|
|
130
|
+
let roundTripTime = 0;
|
|
131
|
+
let jitter = 0;
|
|
132
|
+
let frameRate = 0;
|
|
133
|
+
let resolution = "unknown";
|
|
134
|
+
let codec = "unknown";
|
|
135
|
+
const statsArray = [];
|
|
136
|
+
report.forEach((stat) => statsArray.push(stat));
|
|
137
|
+
for (const stat of statsArray) {
|
|
138
|
+
switch (stat.type) {
|
|
139
|
+
case "outbound-rtp":
|
|
140
|
+
if (stat.kind === "video") {
|
|
141
|
+
bytesSent += stat.bytesSent ?? 0;
|
|
142
|
+
packetsLost += stat.packetsLost ?? 0;
|
|
143
|
+
frameRate = stat.framesPerSecond ?? 0;
|
|
144
|
+
}
|
|
145
|
+
break;
|
|
146
|
+
case "inbound-rtp":
|
|
147
|
+
if (stat.kind === "video") {
|
|
148
|
+
bytesReceived += stat.bytesReceived ?? 0;
|
|
149
|
+
jitter = (stat.jitter ?? 0) * 1e3;
|
|
150
|
+
frameRate = Math.max(frameRate, stat.framesPerSecond ?? 0);
|
|
151
|
+
resolution = stat.frameWidth && stat.frameHeight ? `${stat.frameWidth}x${stat.frameHeight}` : resolution;
|
|
152
|
+
}
|
|
153
|
+
break;
|
|
154
|
+
case "remote-inbound-rtp":
|
|
155
|
+
roundTripTime = (stat.roundTripTime ?? 0) * 1e3;
|
|
156
|
+
break;
|
|
157
|
+
case "codec":
|
|
158
|
+
if (stat.mimeType?.startsWith("video/")) {
|
|
159
|
+
codec = stat.mimeType;
|
|
160
|
+
}
|
|
161
|
+
break;
|
|
162
|
+
case "candidate-pair":
|
|
163
|
+
if (stat.state === "succeeded" && stat.nominated) {
|
|
164
|
+
roundTripTime = roundTripTime || (stat.currentRoundTripTime ?? 0) * 1e3;
|
|
165
|
+
}
|
|
166
|
+
break;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
const totalSent = bytesSent;
|
|
170
|
+
const packetsLostPercent = totalSent > 0 ? packetsLost / (totalSent / 1200 + packetsLost) * 100 : 0;
|
|
171
|
+
return {
|
|
172
|
+
timestamp: Date.now(),
|
|
173
|
+
bytesSent,
|
|
174
|
+
bytesReceived,
|
|
175
|
+
packetsLost,
|
|
176
|
+
packetsLostPercent: Math.round(packetsLostPercent * 100) / 100,
|
|
177
|
+
roundTripTime: Math.round(roundTripTime),
|
|
178
|
+
jitter: Math.round(jitter * 100) / 100,
|
|
179
|
+
frameRate: Math.round(frameRate),
|
|
180
|
+
resolution,
|
|
181
|
+
codec: codec.split("/").pop() ?? codec,
|
|
182
|
+
iceState: this.pc.iceConnectionState,
|
|
183
|
+
connectionState: this.pc.connectionState
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
export {
|
|
188
|
+
StatsMonitor
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
//# sourceMappingURL=stats-monitor.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"mappings":"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","names":[],"ignoreList":[],"sources":["../../../src/stats/stats-monitor.ts"],"sourcesContent":["import { createLogger, type Logger, createTimer } from '../utils/types';\r\nimport type { IDisposable } from '../disposable';\r\nimport type {\r\n StatsMonitorOptions,\r\n StatsSnapshot,\r\n DiagnosticReport,\r\n DiagnosticIssue,\r\n} from './types';\r\n\r\nexport type { StatsMonitorOptions, StatsSnapshot, DiagnosticReport, DiagnosticIssue };\r\n\r\nconst DEFAULT_OPTIONS: Required<StatsMonitorOptions> = {\r\n interval: 2000,\r\n idleInterval: 5000,\r\n};\r\n\r\n/**\r\n * Stats 监控器 — WebRTC internals 采集 + 诊断\r\n */\r\nexport class StatsMonitor implements IDisposable {\r\n private logger: Logger;\r\n private options: Required<StatsMonitorOptions>;\r\n private pc: RTCPeerConnection;\r\n private timer: IDisposable | null = null;\r\n private handlers: ((snapshot: StatsSnapshot) => void)[] = [];\r\n private lastSnapshot: StatsSnapshot | null = null;\r\n private currentInterval: number;\r\n\r\n constructor(pc: RTCPeerConnection, options?: StatsMonitorOptions) {\r\n this.pc = pc;\r\n this.options = { ...DEFAULT_OPTIONS, ...options };\r\n this.currentInterval = this.options.interval;\r\n this.logger = createLogger();\r\n }\r\n\r\n /** 切换 PeerConnection(disconnect / reconnect 后 rebind) */\r\n setPeerConnection(pc: RTCPeerConnection): void {\r\n this.pc = pc;\r\n this.lastSnapshot = null;\r\n }\r\n\r\n /** 启动采集 */\r\n start(): void {\r\n if (this.timer) return;\r\n this.timer = createTimer(() => this.collect(), this.currentInterval);\r\n this.collect(); // 立即采集一次\r\n this.logger.info('StatsMonitor started');\r\n }\r\n\r\n /** 停止采集 */\r\n stop(): void {\r\n this.timer?.dispose();\r\n this.timer = null;\r\n }\r\n\r\n /** 获取最近一次快照 */\r\n getSnapshot(): StatsSnapshot | null {\r\n return this.lastSnapshot;\r\n }\r\n\r\n /** 订阅指标变化 */\r\n onStats(handler: (snapshot: StatsSnapshot) => void): () => void {\r\n this.handlers.push(handler);\r\n return () => {\r\n this.handlers = this.handlers.filter(h => h !== handler);\r\n };\r\n }\r\n\r\n /** 生成诊断报告 */\r\n diagnose(): DiagnosticReport {\r\n if (!this.lastSnapshot) {\r\n return { healthy: true, issues: [], summary: 'No data collected yet' };\r\n }\r\n const issues: DiagnosticIssue[] = [];\r\n const s = this.lastSnapshot;\r\n\r\n // 丢包率 > 5%\r\n if (s.packetsLostPercent > 5) {\r\n issues.push({\r\n severity: 'warning',\r\n metric: 'packetsLostPercent',\r\n value: `${s.packetsLostPercent.toFixed(1)}%`,\r\n threshold: '5%',\r\n suggestion: '考虑降低分辨率或帧率,检查网络稳定性',\r\n });\r\n }\r\n\r\n // 丢包率 > 15%\r\n if (s.packetsLostPercent > 15) {\r\n issues.push({\r\n severity: 'error',\r\n metric: 'packetsLostPercent',\r\n value: `${s.packetsLostPercent.toFixed(1)}%`,\r\n threshold: '15%',\r\n suggestion: '严重丢包,建议切换到 TURN 中继或检查防火墙设置',\r\n });\r\n }\r\n\r\n // RTT > 300ms\r\n if (s.roundTripTime > 300) {\r\n issues.push({\r\n severity: 'warning',\r\n metric: 'roundTripTime',\r\n value: `${s.roundTripTime.toFixed(0)}ms`,\r\n threshold: '300ms',\r\n suggestion: '网络延迟较高,影响实时交互体验',\r\n });\r\n }\r\n\r\n // ICE 断开\r\n if (s.iceState === 'disconnected' || s.iceState === 'failed') {\r\n issues.push({\r\n severity: 'error',\r\n metric: 'iceState',\r\n value: s.iceState,\r\n threshold: 'connected',\r\n suggestion: '连接已断开,检查网络或重启连接',\r\n });\r\n }\r\n\r\n // 帧率过低\r\n if (s.frameRate > 0 && s.frameRate < 15) {\r\n issues.push({\r\n severity: 'warning',\r\n metric: 'frameRate',\r\n value: `${s.frameRate} fps`,\r\n threshold: '15 fps',\r\n suggestion: '编码帧率过低,尝试降低分辨率',\r\n });\r\n }\r\n\r\n const healthy = issues.length === 0;\r\n const summary = healthy\r\n ? '所有指标正常'\r\n : `检测到 ${issues.length} 个问题`;\r\n\r\n return { healthy, issues, summary };\r\n }\r\n\r\n dispose(): void {\r\n this.stop();\r\n this.handlers.length = 0;\r\n this.lastSnapshot = null;\r\n }\r\n\r\n // ── private ──\r\n\r\n private async collect(): Promise<void> {\r\n try {\r\n const report = await this.pc.getStats();\r\n const snapshot = this.parseStats(report);\r\n\r\n // 连接后降频\r\n if (\r\n snapshot.connectionState === 'connected' &&\r\n this.currentInterval !== this.options.idleInterval\r\n ) {\r\n this.currentInterval = this.options.idleInterval;\r\n this.timer?.dispose();\r\n this.timer = createTimer(() => this.collect(), this.currentInterval);\r\n }\r\n\r\n this.lastSnapshot = snapshot;\r\n for (const handler of this.handlers) {\r\n try { handler(snapshot); } catch {}\r\n }\r\n } catch (err) {\r\n // getStats 失败不中断\r\n }\r\n }\r\n\r\n private parseStats(report: RTCStatsReport): StatsSnapshot {\r\n let bytesSent = 0;\r\n let bytesReceived = 0;\r\n let packetsLost = 0;\r\n let roundTripTime = 0;\r\n let jitter = 0;\r\n let frameRate = 0;\r\n let resolution = 'unknown';\r\n let codec = 'unknown';\r\n\r\n // 使用类型断言处理 RTCStatsReport 的迭代\r\n const statsArray: any[] = [];\r\n report.forEach((stat: any) => statsArray.push(stat));\r\n \r\n for (const stat of statsArray) {\r\n switch (stat.type) {\r\n case 'outbound-rtp':\r\n if (stat.kind === 'video') {\r\n bytesSent += stat.bytesSent ?? 0;\r\n packetsLost += stat.packetsLost ?? 0;\r\n frameRate = stat.framesPerSecond ?? 0;\r\n }\r\n break;\r\n case 'inbound-rtp':\r\n if (stat.kind === 'video') {\r\n bytesReceived += stat.bytesReceived ?? 0;\r\n jitter = (stat.jitter ?? 0) * 1000; // seconds → ms\r\n frameRate = Math.max(frameRate, stat.framesPerSecond ?? 0);\r\n resolution = stat.frameWidth && stat.frameHeight\r\n ? `${stat.frameWidth}x${stat.frameHeight}`\r\n : resolution;\r\n }\r\n break;\r\n case 'remote-inbound-rtp':\r\n roundTripTime = (stat.roundTripTime ?? 0) * 1000;\r\n break;\r\n case 'codec':\r\n if (stat.mimeType?.startsWith('video/')) {\r\n codec = stat.mimeType;\r\n }\r\n break;\r\n case 'candidate-pair':\r\n if (stat.state === 'succeeded' && stat.nominated) {\r\n roundTripTime = roundTripTime || (stat.currentRoundTripTime ?? 0) * 1000;\r\n }\r\n break;\r\n }\r\n }\r\n\r\n const totalSent = bytesSent;\r\n const packetsLostPercent = totalSent > 0\r\n ? (packetsLost / ((totalSent / 1200) + packetsLost)) * 100\r\n : 0;\r\n\r\n return {\r\n timestamp: Date.now(),\r\n bytesSent,\r\n bytesReceived,\r\n packetsLost,\r\n packetsLostPercent: Math.round(packetsLostPercent * 100) / 100,\r\n roundTripTime: Math.round(roundTripTime),\r\n jitter: Math.round(jitter * 100) / 100,\r\n frameRate: Math.round(frameRate),\r\n resolution,\r\n codec: codec.split('/').pop() ?? codec,\r\n iceState: this.pc.iceConnectionState,\r\n connectionState: this.pc.connectionState,\r\n };\r\n }\r\n}//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJtYXBwaW5ncyI6IiIsIm5hbWVzIjpbXSwiaWdub3JlTGlzdCI6W10sInNvdXJjZXMiOltdLCJzb3VyY2VzQ29udGVudCI6W119"]}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
export interface StatsMonitorOptions {
|
|
2
|
+
/** 采集间隔 (ms),默认 2000 */
|
|
3
|
+
interval?: number;
|
|
4
|
+
/** 连接后降频 (ms),默认 5000 */
|
|
5
|
+
idleInterval?: number;
|
|
6
|
+
}
|
|
7
|
+
export interface StatsSnapshot {
|
|
8
|
+
timestamp: number;
|
|
9
|
+
bytesSent: number;
|
|
10
|
+
bytesReceived: number;
|
|
11
|
+
packetsLost: number;
|
|
12
|
+
packetsLostPercent: number;
|
|
13
|
+
roundTripTime: number;
|
|
14
|
+
jitter: number;
|
|
15
|
+
frameRate: number;
|
|
16
|
+
resolution: string;
|
|
17
|
+
codec: string;
|
|
18
|
+
iceState: RTCIceConnectionState;
|
|
19
|
+
connectionState: RTCPeerConnectionState;
|
|
20
|
+
}
|
|
21
|
+
export interface DiagnosticReport {
|
|
22
|
+
healthy: boolean;
|
|
23
|
+
issues: DiagnosticIssue[];
|
|
24
|
+
summary: string;
|
|
25
|
+
}
|
|
26
|
+
export interface DiagnosticIssue {
|
|
27
|
+
severity: 'warning' | 'error';
|
|
28
|
+
metric: string;
|
|
29
|
+
value: number | string;
|
|
30
|
+
threshold: number | string;
|
|
31
|
+
suggestion: string;
|
|
32
|
+
}
|
|
33
|
+
//# sourceMappingURL=types.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../../src/stats/types.ts"],"names":[],"mappings":"AAAA,MAAM,WAAW,mBAAmB;IAClC,wBAAwB;IACxB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,yBAAyB;IACzB,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB;AAED,MAAM,WAAW,aAAa;IAC5B,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,EAAE,MAAM,CAAC;IAClB,aAAa,EAAE,MAAM,CAAC;IACtB,WAAW,EAAE,MAAM,CAAC;IACpB,kBAAkB,EAAE,MAAM,CAAC;IAC3B,aAAa,EAAE,MAAM,CAAC;IACtB,MAAM,EAAE,MAAM,CAAC;IACf,SAAS,EAAE,MAAM,CAAC;IAClB,UAAU,EAAE,MAAM,CAAC;IACnB,KAAK,EAAE,MAAM,CAAC;IACd,QAAQ,EAAE,qBAAqB,CAAC;IAChC,eAAe,EAAE,sBAAsB,CAAC;CACzC;AAED,MAAM,WAAW,gBAAgB;IAC/B,OAAO,EAAE,OAAO,CAAC;IACjB,MAAM,EAAE,eAAe,EAAE,CAAC;IAC1B,OAAO,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,eAAe;IAC9B,QAAQ,EAAE,SAAS,GAAG,OAAO,CAAC;IAC9B,MAAM,EAAE,MAAM,CAAC;IACf,KAAK,EAAE,MAAM,GAAG,MAAM,CAAC;IACvB,SAAS,EAAE,MAAM,GAAG,MAAM,CAAC;IAC3B,UAAU,EAAE,MAAM,CAAC;CACpB"}
|
|
File without changes
|