UNPKG

raiden-ts

Version:

Raiden Light Client Typescript/Javascript SDK

442 lines 25.6 kB
"use strict"; var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { if (k2 === undefined) k2 = k; var desc = Object.getOwnPropertyDescriptor(m, k); if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { desc = { enumerable: true, get: function() { return m[k]; } }; } Object.defineProperty(o, k2, desc); }) : (function(o, m, k, k2) { if (k2 === undefined) k2 = k; o[k2] = m[k]; })); var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { Object.defineProperty(o, "default", { enumerable: true, value: v }); }) : function(o, v) { o["default"] = v; }); var __importStar = (this && this.__importStar) || function (mod) { if (mod && mod.__esModule) return mod; var result = {}; if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k); __setModuleDefault(result, mod); return result; }; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.rtcConnectionManagerEpic = void 0; const t = __importStar(require("io-ts")); const constant_1 = __importDefault(require("lodash/constant")); const isEqual_1 = __importDefault(require("lodash/isEqual")); const rxjs_1 = require("rxjs"); const operators_1 = require("rxjs/operators"); const actions_1 = require("../../channels/actions"); const constants_1 = require("../../constants"); const actions_2 = require("../../messages/actions"); const utils_1 = require("../../messages/utils"); const actions_3 = require("../../transfers/actions"); const utils_2 = require("../../transfers/epics/utils"); const state_1 = require("../../transfers/state"); const utils_3 = require("../../transfers/utils"); const actions_4 = require("../../utils/actions"); const data_1 = require("../../utils/data"); const error_1 = require("../../utils/error"); const rx_1 = require("../../utils/rx"); const types_1 = require("../../utils/types"); const actions_5 = require("../actions"); const utils_4 = require("../utils"); const rtcMatrixMsgType = 'm.notice'; var RtcEventType; (function (RtcEventType) { RtcEventType["offer"] = "offer"; RtcEventType["answer"] = "answer"; RtcEventType["candidates"] = "candidates"; RtcEventType["hangup"] = "hangup"; })(RtcEventType || (RtcEventType = {})); const RtcOffer = t.readonly(t.intersection([ t.type({ type: t.literal(RtcEventType.offer), sdp: t.string }), t.partial({ call_id: t.string }), ])); const RtcAnswer = t.readonly(t.intersection([ t.type({ type: t.literal(RtcEventType.answer), sdp: t.string }), t.partial({ call_id: t.string }), ])); const RtcCandidates = t.readonly(t.intersection([ t.type({ type: t.literal(RtcEventType.candidates), candidates: t.readonlyArray(t.unknown) }), t.partial({ call_id: t.string }), ])); const RtcHangup = t.readonly(t.intersection([ t.type({ type: t.literal(RtcEventType.hangup) }), t.partial({ call_id: t.string }), ])); const rtcCodecs = { [RtcEventType.offer]: RtcOffer, [RtcEventType.answer]: RtcAnswer, [RtcEventType.candidates]: RtcCandidates, [RtcEventType.hangup]: RtcHangup, }; function isConnectionInfo(value) { return Array.isArray(value) && value.length === 3 && value[1]?.readyState; } const hangUpError = 'peer hung up'; const closedError = 'channel closed'; const pingMsg = 'ping'; const failedConnectionStates = ['failed', 'closed', 'disconnected']; // fetches and caches matrix set turnServer const _matrixIceServersCache = new WeakMap(); async function getMatrixIceServers(matrix) { const cached = _matrixIceServersCache.get(matrix); if (cached && Date.now() < cached[0]) return cached[1]; const fetched = (await matrix.turnServer().catch(() => undefined)); // if request returns nothing, caches empty list for 1h let expire = Date.now() + 36e5; const servers = []; if (fetched && 'uris' in fetched) { servers.push({ urls: fetched.uris, username: fetched.username, credentialType: 'password', credential: fetched.password, }); expire = Date.now() + fetched.ttl * 1e3; } _matrixIceServersCache.set(matrix, [expire, servers]); return servers; } // returns a stream of filtered, valid Rtc events coming on matrix messages function matrixWebrtcEvents$(action$, type, { peer, callId }, { log } = {}) { return action$.pipe((0, operators_1.filter)(actions_2.messageReceived.is), (0, operators_1.filter)(({ meta, payload }) => meta.address === peer.meta.address && payload.msgtype === rtcMatrixMsgType), (0, operators_1.mergeMap)(function* (action) { try { const json = (0, data_1.jsonParse)(action.payload.text); if (json['type'] === type) yield (0, types_1.decode)(rtcCodecs[type], json); } catch (error) { log?.info('Failed to decode WebRTC signaling message, ignoring', { text: action.payload.text, peerAddr: action.meta.address, peerId: action.payload.userId, error, }); } }), (0, operators_1.filter)((e) => !callId || e.call_id === callId)); } // setup candidates$ handlers: receives & sets candidates from peer, sends ours to them function handleCandidates$(connection, action$, info, { log }) { return (0, rxjs_1.merge)( // when receiving candidates from peer, add it locally matrixWebrtcEvents$(action$, RtcEventType.candidates, info, { log }).pipe((0, operators_1.tap)((e) => log.debug('RTC: received candidates', info.callId, e.candidates)), (0, operators_1.mergeMap)((event) => (0, rxjs_1.from)((event.candidates ?? []))), (0, operators_1.mergeMap)(async (candidate) => { try { await connection.addIceCandidate(candidate); } catch (err) { log.warn('RTC: error setting candidate, ignoring', info.callId, connection.connectionState, candidate, err); } }), (0, operators_1.ignoreElements)()), // when seeing an icecandidate, send it to peer (0, rxjs_1.fromEvent)(connection, 'icecandidate').pipe((0, operators_1.pluck)('candidate'), (0, operators_1.takeWhile)(types_1.isntNil), (0, operators_1.bufferTime)(10), (0, operators_1.filter)((candidates) => candidates.length > 0), (0, operators_1.tap)((e) => log.debug('RTC: got candidates', info.callId, e)), (0, operators_1.map)((candidates) => { const body = { type: RtcEventType.candidates, candidates, call_id: info.callId, }; return actions_2.messageSend.request({ message: (0, data_1.jsonStringify)(body), msgtype: rtcMatrixMsgType, userId: info.peer.payload.userId, }, { address: info.peer.meta.address, msgId: (0, utils_3.makeMessageId)().toString() }); }))); } // extracted helper of [listenDataChannel] function makeDataChannelObservable([connection, dataChannel, info], [action$, open$], { httpTimeout }, deps) { return (0, rxjs_1.merge)((0, rxjs_1.fromEvent)(connection, 'connectionstatechange').pipe((0, operators_1.startWith)(null), (0, operators_1.mergeMap)(() => { if (failedConnectionStates.includes(connection.connectionState)) throw new Error('RTC: connection failed'); return rxjs_1.EMPTY; })), (0, rxjs_1.fromEvent)(dataChannel, 'close').pipe((0, operators_1.map)(() => { throw new Error(closedError); })), (0, rxjs_1.fromEvent)(dataChannel, 'error').pipe((0, operators_1.pluck)('error'), (0, operators_1.mergeMap)(rxjs_1.throwError)), matrixWebrtcEvents$(action$, RtcEventType.hangup, info, deps).pipe((0, operators_1.map)(() => { throw new Error(hangUpError); })), (0, rxjs_1.fromEvent)(dataChannel, 'open').pipe((0, operators_1.take)(1), (0, rx_1.timeoutFirst)(httpTimeout), (0, operators_1.map)(() => { deps.log.info('RTC: dataChannel open', dataChannel.label); info.callId = dataChannel.label; // when connected, sends a first message dataChannel.send(pingMsg); open$.next(true); open$.complete(); return (0, actions_5.rtcChannel)(dataChannel, info.peer.meta); })), (0, rxjs_1.fromEvent)(dataChannel, 'message').pipe((0, operators_1.tap)((e) => deps.log.debug('RTC: dataChannel message', dataChannel.label, e)), (0, operators_1.pluck)('data'), (0, operators_1.filter)((d) => typeof d === 'string'), // ignore pingMsg, used only to succeed rtcChannel (0, operators_1.filter)((line) => line !== pingMsg), (0, operators_1.mergeMap)((lines) => (0, rxjs_1.from)(lines.split('\n'))), (0, operators_1.observeOn)(rxjs_1.asapScheduler), (0, operators_1.map)((line) => (0, actions_2.messageReceived)({ text: line, message: (0, utils_1.parseMessage)(line, info.peer, deps), ts: Date.now(), userId: info.peer.payload.userId, }, info.peer.meta)))).pipe((0, operators_1.finalize)(() => connection.close())); } // setup listeners & events for a data channel, when it gets opened, and teardown when closed function listenDataChannel(action$, deps) { const { config$ } = deps; return (source$) => { let open$; return (0, rxjs_1.defer)(() => { open$ = new rxjs_1.AsyncSubject(); return source$.pipe((0, operators_1.takeUntil)(open$)); }).pipe( // partitionMap will send only ConnectionInfo tuples to pipe below, and passthrough the rest (0, rx_1.partitionMap)(isConnectionInfo, (0, rxjs_1.pipe)((0, operators_1.withLatestFrom)(config$), (0, operators_1.switchMap)(([connection, config]) => makeDataChannelObservable(connection, [action$, open$], config, deps))))); }; } // make an observable which answers an incoming call when subscribed function makeCalleeAnswer$(action$, [peer, offer], deps) { const { matrix$, config$, log } = deps; const info = { callId: offer.call_id, peer, }; log.info('RTC: callee answering', info); const start$ = new rxjs_1.AsyncSubject(); return matrix$.pipe((0, operators_1.mergeMap)(async (matrix) => getMatrixIceServers(matrix)), (0, operators_1.withLatestFrom)(config$), (0, operators_1.mergeMap)(([matrixIce, { fallbackIceServers: fallback }]) => { const connection = new RTCPeerConnection({ iceServers: matrixIce.concat(fallback) }); let emitted = 0; return (0, rxjs_1.merge)(handleCandidates$(connection, action$, info, deps).pipe((0, operators_1.delayWhen)((0, constant_1.default)(start$))), (0, rxjs_1.defer)(async () => connection.setRemoteDescription(offer)).pipe((0, operators_1.mergeMap)(async () => connection.createAnswer()), (0, rx_1.withMergeFrom)(async (answer) => connection.setLocalDescription(answer)), (0, operators_1.mergeMap)(([answer]) => { const body = { type: answer.type, sdp: answer.sdp, call_id: info.callId, }; // send answer, complete when response goes through; no need to forward // the success const request = actions_2.messageSend.request({ message: (0, data_1.jsonStringify)(body), msgtype: rtcMatrixMsgType, userId: peer.payload.userId, }, { ...peer.meta, msgId: (0, utils_3.makeMessageId)().toString() }); // send answer, complete when response goes through return (0, utils_2.dispatchAndWait$)(action$, request, (0, actions_4.isResponseOf)(actions_2.messageSend, request.meta)); }), (0, operators_1.finalize)(() => (start$.next(true), start$.complete()))), (0, rxjs_1.fromEvent)(connection, 'datachannel').pipe((0, operators_1.map)(({ channel }) => (++emitted, [connection, channel, info])), (0, operators_1.take)(1), // if switchMap unsubscribes before datachannel got emitted, release // connection (0, operators_1.finalize)(() => (!emitted ? connection.close() : null)))); }), listenDataChannel(action$, deps)); } // extracted helper of [makeCallerObservable] function makeOfferWaitAnswer(request, connInfo, [action$, start$], deps) { return (0, rxjs_1.merge)( // wait for answer matrixWebrtcEvents$(action$, RtcEventType.answer, connInfo[2], deps).pipe((0, operators_1.take)(1), (0, operators_1.mergeMap)(async (event) => { deps.log.info('RTC: got answer', event.call_id); await connInfo[0].setRemoteDescription(event); // output created channel when the offer has been sent return connInfo; }), (0, operators_1.finalize)(() => (start$.next(true), start$.complete()))), // send invite with offer, complete when success goes through (0, utils_2.dispatchAndWait$)(action$, request, (0, actions_4.isResponseOf)(actions_2.messageSend, request.meta)).pipe((0, operators_1.endWith)(connInfo))); } // extracted helper of [makeCallerCall$] function makeCallerObservable(peer, action$, deps) { const { matrix$, config$, log, address } = deps; return matrix$.pipe((0, operators_1.mergeMap)(async (matrix) => getMatrixIceServers(matrix)), (0, operators_1.withLatestFrom)(config$), (0, operators_1.mergeMap)(([matrixIce, { fallbackIceServers: fallback }]) => { const info = { callId: `${address}|${peer.meta.address}|${Date.now()}`, peer, }; log.info('RTC: caller calling', info); const connection = new RTCPeerConnection({ iceServers: matrixIce.concat(fallback) }); const dataChannel = connection.createDataChannel(info.callId, { ordered: false }); // start$ indicates invite/answer cycle completed, and candidates can be exchanged const start$ = new rxjs_1.AsyncSubject(); let emitted = 0; return (0, rxjs_1.merge)(handleCandidates$(connection, action$, info, deps).pipe((0, operators_1.delayWhen)((0, constant_1.default)(start$))), (0, rxjs_1.defer)(async () => connection.createOffer()).pipe((0, operators_1.mergeMap)(async (offer) => (await connection.setLocalDescription(offer), offer)), (0, operators_1.mergeMap)((offer) => { const body = { type: offer.type, sdp: offer.sdp, call_id: info.callId, }; const request = actions_2.messageSend.request({ message: (0, data_1.jsonStringify)(body), msgtype: rtcMatrixMsgType, userId: info.peer.payload.userId, }, { address: info.peer.meta.address, msgId: (0, utils_3.makeMessageId)().toString() }); return makeOfferWaitAnswer(request, [connection, dataChannel, info], [action$, start$], deps); }))).pipe((0, operators_1.filter)((value) => (isConnectionInfo(value) ? !emitted++ : true)), (0, operators_1.finalize)(() => (!emitted ? connection.close() : null))); }), listenDataChannel(action$, deps)); } // make an observable which calls peer when subscribed function makeCallerCall$(action$, peer, deps) { return action$.pipe((0, rx_1.dispatchRequestAndGetResponse)(actions_5.matrixPresence, (dispatch) => dispatch(actions_5.matrixPresence.request(undefined, { address: peer })).pipe((0, operators_1.mergeMap)((presence) => { (0, error_1.assert)((0, utils_4.getCap)(presence.payload.caps, constants_1.Capabilities.WEBRTC), "peer doesn't support RTC"); return makeCallerObservable(presence, action$, deps); })))); } // manage stream of callee observables and subscribe to them when needed function manageCalleeChannel(peer, setChannel, { log, config$ }) { return (0, rxjs_1.pipe)( // switchMap *unsubscribes* previous incoming call (0, operators_1.switchMap)(([, , channel$]) => { let channel; let error; return channel$.pipe((0, operators_1.tap)((action) => { if (!channel && actions_5.rtcChannel.is(action) && action.payload) setChannel.next([true, (channel = action.payload)]); }), (0, operators_1.catchError)((err) => { log.info('RTC: callee channel error', peer, err.message); error = err; return rxjs_1.EMPTY; }), // if caps change, disconnect every callee channel so they can be retried, but wait a bit // so PFS can pick up the new cap (0, operators_1.takeUntil)(config$.pipe((0, operators_1.distinctUntilChanged)(({ caps: prev }, { caps: cur }) => (0, isEqual_1.default)(prev, cur)), (0, operators_1.skip)(1), (0, operators_1.delayWhen)(({ pollingInterval }) => (0, rxjs_1.timer)(pollingInterval)))), (0, operators_1.finalize)(() => { log.info('RTC: callee disconnecting', peer); if (channel) setChannel.next([false, channel, error]); }), // give up if annother channel (caller) gets established (0, operators_1.takeUntil)(setChannel.pipe((0, operators_1.filter)(([put, channel_]) => put && channel_ !== channel)))); })); } // manage stream of caller observables and subscribe to them when needed function manageCallerChannel(peer, setChannel, { log, config$ }) { return (0, rxjs_1.pipe)( // exhaustMap *ignores* new call requests if one is already running (0, operators_1.exhaustMap)(([, , channel$]) => { let channel; let error; return (0, rxjs_1.defer)(() => { // upon retrying/re-subscribing, reset channel & error; channel = undefined; error = undefined; return channel$.pipe((0, operators_1.tap)({ next(action) { if (!channel && actions_5.rtcChannel.is(action) && action.payload) setChannel.next([true, (channel = action.payload)]); }, error(err) { log.info('RTC: caller channel error', peer, err.message); error = err; }, }), // if caps change, disconnect every caller channel so they can be retried, but wait a bit // so PFS can pick up the new cap (0, operators_1.takeUntil)(config$.pipe((0, operators_1.distinctUntilChanged)(({ caps: prev }, { caps: cur }) => (0, isEqual_1.default)(prev, cur)), (0, operators_1.skip)(1), (0, operators_1.delayWhen)(({ pollingInterval }) => (0, rxjs_1.timer)(pollingInterval)))), (0, operators_1.finalize)(() => { if (channel) setChannel.next([false, channel, error]); })); }).pipe((0, operators_1.retryWhen)((0, rxjs_1.pipe)((0, operators_1.withLatestFrom)(config$), (0, operators_1.mergeMap)(([, { pollingInterval, httpTimeout }], retryCount) => { if (retryCount >= 5) return (0, rxjs_1.of)(true); const delay = Math.min(pollingInterval * Math.pow(2, retryCount), httpTimeout * 2); log.info('RTC: caller retrying in', delay, peer, retryCount); return (0, rxjs_1.timer)(delay); }), (0, operators_1.takeWhile)((val) => val !== true), (0, operators_1.finalize)(() => log.info('RTC: caller giving up', peer)))), // give up if another channel (callee) gets established (0, operators_1.takeUntil)(setChannel.pipe((0, operators_1.filter)(([put, channel_]) => put && channel_ !== channel)))); })); } // operator to map stream of RaidenActions to incoming calls (received message and decoded offer) function mapRtcMessage() { return (action$) => action$.pipe((0, operators_1.filter)(actions_2.messageReceived.is), (0, operators_1.withLatestFrom)(action$.pipe((0, utils_4.getPresencesByUserId)())), (0, operators_1.filter)(([action, seenPresences]) => action.payload.msgtype === rtcMatrixMsgType && !!action.payload.userId && // messageReceived is emitted iff we've seen & validated peer's presence, but we add // a check that it's present here just to be sure action.payload.userId in seenPresences), (0, operators_1.mergeMap)(function* ([action, seenPresences]) { try { const json = (0, data_1.jsonParse)(action.payload.text); if (json['type'] === RtcEventType.offer) yield [ seenPresences[action.payload.userId], (0, types_1.decode)(rtcCodecs[RtcEventType.offer], json), ]; } catch (error) { } })); } // from actions, choose peers which whom we should attempt to [re]establish RTC channels function getAddressOfInterest$(action, { address, config$ }) { let peer$ = rxjs_1.EMPTY; if (actions_1.channelMonitored.is(action)) { peer$ = (0, rxjs_1.of)(action.meta.partner); } else if (actions_3.transferSigned.is(action)) { if (action.meta.direction === state_1.Direction.RECEIVED && action.payload.message.target === address && !('secret' in action.payload.message.metadata)) peer$ = (0, rxjs_1.of)(action.payload.message.initiator); } else if (actions_2.messageSend.request.is(action) && action.payload.msgtype !== rtcMatrixMsgType) { peer$ = (0, rxjs_1.of)(action.meta.address); } else if (actions_5.rtcChannel.is(action) && !action.payload) { // payload=undefined is only emitted when the last valid RTC connection with this peer got // closed, so let's attempt to call them; but delay it by pollingInterval peer$ = config$.pipe((0, operators_1.first)(), (0, operators_1.mergeMap)(({ pollingInterval }) => (0, rxjs_1.timer)(pollingInterval)), (0, operators_1.mapTo)(action.meta.address)); } return peer$; } // adds channel to peer's channel queue when open/put; pops, reset & send hangup when closed function mapChannelUpdateReset(peer, channels) { return (0, rxjs_1.pipe)((0, operators_1.mergeMap)(function* ([put, channel, err]) { if (put) { channels.push(channel); return; } if ((0, types_1.last)(channels) === channel) { channels.pop(); yield (0, actions_5.rtcChannel)((0, types_1.last)(channels), { address: peer }); } else { const idx = channels.indexOf(channel); if (idx >= 0) channels.splice(idx, 1); // else should never happen } if (!(0, error_1.matchError)([hangUpError, closedError], err)) { const body = { type: RtcEventType.hangup, call_id: channel.label, }; yield actions_2.messageSend.request({ message: (0, data_1.jsonStringify)(body), msgtype: rtcMatrixMsgType }, { address: peer, msgId: (0, utils_3.makeMessageId)().toString() }); } })); } /** * Creates and manages WebRTC connection requests * * For whitelisted peers, it'll always listen for 'offer' messages and answer to try to establish * the RTC channel. If a new one comes while the previous is waiting, it'll teardown the previous * request and answer the new. * For Raiden channel partners (on startup/channelOpen), targets, initiators and also upon new * messageSend.requests, it's checked if the peer is online, and in parallel with any callee * handling, a call is initiated, times out after a few seconds and is retried a few times (to be * retried again in any of the above events). The winner channel of the callee/caller race is used. * * @param action$ - Observable of RaidenActions * @param deps - Epics dependencies * @returns Observable of rtcChannel|messageSend.request|messageReceived|matrixPresence.request * actions */ function rtcConnectionManagerEpic(action$, {}, deps) { // observable of observables, created when *receiving* a call const asCallee$ = action$.pipe(mapRtcMessage(), (0, operators_1.map)(([action, offer]) => [ action.meta.address, "callee" /* Role.callee */, makeCalleeAnswer$(action$, [action, offer], deps), ])); // observable of observables, created upon certain events which triggers us to try to call peer const asCaller$ = action$.pipe( // allow RTC connect to neighbors, initiators for received and targets for sent transfers (0, operators_1.mergeMap)((action) => getAddressOfInterest$(action, deps)), (0, operators_1.map)((peer) => [peer, "caller" /* Role.caller */, makeCallerCall$(action$, peer, deps)])); return (0, rxjs_1.merge)(asCallee$, asCaller$).pipe((0, operators_1.groupBy)(([peer]) => peer), (0, operators_1.mergeMap)((perPeer$) => { const peer = perPeer$.key; const channels = []; // peer's channels const setChannel = new rxjs_1.Subject(); return (0, rxjs_1.merge)(perPeer$.pipe((0, operators_1.groupBy)(([, role]) => role), (0, operators_1.mergeMap)((perRole$) => perRole$.key === "callee" /* Role.callee */ ? perRole$.pipe(manageCalleeChannel(peer, setChannel, deps)) : perRole$.pipe((0, operators_1.filter)(() => !channels.length), // don't call if there's already an open channel manageCallerChannel(peer, setChannel, deps))), (0, rx_1.completeWith)(action$), (0, operators_1.finalize)(() => setTimeout(() => setChannel.complete(), 10))), setChannel.pipe(mapChannelUpdateReset(peer, channels))); }), (0, rx_1.takeIf)(deps.config$.pipe((0, operators_1.pluck)('caps', constants_1.Capabilities.WEBRTC), (0, rx_1.completeWith)(action$)))); } exports.rtcConnectionManagerEpic = rtcConnectionManagerEpic; //# sourceMappingURL=webrtc.js.map