raiden-ts
Version:
Raiden Light Client Typescript/Javascript SDK
412 lines • 22 kB
JavaScript
import * as t from 'io-ts';
import constant from 'lodash/constant';
import isEqual from 'lodash/isEqual';
import { asapScheduler, AsyncSubject, defer, EMPTY, from, fromEvent, merge, of, pipe, Subject, throwError, timer, } from 'rxjs';
import { bufferTime, catchError, delayWhen, distinctUntilChanged, endWith, exhaustMap, filter, finalize, first, groupBy, ignoreElements, map, mapTo, mergeMap, observeOn, pluck, retryWhen, skip, startWith, switchMap, take, takeUntil, takeWhile, tap, withLatestFrom, } from 'rxjs/operators';
import { channelMonitored } from '../../channels/actions';
import { Capabilities } from '../../constants';
import { messageReceived, messageSend } from '../../messages/actions';
import { parseMessage } from '../../messages/utils';
import { transferSigned } from '../../transfers/actions';
import { dispatchAndWait$ } from '../../transfers/epics/utils';
import { Direction } from '../../transfers/state';
import { makeMessageId } from '../../transfers/utils';
import { isResponseOf } from '../../utils/actions';
import { jsonParse, jsonStringify } from '../../utils/data';
import { assert, matchError } from '../../utils/error';
import { completeWith, dispatchRequestAndGetResponse, partitionMap, takeIf, timeoutFirst, withMergeFrom, } from '../../utils/rx';
import { decode, isntNil, last } from '../../utils/types';
import { matrixPresence, rtcChannel } from '../actions';
import { getCap, getPresencesByUserId } from '../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(filter(messageReceived.is), filter(({ meta, payload }) => meta.address === peer.meta.address && payload.msgtype === rtcMatrixMsgType), mergeMap(function* (action) {
try {
const json = jsonParse(action.payload.text);
if (json['type'] === type)
yield 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,
});
}
}), 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 merge(
// when receiving candidates from peer, add it locally
matrixWebrtcEvents$(action$, RtcEventType.candidates, info, { log }).pipe(tap((e) => log.debug('RTC: received candidates', info.callId, e.candidates)), mergeMap((event) => from((event.candidates ?? []))), mergeMap(async (candidate) => {
try {
await connection.addIceCandidate(candidate);
}
catch (err) {
log.warn('RTC: error setting candidate, ignoring', info.callId, connection.connectionState, candidate, err);
}
}), ignoreElements()),
// when seeing an icecandidate, send it to peer
fromEvent(connection, 'icecandidate').pipe(pluck('candidate'), takeWhile(isntNil), bufferTime(10), filter((candidates) => candidates.length > 0), tap((e) => log.debug('RTC: got candidates', info.callId, e)), map((candidates) => {
const body = {
type: RtcEventType.candidates,
candidates,
call_id: info.callId,
};
return messageSend.request({
message: jsonStringify(body),
msgtype: rtcMatrixMsgType,
userId: info.peer.payload.userId,
}, { address: info.peer.meta.address, msgId: makeMessageId().toString() });
})));
}
// extracted helper of [listenDataChannel]
function makeDataChannelObservable([connection, dataChannel, info], [action$, open$], { httpTimeout }, deps) {
return merge(fromEvent(connection, 'connectionstatechange').pipe(startWith(null), mergeMap(() => {
if (failedConnectionStates.includes(connection.connectionState))
throw new Error('RTC: connection failed');
return EMPTY;
})), fromEvent(dataChannel, 'close').pipe(map(() => {
throw new Error(closedError);
})), fromEvent(dataChannel, 'error').pipe(pluck('error'), mergeMap(throwError)), matrixWebrtcEvents$(action$, RtcEventType.hangup, info, deps).pipe(map(() => {
throw new Error(hangUpError);
})), fromEvent(dataChannel, 'open').pipe(take(1), timeoutFirst(httpTimeout), 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 rtcChannel(dataChannel, info.peer.meta);
})), fromEvent(dataChannel, 'message').pipe(tap((e) => deps.log.debug('RTC: dataChannel message', dataChannel.label, e)), pluck('data'), filter((d) => typeof d === 'string'),
// ignore pingMsg, used only to succeed rtcChannel
filter((line) => line !== pingMsg), mergeMap((lines) => from(lines.split('\n'))), observeOn(asapScheduler), map((line) => messageReceived({
text: line,
message: parseMessage(line, info.peer, deps),
ts: Date.now(),
userId: info.peer.payload.userId,
}, info.peer.meta)))).pipe(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 defer(() => {
open$ = new AsyncSubject();
return source$.pipe(takeUntil(open$));
}).pipe(
// partitionMap will send only ConnectionInfo tuples to pipe below, and passthrough the rest
partitionMap(isConnectionInfo, pipe(withLatestFrom(config$), 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 AsyncSubject();
return matrix$.pipe(mergeMap(async (matrix) => getMatrixIceServers(matrix)), withLatestFrom(config$), mergeMap(([matrixIce, { fallbackIceServers: fallback }]) => {
const connection = new RTCPeerConnection({ iceServers: matrixIce.concat(fallback) });
let emitted = 0;
return merge(handleCandidates$(connection, action$, info, deps).pipe(delayWhen(constant(start$))), defer(async () => connection.setRemoteDescription(offer)).pipe(mergeMap(async () => connection.createAnswer()), withMergeFrom(async (answer) => connection.setLocalDescription(answer)), 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 = messageSend.request({
message: jsonStringify(body),
msgtype: rtcMatrixMsgType,
userId: peer.payload.userId,
}, { ...peer.meta, msgId: makeMessageId().toString() });
// send answer, complete when response goes through
return dispatchAndWait$(action$, request, isResponseOf(messageSend, request.meta));
}), finalize(() => (start$.next(true), start$.complete()))), fromEvent(connection, 'datachannel').pipe(map(({ channel }) => (++emitted, [connection, channel, info])), take(1),
// if switchMap unsubscribes before datachannel got emitted, release
// connection
finalize(() => (!emitted ? connection.close() : null))));
}), listenDataChannel(action$, deps));
}
// extracted helper of [makeCallerObservable]
function makeOfferWaitAnswer(request, connInfo, [action$, start$], deps) {
return merge(
// wait for answer
matrixWebrtcEvents$(action$, RtcEventType.answer, connInfo[2], deps).pipe(take(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;
}), finalize(() => (start$.next(true), start$.complete()))),
// send invite with offer, complete when success goes through
dispatchAndWait$(action$, request, isResponseOf(messageSend, request.meta)).pipe(endWith(connInfo)));
}
// extracted helper of [makeCallerCall$]
function makeCallerObservable(peer, action$, deps) {
const { matrix$, config$, log, address } = deps;
return matrix$.pipe(mergeMap(async (matrix) => getMatrixIceServers(matrix)), withLatestFrom(config$), 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 AsyncSubject();
let emitted = 0;
return merge(handleCandidates$(connection, action$, info, deps).pipe(delayWhen(constant(start$))), defer(async () => connection.createOffer()).pipe(mergeMap(async (offer) => (await connection.setLocalDescription(offer), offer)), mergeMap((offer) => {
const body = {
type: offer.type,
sdp: offer.sdp,
call_id: info.callId,
};
const request = messageSend.request({
message: jsonStringify(body),
msgtype: rtcMatrixMsgType,
userId: info.peer.payload.userId,
}, { address: info.peer.meta.address, msgId: makeMessageId().toString() });
return makeOfferWaitAnswer(request, [connection, dataChannel, info], [action$, start$], deps);
}))).pipe(filter((value) => (isConnectionInfo(value) ? !emitted++ : true)), finalize(() => (!emitted ? connection.close() : null)));
}), listenDataChannel(action$, deps));
}
// make an observable which calls peer when subscribed
function makeCallerCall$(action$, peer, deps) {
return action$.pipe(dispatchRequestAndGetResponse(matrixPresence, (dispatch) => dispatch(matrixPresence.request(undefined, { address: peer })).pipe(mergeMap((presence) => {
assert(getCap(presence.payload.caps, 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 pipe(
// switchMap *unsubscribes* previous incoming call
switchMap(([, , channel$]) => {
let channel;
let error;
return channel$.pipe(tap((action) => {
if (!channel && rtcChannel.is(action) && action.payload)
setChannel.next([true, (channel = action.payload)]);
}), catchError((err) => {
log.info('RTC: callee channel error', peer, err.message);
error = err;
return 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
takeUntil(config$.pipe(distinctUntilChanged(({ caps: prev }, { caps: cur }) => isEqual(prev, cur)), skip(1), delayWhen(({ pollingInterval }) => timer(pollingInterval)))), finalize(() => {
log.info('RTC: callee disconnecting', peer);
if (channel)
setChannel.next([false, channel, error]);
}),
// give up if annother channel (caller) gets established
takeUntil(setChannel.pipe(filter(([put, channel_]) => put && channel_ !== channel))));
}));
}
// manage stream of caller observables and subscribe to them when needed
function manageCallerChannel(peer, setChannel, { log, config$ }) {
return pipe(
// exhaustMap *ignores* new call requests if one is already running
exhaustMap(([, , channel$]) => {
let channel;
let error;
return defer(() => {
// upon retrying/re-subscribing, reset channel & error;
channel = undefined;
error = undefined;
return channel$.pipe(tap({
next(action) {
if (!channel && 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
takeUntil(config$.pipe(distinctUntilChanged(({ caps: prev }, { caps: cur }) => isEqual(prev, cur)), skip(1), delayWhen(({ pollingInterval }) => timer(pollingInterval)))), finalize(() => {
if (channel)
setChannel.next([false, channel, error]);
}));
}).pipe(retryWhen(pipe(withLatestFrom(config$), mergeMap(([, { pollingInterval, httpTimeout }], retryCount) => {
if (retryCount >= 5)
return of(true);
const delay = Math.min(pollingInterval * Math.pow(2, retryCount), httpTimeout * 2);
log.info('RTC: caller retrying in', delay, peer, retryCount);
return timer(delay);
}), takeWhile((val) => val !== true), finalize(() => log.info('RTC: caller giving up', peer)))),
// give up if another channel (callee) gets established
takeUntil(setChannel.pipe(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(filter(messageReceived.is), withLatestFrom(action$.pipe(getPresencesByUserId())), 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), mergeMap(function* ([action, seenPresences]) {
try {
const json = jsonParse(action.payload.text);
if (json['type'] === RtcEventType.offer)
yield [
seenPresences[action.payload.userId],
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$ = EMPTY;
if (channelMonitored.is(action)) {
peer$ = of(action.meta.partner);
}
else if (transferSigned.is(action)) {
if (action.meta.direction === Direction.RECEIVED &&
action.payload.message.target === address &&
!('secret' in action.payload.message.metadata))
peer$ = of(action.payload.message.initiator);
}
else if (messageSend.request.is(action) && action.payload.msgtype !== rtcMatrixMsgType) {
peer$ = of(action.meta.address);
}
else if (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(first(), mergeMap(({ pollingInterval }) => timer(pollingInterval)), 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 pipe(mergeMap(function* ([put, channel, err]) {
if (put) {
channels.push(channel);
return;
}
if (last(channels) === channel) {
channels.pop();
yield rtcChannel(last(channels), { address: peer });
}
else {
const idx = channels.indexOf(channel);
if (idx >= 0)
channels.splice(idx, 1); // else should never happen
}
if (!matchError([hangUpError, closedError], err)) {
const body = {
type: RtcEventType.hangup,
call_id: channel.label,
};
yield messageSend.request({ message: jsonStringify(body), msgtype: rtcMatrixMsgType }, { address: peer, msgId: 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
*/
export function rtcConnectionManagerEpic(action$, {}, deps) {
// observable of observables, created when *receiving* a call
const asCallee$ = action$.pipe(mapRtcMessage(), 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
mergeMap((action) => getAddressOfInterest$(action, deps)), map((peer) => [peer, "caller" /* Role.caller */, makeCallerCall$(action$, peer, deps)]));
return merge(asCallee$, asCaller$).pipe(groupBy(([peer]) => peer), mergeMap((perPeer$) => {
const peer = perPeer$.key;
const channels = []; // peer's channels
const setChannel = new Subject();
return merge(perPeer$.pipe(groupBy(([, role]) => role), mergeMap((perRole$) => perRole$.key === "callee" /* Role.callee */
? perRole$.pipe(manageCalleeChannel(peer, setChannel, deps))
: perRole$.pipe(filter(() => !channels.length), // don't call if there's already an open channel
manageCallerChannel(peer, setChannel, deps))), completeWith(action$), finalize(() => setTimeout(() => setChannel.complete(), 10))), setChannel.pipe(mapChannelUpdateReset(peer, channels)));
}), takeIf(deps.config$.pipe(pluck('caps', Capabilities.WEBRTC), completeWith(action$))));
}
//# sourceMappingURL=webrtc.js.map