raiden-ts
Version:
Raiden Light Client Typescript/Javascript SDK
442 lines • 25.6 kB
JavaScript
;
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