raiden-ts
Version:
Raiden Light Client Typescript/Javascript SDK
266 lines • 14.8 kB
JavaScript
import constant from 'lodash/constant';
import uniq from 'lodash/uniq';
import { asapScheduler, defer, EMPTY, from, fromEvent, merge, of, partition, scheduled, timer, } from 'rxjs';
import { catchError, concatMap, delayWhen, endWith, filter, ignoreElements, map, mergeMap, pluck, share, take, takeUntil, tap, toArray, withLatestFrom, } from 'rxjs/operators';
import { intervalFromConfig } from '../../config';
import { messageReceived, messageSend, messageServiceSend } from '../../messages/actions';
import { MessageType, Processed, SecretRequest, SecretReveal } from '../../messages/types';
import { encodeJsonMessage, isMessageReceivedOfType, parseMessage, signMessage, } from '../../messages/utils';
import { ServiceDeviceId } from '../../services/types';
import { pfsInfoAddress } from '../../services/utils';
import { isActionOf } from '../../utils/actions';
import { assert, networkErrors } from '../../utils/error';
import { LruCache } from '../../utils/lru';
import { getServerName } from '../../utils/matrix';
import { completeWith, dispatchRequestAndGetResponse, pluckDistinct, retryWhile, withMergeFrom, } from '../../utils/rx';
import { Address, isntNil, Signed } from '../../utils/types';
import { matrixPresence } from '../actions';
import { getAddressFromUserId, getPresencesByAddress, getPresencesByUserId, peerIsOnlineLC, } from '../utils';
function getMessageBody(message) {
return typeof message === 'string' ? message : encodeJsonMessage(message);
}
const textMsgType = 'm.text';
function getMsgType(request) {
return request.payload.msgtype ?? textMsgType;
}
// toDevice API can send multiple messages to multiple userIds+deviceIds, but for each, only a
// single `msgtype` is allowed; This function walks through the queue and on each call pops at most
// masBatchSize messages from as many recipients as needed as long as all messages popped for a
// peer are of the same msgtype; left messages will be picked on next iteration
function popMessagesOfSameType(queue, maxBatchSize) {
let selectedCount = 0;
// per userId message queues with at least one message (which defines chosen msgtype)
const selected = new Map();
for (let i = 0; i < queue.length && selectedCount < maxBatchSize; i++) {
const request = queue[i];
// guaranteed to be defined from request$
const peerId = request.payload.userId;
const peerQueue = selected.get(peerId);
const msgTypeDiffersPeerQueue = peerQueue && getMsgType(request) !== getMsgType(peerQueue[0]);
if (msgTypeDiffersPeerQueue)
continue;
queue.splice(i--, 1);
if (!peerQueue)
selected.set(peerId, [request]);
else
peerQueue.push(request);
selectedCount++;
}
return selected;
}
function flushQueueToDevice(queue, deps) {
if (!queue.length)
return EMPTY;
const start = Date.now();
// limit each batch size to prevent API errors if request's payload is too big
const batch = popMessagesOfSameType(queue, 20);
const payload = {};
for (const [peerId, peerQueue] of batch) {
// several messages can be batched in a single body, as long as they share same msgtype
const body = peerQueue.map((action) => getMessageBody(action.payload.message)).join('\n');
const content = { msgtype: getMsgType(peerQueue[0]), body };
payload[peerId] = { ['*']: content };
}
let retries = -1;
return deps.matrix$.pipe(mergeMap(async (matrix) => {
retries++;
return matrix.sendToDevice('m.room.message', payload);
}), retryWhile(intervalFromConfig(deps.config$), {
maxRetries: 3,
onErrors: networkErrors,
log: deps.log.warn,
}),
// a higher-order observable is used to be able to have requests serialized/batched,
// but return observables for success/failures which will resolve independently when peer comes
// online, and get merged in parallel at the end to avoid holding concatMap
mergeMap(function* () {
const tookMs = Date.now() - start;
for (const [peerId, peerQueue] of batch) {
for (const action of peerQueue) {
// FIXME: if possible, restore waiting for some condition which indicates the
// peer has received the message; maybe an incoming message, rtc channel or presence
yield of(messageSend.success({ via: peerId, tookMs, retries }, action.meta));
}
}
}), catchError(function* (err) {
for (const [, peerQueue] of batch) {
for (const action of peerQueue) {
// if failure, fail all queued responses at once, no need to wait
yield of(messageSend.failure(err, action.meta));
}
}
}));
}
function sendBatchedToDevice(action$, deps) {
const queue = [];
return action$.pipe(tap(([action, userId]) => queue.push({ ...action, payload: { ...action.payload, userId } })),
// like concatBuffer, but takes queue control in order to do custom batch selection
concatMap(() => defer(() => flushQueueToDevice(queue, deps))),
// notice the emitted elements are observables, which can be merged in the output *after*
// the concatMap was released in order to "wait" for some condition before success|failure
mergeMap((obs$) => obs$.pipe(completeWith(action$, 10))));
}
function sendNowToRtc(toRtc$) {
return toRtc$.pipe(map(([action, rtcChannel]) => {
try {
const start = Date.now();
rtcChannel.send(getMessageBody(action.payload.message));
const payload = { via: rtcChannel.label, tookMs: Date.now() - start, retries: 0 };
return messageSend.success(payload, action.meta);
}
catch (err) {
return messageSend.failure(err, action.meta);
}
}));
}
/**
* Handles a [[messageSend.request]] action and send its message to peer through rtcChannel or
* toDevice
*
* @param action$ - Observable of messageSend.request actions
* @param state$ - Observable of RaidenStates
* @param deps - RaidenEpicDeps members
* @returns Observable of messageSend.success | messageSend.failure actions
*/
export function matrixMessageSendEpic(action$, {}, deps) {
return action$.pipe(dispatchRequestAndGetResponse(matrixPresence, (dispatch) => {
const request$ = action$.pipe(filter(messageSend.request.is),
// "holds" message until it can be handled: there's either a rtcChannel or presence for peer
withMergeFrom((action) => {
const address = action.meta.address;
const rtc$ = getMsgType(action) !== textMsgType
? EMPTY // don't send through rtc if msgtype is text
: deps.latest$.pipe(pluck('rtc', address), filter(isntNil), filter(({ readyState }) => readyState === 'open'));
const userId$ = action.payload.userId
? of(action.payload.userId)
: dispatch(matrixPresence.request(undefined, { address })).pipe(pluck('payload', 'userId'), catchError((err) => of(err)));
return merge(rtc$, userId$).pipe(take(1), completeWith(action$));
}), share());
// split messages which can be sent right away through rtc and the ones which need to be queued
// and sent through toDevice messages
const [toRtc$, toDeviceOrError$] = partition(request$,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
([, rtcOrPresence]) => !!rtcOrPresence && rtcOrPresence?.readyState); // partition isn't smart enough to detect the split condition, so we need to cast
const [toDevice$, presenceError$] = partition(toDeviceOrError$, ([, userIdOrError]) => typeof userIdOrError === 'string');
const sendToRtc$ = sendNowToRtc(toRtc$);
const sendToDevice$ = sendBatchedToDevice(toDevice$, deps);
const presenceRequestErrored$ = presenceError$.pipe(map(([request, error]) => messageSend.failure(error, request.meta)));
return merge(presenceRequestErrored$, sendToRtc$, sendToDevice$);
}, undefined, ({ meta }) => meta.address));
}
function sendServiceMessage(request, deps) {
const { matrix$, config$, latest$ } = deps;
return matrix$.pipe(withLatestFrom(latest$, config$), withMergeFrom(([, , { additionalServices }]) => from(additionalServices).pipe(mergeMap((serviceAddrOrUrl) => Address.is(serviceAddrOrUrl)
? of(serviceAddrOrUrl)
: defer(async () => pfsInfoAddress(serviceAddrOrUrl, deps)).pipe(catchError(constant(EMPTY))), 5), toArray())), mergeMap(([[matrix, { state }], additionalServicesAddrs]) => {
const servicesAddrs = uniq(additionalServicesAddrs.concat(Object.keys(state.services)));
assert(servicesAddrs.length, 'no services to messageServiceSend to');
const serverName = getServerName(matrix.getHomeserverUrl());
const userIds = servicesAddrs.map((service) => `@${service.toLowerCase()}:${serverName}`);
// batch action messages in a single text body
const content = {
msgtype: 'm.text',
body: getMessageBody(request.payload.message),
};
const payload = Object.fromEntries(userIds.map((uid) => [uid, { [ServiceDeviceId[request.meta.service]]: content }]));
const start = Date.now();
let retries = -1;
return defer(async () => {
retries++;
await matrix.sendToDevice('m.room.message', payload); // send message!
return messageServiceSend.success({ via: userIds, tookMs: Date.now() - start, retries }, request.meta);
}).pipe(retryWhile(intervalFromConfig(config$), { maxRetries: 3, onErrors: networkErrors }));
}), catchError((err) => of(messageServiceSend.failure(err, request.meta))));
}
/**
* Handles a [[messageServiceSend.request]] action and send one-shot message to a service
*
* @param action$ - Observable of messageServiceSend actions
* @param state$ - Observable of RaidenStates
* @param deps - Epics dependencies
* @returns Observable of messageServiceSend.success|failure actions
*/
export function matrixMessageServiceSendEpic(action$, {}, deps) {
return action$.pipe(filter(isActionOf(messageServiceSend.request)),
// wait until init$ is completed before handling requests, so state.services is populated
delayWhen(() => deps.init$.pipe(ignoreElements(), endWith(true))), mergeMap((request) => sendServiceMessage(request, deps), 5));
}
/**
* Subscribe to matrix messages and emits MessageReceivedAction upon receiving a valid message from
* an user of interest (one valid signature from an address we monitor) in a room we have for them
*
* @param action$ - Observable of RaidenActions
* @param state$ - Observable of RaidenStates
* @param deps - RaidenEpicDeps members
* @param deps.log - Logger instance
* @param deps.matrix$ - MatrixClient async subject
* @param deps.config$ - Config observable
* @param deps.latest$ - Latest values
* @returns Observable of messageReceived|matrixPresence.request actions
*/
export function matrixMessageReceivedEpic(action$, {}, { log, matrix$, config$, latest$ }) {
return action$.pipe(dispatchRequestAndGetResponse(matrixPresence, (requestPresence) => matrix$.pipe(
// when matrix finishes initialization, register to matrix timeline events
withMergeFrom((matrix) => fromEvent(matrix, 'toDeviceEvent')), filter(([matrix, event]) => event.getType() === 'm.room.message' && event.getSender() !== matrix.getUserId()), pluck(1), withLatestFrom(config$, action$.pipe(getPresencesByUserId())), withMergeFrom(([event, { httpTimeout }, seenPresences]) => {
const senderId = event.getSender();
const presence = seenPresences[senderId];
if (presence)
return of(presence);
return latest$.pipe(pluckDistinct('whitelisted'), map((whitelisted) => {
const address = getAddressFromUserId(senderId);
if (!!address && whitelisted.includes(address))
return address;
}), filter(isntNil), take(1),
// take up to an arbitrary timeout for sender to be whitelisted
takeUntil(timer(httpTimeout)),
// only request presence if node was whitelisted, to avoid whitelisting unknown senders
mergeMap((address) => requestPresence(matrixPresence.request(undefined, { address }))), catchError(constant(EMPTY)));
}), completeWith(action$), mergeMap(([[event], presence]) => {
const lines = (event.getContent()['body'] ?? '').split('\n');
return scheduled(lines, asapScheduler).pipe(map((line) => {
let message;
if (event.getContent().msgtype === textMsgType)
message = parseMessage(line, presence, { log });
return messageReceived({
text: line,
...(message ? { message } : {}),
ts: Date.now(),
userId: event.getSender(),
...(event.getContent().msgtype ? { msgtype: event.getContent().msgtype } : {}),
}, presence.meta);
}));
}))));
}
/**
* Sends Delivered for specific messages
*
* @param action$ - Observable of messageReceived actions
* @param state$ - Observable of RaidenStates
* @param deps - RaidenEpicDeps members
* @param deps.log - Logger instance
* @param deps.signer - Signer instance
* @returns Observable of messageSend.request actions
*/
export function deliveredEpic(action$, {}, { log, signer }) {
const cache = new LruCache(32);
return action$.pipe(filter(isMessageReceivedOfType([Signed(Processed), Signed(SecretRequest), Signed(SecretReveal)])), withLatestFrom(action$.pipe(getPresencesByAddress())),
// light-clients don't need Delivered messages
filter(([action, presences]) => !peerIsOnlineLC(presences[action.meta.address])), concatMap(([action]) => {
const message = action.payload.message;
// defer causes the cache check to be performed at subscription time
return defer(() => {
const msgId = message.message_identifier;
const key = msgId.toString();
const cached = cache.get(key);
if (cached)
return of(messageSend.request({ message: cached }, { address: action.meta.address, msgId: key }));
const delivered = {
type: MessageType.DELIVERED,
delivered_message_identifier: msgId,
};
log.info(`Signing "${delivered.type}" for "${message.type}" with id=${msgId.toString()}`);
return from(signMessage(signer, delivered, { log })).pipe(tap((signed) => cache.set(key, signed)), map((signed) => messageSend.request({ message: signed }, { address: action.meta.address, msgId: key })));
});
}));
}
//# sourceMappingURL=messages.js.map