whepts 1.0.0 → 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/whep.ts ADDED
@@ -0,0 +1,404 @@
1
+ import type { ParsedOffer } from './utils/sdp'
2
+ import VisibilityObserver from '~/utils/observer'
3
+ import { ErrorTypes, WebRTCError } from './errors'
4
+ import { FlowCheck } from './utils/flow-check'
5
+ import { SdpUtils } from './utils/sdp'
6
+ import { WebRtcUtils } from './utils/webrtc'
7
+
8
+ /**
9
+ * Configuration interface for WebRTCWhep.
10
+ */
11
+ export interface Conf {
12
+ /** Absolute URL of the WHEP endpoint */
13
+ url: string
14
+ /** Media player container */
15
+ container: HTMLMediaElement
16
+ /** Username for authentication */
17
+ user?: string
18
+ /** Password for authentication */
19
+ pass?: string
20
+ /** Token for authentication */
21
+ token?: string
22
+ /** ice server list */
23
+ iceServers?: RTCIceServer[]
24
+ /** Called when there's an error */
25
+ onError?: (err: WebRTCError) => void
26
+ }
27
+
28
+ /** Extend RTCConfiguration to include experimental properties */
29
+ declare global {
30
+ interface RTCConfiguration {
31
+ sdpSemantics?: 'plan-b' | 'unified-plan'
32
+ }
33
+
34
+ interface RTCIceServer {
35
+ credentialType?: 'password'
36
+ }
37
+ }
38
+
39
+ /** WebRTC/WHEP reader. */
40
+ export default class WebRTCWhep {
41
+ private retryPause: number = 2000
42
+ private conf: Conf
43
+ private state: 'getting_codecs' | 'running' | 'restarting' | 'closed' | 'failed'
44
+ private restartTimeout?: NodeJS.Timeout
45
+ private pc?: RTCPeerConnection
46
+ private offerData?: ParsedOffer
47
+ private sessionUrl?: string
48
+ private queuedCandidates: RTCIceCandidate[] = []
49
+ private nonAdvertisedCodecs: string[] = []
50
+ private observer: VisibilityObserver
51
+ private stream?: MediaStream
52
+ private flowCheck: FlowCheck
53
+
54
+ /**
55
+ * Create a WebRTCWhep.
56
+ * @param {Conf} conf - Configuration.
57
+ */
58
+ constructor(conf: Conf) {
59
+ this.conf = conf
60
+ this.state = 'getting_codecs'
61
+ this.observer = new VisibilityObserver()
62
+ this.flowCheck = new FlowCheck({
63
+ interval: 5000,
64
+ onError: (err: WebRTCError) => this.handleError(err),
65
+ })
66
+ this.getNonAdvertisedCodecs()
67
+ }
68
+
69
+ /**
70
+ * 媒体是否正常
71
+ */
72
+ get isRunning(): boolean {
73
+ return this.state === 'running'
74
+ }
75
+
76
+ /**
77
+ * Close the reader and all its resources.
78
+ */
79
+ close(): void {
80
+ this.state = 'closed'
81
+ this.pc?.close()
82
+
83
+ this.observer.stop()
84
+ this.flowCheck.stop()
85
+ if (this.restartTimeout) {
86
+ clearTimeout(this.restartTimeout)
87
+ }
88
+ }
89
+
90
+ /**
91
+ * Handle errors.
92
+ */
93
+ private handleError(err: Error | WebRTCError): void {
94
+ this.flowCheck.stop()
95
+
96
+ if (this.state === 'getting_codecs') {
97
+ this.state = 'failed'
98
+ }
99
+ else if (err instanceof WebRTCError && err.type === ErrorTypes.SIGNAL_ERROR) {
100
+ this.state = 'failed'
101
+ }
102
+ else if (this.state === 'running') {
103
+ this.pc?.close()
104
+ this.pc = undefined
105
+
106
+ this.offerData = undefined
107
+ this.queuedCandidates = []
108
+
109
+ if (this.sessionUrl) {
110
+ fetch(this.sessionUrl, {
111
+ method: 'DELETE',
112
+ })
113
+ this.sessionUrl = undefined
114
+ }
115
+
116
+ this.state = 'restarting'
117
+
118
+ this.restartTimeout = setTimeout(() => {
119
+ this.restartTimeout = undefined
120
+ this.state = 'running'
121
+ this.start()
122
+ }, this.retryPause)
123
+
124
+ err.message = `${err.message}, retrying in some seconds`
125
+ }
126
+
127
+ if (this.conf.onError) {
128
+ if (err instanceof WebRTCError) {
129
+ this.conf.onError(err)
130
+ }
131
+ else {
132
+ this.conf.onError(new WebRTCError(ErrorTypes.OTHER_ERROR, err.message))
133
+ }
134
+ }
135
+ }
136
+
137
+ /**
138
+ * Get non-advertised codecs.
139
+ */
140
+ private getNonAdvertisedCodecs(): void {
141
+ Promise.all(
142
+ [
143
+ ['pcma/8000/2'],
144
+ ['multiopus/48000/6', 'channel_mapping=0,4,1,2,3,5;num_streams=4;coupled_streams=2'],
145
+ ['L16/48000/2'],
146
+ ].map(c => WebRtcUtils.supportsNonAdvertisedCodec(c[0], c[1]).then(r => (r ? c[0] : false))),
147
+ )
148
+ .then(c => c.filter(e => e !== false))
149
+ .then((codecs) => {
150
+ if (this.state !== 'getting_codecs') {
151
+ throw new WebRTCError(ErrorTypes.STATE_ERROR, 'closed')
152
+ }
153
+
154
+ this.nonAdvertisedCodecs = codecs as string[]
155
+ this.state = 'running'
156
+ this.start()
157
+ })
158
+ .catch(err => this.handleError(err))
159
+ }
160
+
161
+ /**
162
+ * Start the WebRTC session.
163
+ */
164
+ private start(): void {
165
+ this.requestICEServers()
166
+ .then(iceServers => this.setupPeerConnection(iceServers))
167
+ .then(offer => this.sendOffer(offer))
168
+ .then(answer => this.setAnswer(answer))
169
+ .catch(err => this.handleError(err))
170
+ }
171
+
172
+ /**
173
+ * Generate an authorization header.
174
+ */
175
+ private authHeader(): Record<string, string> {
176
+ if (this.conf.user && this.conf.user !== '') {
177
+ const credentials = btoa(`${this.conf.user}:${this.conf.pass}`)
178
+ return { Authorization: `Basic ${credentials}` }
179
+ }
180
+ if (this.conf.token && this.conf.token !== '') {
181
+ return { Authorization: `Bearer ${this.conf.token}` }
182
+ }
183
+ return {}
184
+ }
185
+
186
+ /**
187
+ * Request ICE servers from the endpoint.
188
+ */
189
+ private async requestICEServers(): Promise<RTCIceServer[]> {
190
+ if (this.conf.iceServers && this.conf.iceServers.length > 0) {
191
+ return this.conf.iceServers
192
+ }
193
+
194
+ return fetch(this.conf.url, {
195
+ method: 'OPTIONS',
196
+ headers: {
197
+ ...this.authHeader(),
198
+ },
199
+ }).then(res => WebRtcUtils.linkToIceServers(res.headers.get('Link')))
200
+ }
201
+
202
+ /**
203
+ * Setup a peer connection.
204
+ */
205
+ private async setupPeerConnection(iceServers: RTCIceServer[]): Promise<string> {
206
+ if (this.state !== 'running') {
207
+ throw new WebRTCError(ErrorTypes.STATE_ERROR, 'closed')
208
+ }
209
+
210
+ const pc = new RTCPeerConnection({
211
+ iceServers,
212
+ // https://webrtc.org/getting-started/unified-plan-transition-guide
213
+ sdpSemantics: 'unified-plan',
214
+ })
215
+ this.pc = pc
216
+ this.flowCheck.setPeerConnection(pc)
217
+
218
+ const direction: RTCRtpTransceiverDirection = 'recvonly'
219
+ pc.addTransceiver('video', { direction })
220
+ pc.addTransceiver('audio', { direction })
221
+
222
+ pc.onicecandidate = (evt: RTCPeerConnectionIceEvent) => this.onLocalCandidate(evt)
223
+ pc.onconnectionstatechange = () => this.onConnectionState()
224
+ pc.ontrack = (evt: RTCTrackEvent) => this.onTrack(evt)
225
+
226
+ return pc.createOffer().then((offer) => {
227
+ if (!offer.sdp) {
228
+ throw new WebRTCError(ErrorTypes.SIGNAL_ERROR, 'Failed to create offer SDP')
229
+ }
230
+
231
+ offer.sdp = SdpUtils.editOffer(offer.sdp, this.nonAdvertisedCodecs)
232
+ this.offerData = SdpUtils.parseOffer(offer.sdp)
233
+
234
+ return pc.setLocalDescription(offer).then(() => offer.sdp!)
235
+ })
236
+ }
237
+
238
+ /**
239
+ * Send an offer to the endpoint.
240
+ */
241
+ private sendOffer(offer: string): Promise<string> {
242
+ if (this.state !== 'running') {
243
+ throw new WebRTCError(ErrorTypes.STATE_ERROR, 'closed')
244
+ }
245
+
246
+ return fetch(this.conf.url, {
247
+ method: 'POST',
248
+ headers: {
249
+ ...this.authHeader(),
250
+ 'Content-Type': 'application/sdp',
251
+ },
252
+ body: offer,
253
+ }).then((res) => {
254
+ switch (res.status) {
255
+ case 201:
256
+ break
257
+ case 404:
258
+ throw new WebRTCError(ErrorTypes.NETWORK_ERROR, 'stream not found')
259
+ case 406:
260
+ throw new WebRTCError(ErrorTypes.NETWORK_ERROR, 'stream not supported')
261
+ case 400:
262
+ return res.json().then((e: { error: string }) => {
263
+ throw new WebRTCError(ErrorTypes.NETWORK_ERROR, e.error)
264
+ })
265
+ default:
266
+ throw new WebRTCError(ErrorTypes.NETWORK_ERROR, `bad status code ${res.status}`)
267
+ }
268
+
269
+ const location = res.headers.get('Location')
270
+ if (location) {
271
+ this.sessionUrl = new URL(location, this.conf.url).toString()
272
+ }
273
+
274
+ return res.text()
275
+ })
276
+ }
277
+
278
+ /**
279
+ * Set a remote answer.
280
+ */
281
+ private setAnswer(answer: string): Promise<void> {
282
+ if (this.state !== 'running') {
283
+ throw new WebRTCError(ErrorTypes.STATE_ERROR, 'closed')
284
+ }
285
+
286
+ return this.pc!.setRemoteDescription(
287
+ new RTCSessionDescription({
288
+ type: 'answer',
289
+ sdp: answer,
290
+ }),
291
+ ).then(() => {
292
+ if (this.state !== 'running') {
293
+ return
294
+ }
295
+
296
+ if (this.queuedCandidates.length !== 0) {
297
+ this.sendLocalCandidates(this.queuedCandidates)
298
+ this.queuedCandidates = []
299
+ }
300
+ })
301
+ }
302
+
303
+ /**
304
+ * Handle local ICE candidates.
305
+ */
306
+ private onLocalCandidate(evt: RTCPeerConnectionIceEvent): void {
307
+ if (this.state !== 'running') {
308
+ return
309
+ }
310
+
311
+ if (evt.candidate) {
312
+ if (this.sessionUrl) {
313
+ this.sendLocalCandidates([evt.candidate])
314
+ }
315
+ else {
316
+ this.queuedCandidates.push(evt.candidate)
317
+ }
318
+ }
319
+ }
320
+
321
+ /**
322
+ * Send local ICE candidates to the endpoint.
323
+ */
324
+ private sendLocalCandidates(candidates: RTCIceCandidate[]): void {
325
+ if (!this.sessionUrl || !this.offerData) {
326
+ return
327
+ }
328
+
329
+ fetch(this.sessionUrl, {
330
+ method: 'PATCH',
331
+ headers: {
332
+ 'Content-Type': 'application/trickle-ice-sdpfrag',
333
+ 'If-Match': '*',
334
+ },
335
+ body: SdpUtils.generateSdpFragment(this.offerData, candidates),
336
+ })
337
+ .then((res) => {
338
+ switch (res.status) {
339
+ case 204:
340
+ break
341
+ case 404:
342
+ throw new WebRTCError(ErrorTypes.NETWORK_ERROR, 'stream not found')
343
+ default:
344
+ throw new WebRTCError(ErrorTypes.NETWORK_ERROR, `bad status code ${res.status}`)
345
+ }
346
+ })
347
+ .catch(err => this.handleError(err))
348
+ }
349
+
350
+ /**
351
+ * Handle peer connection state changes.
352
+ */
353
+ private onConnectionState(): void {
354
+ if (this.state !== 'running' || !this.pc) {
355
+ return
356
+ }
357
+
358
+ // "closed" can arrive before "failed" and without
359
+ // the close() method being called at all.
360
+ // It happens when the other peer sends a termination
361
+ // message like a DTLS CloseNotify.
362
+ if (this.pc.connectionState === 'failed' || this.pc.connectionState === 'closed') {
363
+ this.handleError(new WebRTCError(ErrorTypes.OTHER_ERROR, 'peer connection closed'))
364
+ }
365
+ else if (this.pc.connectionState === 'connected') {
366
+ this.flowCheck.start()
367
+ }
368
+ }
369
+
370
+ /**
371
+ * Handle incoming tracks.
372
+ */
373
+ private onTrack(evt: RTCTrackEvent): void {
374
+ this.stream = evt.streams[0]
375
+ this.observer.start(this.conf.container, (isIntersecting) => {
376
+ if (isIntersecting)
377
+ this.resume()
378
+ else
379
+ this.pause()
380
+ })
381
+ }
382
+
383
+ /**
384
+ * 流是否为空
385
+ */
386
+ get paused() {
387
+ return this.conf.container.srcObject === null
388
+ }
389
+
390
+ /**
391
+ * 暂停播放
392
+ */
393
+ pause() {
394
+ this.conf.container.srcObject = null
395
+ }
396
+
397
+ /**
398
+ * 恢复播放
399
+ */
400
+ resume() {
401
+ if (this.stream && this.paused)
402
+ this.conf.container.srcObject = this.stream
403
+ }
404
+ }