@berish/rfp

Binary secure transport organization protocol for peer communication using function fingerprints

Usage no npm install needed!

<script type="module">
  import berishRfp from 'https://cdn.skypack.dev/@berish/rfp';
</script>

README

@berish/stateful

Бинарный безопасный протокол организации транспорта для взаимодействия пиров с помощью отпечатков функций.

Является только уровнем организации транспорта (не является транспортом самостоятельно!).

Старая версия находится в npm как fartix-rfp

Installation

$ npm install @berish/rfp --save

or

$ yarn add @berish/rfp

Supports typescript

Инициализация

Нужно создать подключение с помощью socket.io (смотреть документацию к socketIO)

После настройки сокета мы можем инициализировать RfpPeer для дальнейшей работы

const socketClientPeer = SocketIOClient.connect(socketServerAddress,{ transports: ['websocket'] });
const socketClientRfpPeer = new RfpPeer().setSocket(socketClientPeer));
socketClientPeer.on('connect', () => {
  socketClientRfpPeer.open();
})
socketClientPeer.on('disconnect', () => {
  socketClientRfpPeer.close();
})

Все! RfpPeer готов к работе. Пример выше показан для использования RfpPeer на стороне клиента, для сервера практически все аналогично

const httpServer = http.createServer().listen();
const httpServerAddress = httpServer.address() as AddressInfo;
const socketServer = SocketIO(httpServer);

socketServer.on('connection', (socket) => {
  const socketServerRfpPeer = new RfpPeer().setSocket(socket);
});

Настройка

Настройка транспорта

Можно удобно настраивать транспорт на лету.

const peer = new RfpPeer();
peer.setSocket(socket: SocketIO.Client | SocketIO-Client.Socket);

Настройка уровня использования

peer.setSerberLevel(level: 'raw' | 'production')

Serber является реализацией библиотеки berish-serber. Служит для удобной сериализации и десериализации объектов. В случае установки значения в raw, RfpPeer будет общаться в сырых данных, которые получаются при обычной сериализации. Если стоит значение production, RfpPeer будет реализовывать умную поддержку функций и отпечатков функций.

По умолчанию стоит значение production.

Настройка общего хранилища

peer.setStore<StoreState>(store: RfpStore<StoreState>)

Хранилище организовано с помощью реализации библиотеки berish-stober. Данная библиотека позволяет использовать любой адаптер хранилища, в том числе redux. Позволяет записывать данные в любой тип хранилища, например localStorage, после того, как будет описан адаптер типа хранилища storageAdapter (смотреть berish-stober).

const store = new Stober.DynamicStore<IState>(redux.createStore, []);
const rfpStore = new RfpStore().setStoreAdapter(store);

const rfpPeer = new RfpPeer().setSocket(socket).setStore(rfpStore);

Взаимодействие

Контроль соединения

Можно вручную открывать и закрывать контроль соединение (но не соединение транспорта)

rfpPeer.open();
console.log(rfpPeer.isOpened);
rfpPeer.close();

По-умолчанию, при настройки транспорта автоматически открывается соединение прослушки транспорта для контроля RfpPeer.

Сообщения

Клиент и сервер общаются посредством RfpChunk. Это является транспортной бизнес-сущностью, которая передает всю информацию между пирами.

export type RfpResponseStatusType = 'resolve' | 'reject' | 'initial';

export interface IRfpChunkStore<StoreState> {
  /** Ссылка на store внутри peer */
  store?: RfpStore<StoreState>;
}

export interface IRfpChunkSend<Body> {
  /** Показывает путь, на который направлен текущий chunk */
  readonly path: string;
  /** Основная информация, которую передает chunk */
  readonly body?: Body;
}

export interface IRfpChunkId {
  /** Показывает идентификатор текущего chunk */
  chunkId?: string;
  /** Показывает на какой chunk указывает текущий chunk */
  replyId?: string;
  /** Показывает запрос выполнен со статусом resolve или reject */
  status?: RfpResponseStatusType;
}

export interface IRfpChunk<Body, StoreState = any>
  extends IRfpChunkSend<Body>,
    IRfpChunkId,
    IRfpChunkStore<StoreState> {
  /** Дополнительная информация, которую можно получить из chunk */
  aside?: { [key: string]: any };
}

Отправка данных

Чтобы отправить данные через rfp, необходимо вызвать метод send.

const response = await socketClientRfpPeer.send({ body: 'hello world', path: 'hey' });

В данном случае клиент отправляет chunk по пути ‘hey’ данные ‘hello world’

Поле path является обязательным условием, но сами данные не являются обязательными, так как серверный peer имеет возможность не смотря на body отправить свой ответ.

Пример

const lib = await socketClientRfpPeer.send({ path: ‘getLib’ });

// lib теперь имеет отпечатки функций сервера, где может вызывать функции библиотеки, которую ранее он получил

Формат отправки chunk

export interface IRfpChunkSend {
    body: any // любые данные
    path: string // адрес, на который отправляется chunk,
    chunkId: string // идентификатор текущего chunk, если не указывать, значение является уникальным
    replyId: string // идентификатор chunk, на который направлен текущий chunk. В случае нахождение chunkId в базе прослушки, отвечает на chunk для которого он показывает
    aside: {[key: string]: any} // дополнительная информация, которую можно отправить вместе с основной информацией (например, userToken)
    status: ‘resolve’ | ‘reject’ | ‘initial’ // показывает, является ли это самостоятельным chunk (initial), либо отвечает на другой chunk в сети (resolve - успешно, reject - ошибка)
}

Получение данных

Мы можем слушать любой chunk по пути path, после чего сработает callback, в который передаются 2 аргумента: request, next.

interface IRfpRequest<StoreState = any> {
  chunk: IRfpChunk<any, StoreState>;
  peer: RfpPeer<StoreState>;
}

export type IRfpNextResponse = () => void;

Пример

socketServerRfpPeer.receive(‘auth’, (request, next) => {
    if(request.chunk.body.login !== ‘admin’ && request.chunk.body.pass !== ‘123456’ )
        throw new Error(‘permission denied’);
    return ‘access’;
});

Клиент в свою очередь получит ответ

const response: IRfpChunk & IRfpReplyChunk = await socketClientRfpPeer.send({ body: { login: ‘admin’, pass: ‘123456’ }, path: ‘auth’ });

console.log(response.body) // access;
console.log(response.replyChunk.body) // { body: { login: ‘admin’, pass: ‘123456’ };

Отпечатки функций

Библиотека умеет эмулировать функции, которые вы можете передать с сервера на клиент и обратно. То есть теперь Вы можете отправлять не обычные текстовые данные (json), а полноценные переменные, объекты и функции.

Когда один пир отправляет другому пиру функцию или объект, в котором есть функция, эти функции преобразовываются в отпечатки функций. То есть это есть реальные функции, которые Вы в будущем сможете вызвать на клиенте. В этот момент произойдет запрос на сервер и вызовется настоящая функция, на которую и смотрит вызванный отпечаток этой функции. Вам не надо заботится о взаимодействии, библиотека это сделает за вас. Так ваши функции могут отправлять сами функции и объекты с функциями, они также будут автоматически преобразовываться в отпечатки функций. Стоит учитывать, если у вас на сервере функции являются синхронными, они преобразовываются в асинхронные (для клиента), для лучшей производительности JS движка. Вы можете отправлять функции с любого пира, все превращение сделает библиотека.

Пример

const socketServerRfpPeer = new RfpPeer()
    .setSocket(socketServerPeer);
const socketClientRfpPeer = new RfpPeer()
    .setSocket(socketClientPeer);

const sumFunc = (a: number, b: number) => a + b;

socketServerRfpPeer.receive(‘getSum’, (req, next) => {
    return sumFunc;
});

const lib = await socketClientRfpPeer.send({ path: ‘getSum’ });

const a = 13;
const b = 17;
const sum = await lib.body(a, b); // body - в данном случае будет отпечатком функции sumFunc
console.log(sum === sumFunc(a, b)) // 30 === 30

Поддерживается многоуровневая передача функций. Например, вложенность второго уровня

const socketServerRfpPeer = new RfpPeer().setSocket(socketServerPeer);
const socketClientRfpPeer = new RfpPeer().setSocket(socketClientPeer);

const libServer = {
  minus: (a, b) => a - b,
  sum: (a, b) => a + b,
};
socketServerRfpPeer.receive('hey', (req, next) => {
  return libServer;
});

const response = await socketClientRfpPeer.send({ path: 'hey' });
const libClient = response.body;

const a = 54;
const b = 32;
const sum = await libClient.sum(a, b);
const minus = await libClient.minus(a, b);
console.log(sum === libServer.sum(a, b)); // 86 === 86
console.log(minus === libServer.minus(a, b)); // 22 === 22

Бэкенд как библиотека

Теперь Вы можете описывать свой бэкенд как библиотеку, не задумываясь о путях, и настройки роутингов для старого REST и о прочих трудностях. Вам только необходимо будет написать красивый бэкенд, не заботясь обо всех побочных задачах, которая вызывала ранее разработка бэкенда

const socketServerRfpPeer = new RfpPeer().setSocket(socketServerPeer);
const socketClientRfpPeer = new RfpPeer().setSocket(socketClientPeer);

const books: { [id: string]: string } = {};

const libServer = {
  book: {
    create: (name: string) => {
      const id = faker.random.uuid();
      books[id] = name;
      return id;
    },
    delete: (id: string) => {
      delete books[id];
    },
    read: (id: string) => books[id],
    update: (id: string, name: string) => {
      books[id] = name;
      return id;
    },
  },
};
socketServerRfpPeer.receive('hey', (req, next) => {
  return libServer;
});

const libClient = await socketClientRfpPeer.send({ path: 'hey' });

const firstName = faker.name.title();
const idBook1 = await libClient.body.book.create(firstName);
let bookName = await libClient.body.book.read(idBook1);
console.log(bookName === firstName); // true

const secondName = faker.name.title();
await libClient.body.book.update(idBook1, secondName);
bookName = await libClient.body.book.read(idBook1);
console.log(bookName !== firstName); // true
console.log(bookName === secondName); // true

await libClient.body.book.delete(idBook1);
const bookName2 = await libClient.body.book.read(idBook1);
console.log(!!bookName2); // true

Взаимодействие между клиентами

Вы можете отправлять переменные, объекты, функции и всю прочую информацию между клиентами, не заботясь о настройках транспорта.

То есть, есть 2 клиента и 1 сервер. Вы можете перенаправлять информацию между клиентами через сервер, и все будет прекрасно работать. Если Вы хотите, чтобы общение происходило только через клиентов, без взаимодействия с сервером, вам необходимо настроить транспорт для P2P соединений, например, socket.io P2P или TCP (UDP) соединение (напомним, для socket.io P2P адаптер у нас уже готов, Вам необходимо только внести правки в режим транспорта socket.io, смотрите соответствующую документацию у них на сайте).

const socketServerRfpPeer1 = new RfpPeer().setSocket(socketServerPeer1); // в данном случае это сокет, направленный на первого пира
const socketServerRfpPeer2 = new RfpPeer().setSocket(socketServerPeer2);// в данном случае это сокет, направленный на второго пира
const socketClientRfpPeer1 = new RfpPeer().setSocket(socketClientPeer1);
const socketClientRfpPeer2 = new RfpPeer().setSocket(socketClientPeer2);

socketServerRfpPeer1.receive('hey', async ({ chunk }, next) => {
    const response = await socketServerRfpPeer2.send({ path: ‘hey’, body: chunk.body })
    return response;
});
const lib = await socketClientRfpPeer1.send({
    body: (data: string) => data && data.toLocaleUpperCase(),
    path: 'hey',
});
const responseFromClientPeer2 = await lib.body('hello');

Взаимодействия с функциями внутри классов

Можно отправлять функции, которые находятся внутри какого-либо класса. Контекст слова this сохранится. Следует учитывать, что сам класс как класс передать не получится, отправится лишь объект представляющий данный класс, и ключевое слово this потеряет свой смысл если Вы завязаны на прототипе. Но если Вы хотите просто передать класс, не для работы с ним напрямую, а для вызова функций внутри этого класса, тогда все будет работать как надо.

Мы работает над тем, чтобы была какая-либо возможность передавать классы с прототипом в будущем напрямую.

Хранилище

Можно удобно хранить и использовать данные в общем хранилище. Так, Вы можете хранить user token пользователя и в любой момент забирать его на сервере для своих проверок.

Хранилище использует реализацию библиотеки berish-stober (npm).

const storeClient = new stober.DynamicStore<IState>(createStore, []);
const socketClientRfpStore = new RfpStore().setStoreAdapter(storeClient);

const socketClientRfpPeer = new RfpPeer().setSocket(socketClientPeer).setStore(socketClientRfpStore);

Вы можете использовать методы RfpStore для своих нужд.

rfpStore.open(); // принудительное включение прослушки store
rfpStore.close(); // принудительная остановка прослушки store
rfpStore.isOpened; // прослушивается ли текущий store

rfpStore.state; // текущее состояние хранилище
rfpStore.setState(state); // задать текущий rfp.state по ключам из входящего state (как в react) и обновление на обеих сторонах
rfpStore.forceUpdate(); // принудительное обновление хранилище на обеих сторонах
rfpStore.setStateLocal(state); // похоже на setState, но не отправляет state на другой пир

rfpStore.get(key); // работа с полями через getter
rfpStore.set(key, value); // работа с полями через setter

Пример

interface IState {
    login: string;
}

const storeServer = new DynamicStore<IState>(createStore, []);
const storeClient = new DynamicStore<IState>(createStore, []);

const socketServerRfpStore = new RfpStore()
    .setStoreAdapter(storeServer);
const socketClientRfpStore = new RfpStore()
    .setStoreAdapter(storeClient);

const socketServerRfpPeer = new RfpPeer()
    .setSocket(socketServerPeer)
    .setStore(socketServerRfpStore);

const socketClientRfpPeer = new RfpPeer()
    .setSocket(socketClientPeer)
    .setStore(socketClientRfpStore);

socketClientRfpPeer.receive('hey', async ({ chunk }, next) => {
    const state = chunk.store.state;
    console.log(state) // {}
    console.log(chunk.body) // {login: ‘admin’}
    await chunk.store.setState(chunk.body);
    return 'ok';
});

const status = await socketServerRfpPeer.send({ path: 'hey', body: { login: ‘admin’ } });

console.log(status.body) // ok
console.log(socketServerRfpPeer.store.state) // { login: ‘admin’ }

Middleware и Receivers. Цепочка выполнения запроса

Вы можете писать свои middleware или подключать сторонние. В них Вы можете описывать действия, которые необходимо совершить перед каждый получением данных на стороне одного из пира. Middleware получает на вход такой же request и next как и обычный receiver. Чтобы ответить на middleware необходимо вызвать метод next.

Если Вы ответите throw err или return data, Вы завершите цикл запроса и далее запрос не пойдет по цепочке, вместо этого пойдет ответный запрос на пир, который ожидает ответ. Если вы ответите next(), цепочка продолжится и будет вызываться другой middleware, если такой есть.

Если таковой отсутствует, запрос начнется по цепочке receivers на пути, которые были указаны в оригинальном chunk. Там также доступны 3 варианта ответа, которые работают аналогично, кроме next(). Метод next будет далее срабатывать по цепочке receivers с нужным path пока не будет получен ответ.

Если ответ никто так и не сформировал (отсутствуют middleware или все дали ответ next(), и отсутствуют receivers по нужному пути или все дали ответ next()) будет автоматически дан ответ reject с сообщением 'path is not found'.

Пример

const socketServerRfpPeer = new RfpPeer().setSocket(socketServerPeer);
const socketClientRfpPeer = new RfpPeer().setSocket(socketClientPeer);

const authMiddle: RfpMiddlewareReceive<any> = ({ chunk, peer }, { reject, next }) => {
  if (chunk.body.login === 'admin') return next();
  throw new Error('its not admin');
};
socketServerRfpPeer.middleware(authMiddle);

socketServerRfpPeer.receive('hey', ({ chunk }, next) => {
  if (chunk.body.password === '123456') return 'resolve by 1';
  if (chunk.body.password === '1234567') return throw new Error('reject by 1');
  return next();
});

socketServerRfpPeer.receive('hey', ({ chunk }, next) => {
  if (chunk.body.password === '123123') return 'resolve by 2';
  throw new Error('reject by 2');
});

// Примеры цепочки запросов

// Срабатывание middleware c ответом resolve
try {
  const test = await socketClientRfpPeer.send({ path: 'hey', body: { login: 'admin2' } });
} catch (err) {
  console.log(err); // its not admin
}

// Срабатывание второго receiver с ответом reject
try {
  const test = await socketClientRfpPeer.send({ path: 'hey', body: { login: 'admin' } });
} catch (err) {
  console.log(err); //reject by 2
}

// Срабатывание первого receiver с ответом reject
try {
  const test = await socketClientRfpPeer.send({ body: { login: 'admin', password: '1234567' }, path: 'hey' });
} catch (err) {
  console.log(err); //reject by 1
}

// Срабатывание первого receiver с ответом resolve
const test = await socketClientRfpPeer.send({ body: { login: 'admin', password: '123456' }, path: 'hey' });
console.log(test.body); //resolve by 1

// Срабатывание второго receiver с ответом resolve
const test = await socketClientRfpPeer.send({ body: { login: 'admin', password: '123123' }, path: 'hey' });
console.log(test.body); //resolve by 2

Контекст выполнения

Все хорошо, когда Вы напрямую ожидаете вызова через receivers, там Вы можете получить request напрямую, откуда можете считать любую информацию, например body или aside. Но что делать, когда Вы передали отпечаток функции и ожидаете вызова напрямую в функции? Изначально, в отпечаток функции не приходит request, потому что передача невозможно (ведь аргументы заняты Вами). В таком случае на помощь приходит небольшая магия.

Получение контекста

Например, Вы получили функции sum от сервера, которая просто суммирует 2 числа

sum(a: number, b: number) {
    return a + b;
}

Но что, если Вам надо проверить какое либо условие, например, может ли текущий пользователь суммировать числа, или выдать ему ошибка, что у Вас недостаточно прав.

sum(a: number, b: number) {
    if(chunk.store.state.userToken === undefined) throw new Error(‘permission denied’)
    return a + b;
}

Но где же нам взять входящий chunk внутри функции? Для этого нужно прописать еще один аргумент, в который и будет передавать текущий request.

ОБЯЗАТЕЛЬНО: аргумент должен называться rfp и должен располагаться в конце всех аргументов; если аргумент будет назван по-другому, будет находиться не последним, либо не объявлен вообще, request передан не будет!

sum(a: number, b: number, rfp?: IRfpChunk) {
    const {chunk, peer} = rfp;
    if(chunk.store.state.userToken === undefined) throw new Error(‘permission denied’)
    return a + b;
}

Теперь магическим способом, мы получим request через аргумент rfp. Даже если Вы ожидаете любое количество аргументов, мы, если встретим среди них rfp, передадим Вам request.

Но что делать, если Вы ожидаете 2 аргумента, и второй из них может быть Вами не передан самостоятельно, например sum(3), когда sum(a: number, b?: number, rfp?: IRfpChunk). Тогда мы просто не передадим Вам этот второй аргумент, но rfp Вы получите в любом случае.

Если Вы хотите передать контекст временно (через aside), вы также его сможете легко получить (о том, как передавать контекст, читайте далее).

sum(a: number, b: number, rfp?: IRfpChunk) {
    const contextData = context.getContextAside(rfp);
    if(contextData === undefined) throw new Error(‘permission denied’)
    return a + b;
}

Передача контекста

Если Вы хотите при вызове функции через ее отпечаток передать дополнительный контекст, Вы можете воспользоваться функцией высшего порядка

context.setContextAside(func, contextData);

Пример по отпечатку функции sum из прошлого примера

const contextData = ‘123123’;
const sumWithContext = context.setContextAside(libClient.body.sum, contextData);

const sum = await sumWithContext(3, 4);
console.log(sum) // 7

const sumThrown = libClient.body.sum(3, 4) // thrown ‘permission denied’ (если вызывать без контекста функцию из прошлого примера)