Server Social: Создание клона Twitter на P2P только для браузера.

Server Social: Создание клона Twitter на P2P только для браузера.

8 июля 2025 г.

Традиционные социальные платформы контролируют ваши данные, решают, что вы видите, и могут подвергать цензуре или зарабатывать деньги на вашем контенте, не спрашивая. Переключившись на модель P2P только для браузера, браузер каждого пользователя действует как клиент, так и сервер, позволяя им владеть своими данными и решать, с кем подключиться. Это удаляет центральные контрольные точки, предоставляя пользователям полный контроль над своими сообщениями, сроками и конфиденциальностью.

Разница между централизованным микроблогом (Twitter) и сеткой P2P только для браузера.

На тWitter (x)Каждый твит проходит и хранится на серверах Twitter. Вы зависите от их времени выполнения, их правил и их бизнес -модели. В сетке P2P только для браузера пользователи находят друг друга напрямую (с минимальной сигнализацией), делятся обновлениями над WEBRTC и используют хранилище на основе CRDT, чтобы оставаться в синхронизации. Там нет единого авторитета, нет серверной фермы и нет центральной точки отказа.

Предварительные условия

Перед началом кода убедитесь, что у вас установлена ​​последняя версия Node.js (версия 20 LTS или выше), и выберите диспетчер пакетов, такой как NPM или PNPM. Вам также нужен современный браузер, который поддерживает модули WEBRTC и ES. Я рекомендую использовать последнюю версию браузера на основе хрома (Chrome, Edge или Brave) или Firefox. Эти браузеры предоставляют необходимые API для одноранговых соединений, хранения IndexedDB и импорта модуля ES без дополнительного объединения.

На концептуальной стороне вы должны быть знакомы с основами рукопожатий WEBRTC. Понимание кандидатов на льду, STUN/Turn Servers и обмен предложением/ответами SDP будет важным, когда мы настроем общение со стороны сверстников. Это также поможет узнать о CRDT (без конфликта типов данных) и о том, как они управляют обновлениями в распределенных системах. Если вы использовали библиотеки, такие как YJS или Automerge, вы узнаете аналогичные концепции в нашем магазине сроков: каждый сверстник в конечном итоге соглашается с одним и тем же порядком сообщений, даже если они остаются в автономном режиме или теряют сетевые подключения.

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

Bootstrap папки проекта

Для начала мы создадим новый проект Vite, который готов к React и TypeScript. Vite отлично, потому что он сразу же обеспечивает почти мгновенные перезагрузки горячих модулей и поддерживает ES-модули, что идеально подходит для нашего приложения P2P только для браузера.

Во -первых, запустите это в своем терминале:

npx create-vite p2p-twitter --template react-ts

Вот что происходит за кулисами:

  • npx create-viteЗапускает настройку проекта Vite без необходимости глобальной установки.
  • p2p-twitterиспользуется как имя папки и имя пакета проекта.
  • --template react-tsсообщает VITE, чтобы настроить проект React с TypeScript, включаяtsconfig.json, Реагировать настройки сборки, и тип-защитник JSX.

Как только эта команда завершится, переодеться в свой новый каталог:

cd p2p-twitter

Внутри вы увидите структуру Vite по умолчанию:srcпапка сmain.tsxиApp.tsx, аpublicпапка для статических активов и основных файлов конфигурации (package.jsonВtsconfig.jsonВvite.config.ts) Теперь вы можете начать сервер разработки, чтобы убедиться, что все работает:

npm install    # or `pnpm install` if you prefer
npm run dev

Посетите URL, показанный в вашем терминале (обычноhttp://localhost:5173) увидеть экран приветствия Vite. Благодаря гладкому настройке, вы готовы добавить сигнализацию P2P, каналы WEBRTC и другие функции нашего без сервера клона в Твиттере.

Добавить минимальный сигнальный заглушка

Наша одноранговая сетка нуждается в простом «лобби», чтобы справиться с начальным рукопожатием-обследование описаний сеансов и кандидатов на льду-до того, как браузеры смогут общаться напрямую. Вместо использованияtiny-ws, мы построим минимальную заглушку сwsбиблиотека. После того, как сверстники получили информацию друг о друге, все дальнейшие потоки данных поступают однозначные по сравнению с WEBRTC.

1. Установите библиотеку сигнализации

npm install ws

Кончик:Использовать ESMimportсинтаксис в узле, убедитесь, что вашpackage.jsonВключает

  "type": "module"

или переименовать свой файл заглушки вserver.mjsПолем

2. Создать сервер сигнализации

Создайте файл с названиемserver.js(илиserver.mjs):

import { WebSocketServer } from 'ws';

const PORT = 3000;
const wss = new WebSocketServer({ port: PORT });
console.log(`⮞ WebSocket signalling server running on ws://localhost:${PORT}`);

wss.on('connection', (ws) => {
  console.log('⮞ New peer connected');

  ws.on('message', (data) => {
    // Broadcast to all *other* clients
    for (const client of wss.clients) {
      if (client !== ws && client.readyState === WebSocketServer.OPEN) {
        client.send(data);
      }
    }
    console.log('⮞ Broadcasted message to peers:', data.toString());
  });

  ws.on('close', () => console.log('⮞ Peer disconnected'));
});

Эта заглушка будет:

  • Слушайте на порту 3000 для входящих подключений WebSocket.
  • Когда один клиент отправляет сообщение (предложение/ответ SDP или кандидат от ICE), перенаправьте его любому другому подключенному клиенту.
  • Соединения журнала, трансляции и отключения с консоли.

3. Добавьте сценарий удобства

В вашемpackage.json, под"scripts", добавлять:

{
  "scripts": {
    "dev:signal": "node server.js",
    // …your existing scripts
  }
}

4. Запустите свое приложение + сигнализационное заглушка

  1. НачинатьПриложение Vite React (обычно на порту 5173):

    npm run dev
    

  1. Во втором терминалеВначинатьСервер сигнализации:

    npm run dev:signal
    

  1. ОткрытьДва окна браузера, указывающие на ваше приложение React. В каждой консоли вы увидите журналы, как:

    ⮞ New peer connected
    ⮞ Broadcasted message to peers: {"type":"offer","sdp":"…"}
    

После того, как появляются сообщения, и ответы на ответ, ваши коллеги обменивались льдом и SDP, и могут установить прямое соединение WEBRTC.

Только с этой крошечной заглушкой вы заменилиtiny-wsНе добавляя каких -либо зависимостей в тяжелом весе или дополнительных серверов - просто важную логику вещания для начала вашего клона P2P Twitter.

Создайте каналы Webrtc браузера-браузер

Браузеры не могут подключаться непосредственно, пока они не поделится достаточно информацией, чтобы найти друг друга. Вот где ICE (интерактивное учреждение подключения) помогает, он собирает возможные точки соединения (например, ваш локальный IPS, ваш общедоступный IP через сервер STUN и любые реле поворота, если прямые пути не работают). После того, как у вас есть кандидаты на льду и пара каплей SDP (протокол описания сеанса), предложение от одного сверстника и ответ от другого; аRTCPeerConnectionсоединяет все.

В вашем приложении React создайте модуль (например,webrtc.ts) это экспортирует функцию для настройки соединения со стороны сверстников:

// webrtc.ts
export async function createPeerConnection(
  sendSignal: (msg: any) => void,
  onData: (data: any) => void
) {
  const config = {
    iceServers: [
      { urls: 'stun:stun.l.google.com:19302' }
    ]
  };
  const pc = new RTCPeerConnection(config);

  const channel = pc.createDataChannel('chat', {
    negotiated: true,
    id: 0,
    maxPacketLifeTime: 3000
  });

  channel.binaryType = 'arraybuffer';
  channel.onmessage = ({ data }) => onData(data);

  pc.onicecandidate = ({ candidate }) => {
    if (candidate) sendSignal({ type: 'ice', candidate });
  };

  pc.ondatachannel = ({ channel: remote }) => {
    remote.binaryType = 'arraybuffer';
    remote.onmessage = ({ data }) => onData(data);
  };

  // Begin handshake
  const offer = await pc.createOffer();
  await pc.setLocalDescription(offer);
  sendSignal({ type: 'offer', sdp: pc.localDescription });

  return async function handleSignal(message: any) {
    if (message.type === 'offer') {
      await pc.setRemoteDescription(new RTCSessionDescription(message.sdp));
      const answer = await pc.createAnswer();
      await pc.setLocalDescription(answer);
      sendSignal({ type: 'answer', sdp: pc.localDescription });
    } else if (message.type === 'answer') {
      await pc.setRemoteDescription(new RTCSessionDescription(message.sdp));
    } else if (message.type === 'ice') {
      await pc.addIceCandidate(new RTCIceCandidate(message.candidate));
    }
  };
}

Вот что происходит:

  1. АRTCPeerConnectionнастроен с общедоступным STUN Server, чтобы каждый браузер мог обнаружить свой общедоступный адрес.
  2. Мы немедленно открываем канал данных с именем «чат» с согласованными параметрами (без переговоров без бандита) и разрешаем до 3 секунд ретрансмиссии пакетов (maxPacketLifeTime) ПараметрbinaryType = 'arraybuffer'Убедитесь, что мы можем обработать как текстовые, так и бинарные капли позже.
  3. Как собираются кандидаты на льду,onicecandidateпожары; Мы сериализуем каждого кандидата через нашу сигнальную заглушку.
  4. Если удаленный сверстник сначала создает канал,ondatachannelПоймает его, чтобы обе стороны могли отправлять и получать.
  5. Мы начнем переговоры, создав предложение SDP, отправив его, а затем ожидаяhandleSignalЧтобы отреагировать, чтобы предложить, ответить или ледовые сообщения.

Чтобы проверить, подключите это к консоли вашего приложения. На одной вкладке запустите:

window.signalHandler = await createPeerConnection(msg => ws.send(JSON.stringify(msg)), data => console.log('received', data));

... на другой вкладке сделайте то же самое, но отправьте входящие сообщения WebSocketwindow.signalHandler(JSON.parse(evt.data))Полем Как только оба сверстника обменялись предложением, ответом и всеми кандидатами на льду, введите в одну консоли:

const buf = new TextEncoder().encode('ping');
channel.send(buf);

Консоль другой вкладки должна войти в системуreceived Uint8Array([...]), подтверждая прямой канал браузера-браузер. С этого момента каждое сообщение, включая наши будущие обновления CRDT, путешествует, не проходя через сервер.

Подключить магазин временной шкалы CRDT

Чтобы сохранить временную шкалу каждого сверстника в синхронизации, даже когда кто-то выйдет в автономном режиме или несколько человек публикуют одновременно, мы будем использовать YJS, надежную библиотеку CRDT, а также его адаптер Y-Webrtc. YJS обеспечивает слияние всех обновлений без конфликтов, в то время как Y-Webrtc использует те же каналы соединения, которые мы уже открыли.

Во -первых, установите оба пакета в корне проекта:

npm install yjs y-webrtc

Здесь,yjsобеспечивает основные типы CRDT и алгоритмы;y-webrtcПодключаются непосредственно в каналы данных WEBRTC, поэтому изменяют распространение мгновенно для каждого подключенного сверстника.

Далее создайте новый файл (src/crdt.ts) для инициализации и экспорта вашей общей графики:

// src/crdt.ts
import * as Y from 'yjs'
import { WebrtcProvider } from 'y-webrtc'

// A Yjs document represents the shared CRDT state
const doc = new Y.Doc()

// Name “p2p-twitter” ensures all peers join the same room
const provider = new WebrtcProvider('p2p-twitter', doc, {
  // optional: pass our own RTCPeerConnection instances if you’d like
})

// Use a Y.Array to hold an ordered list of posts
const posts = doc.getArray<{ id: string; text: string; ts: number }>('posts')

// Whenever `posts` changes, fire a callback so the UI can re-render
posts.observe(() => {
  // you’ll wire this to your React state later
  renderTimeline(posts.toArray())
})

export function addPost(text: string) {
  const entry = { id: crypto.randomUUID(), text, ts: Date.now() }
  // CRDT push: this update goes to every peer
  posts.push([entry])
}

export function getPosts() {
  return posts.toArray()
}

Вот что происходит выше:

  • АY.DocУдерживает все ваши типы CRDT в одном документе в памяти.
  • WebrtcProviderСоединяет комнату «P2P-TWITTER» через WEBRTC, передавая обновления YJS на тех же каналах сверстников, которые вы настраивали ранее.
  • doc.getArray('posts')Создает (или возвращает) общий массив, предназначенный для строки «посты». Каждый элемент представляет собой объект, содержащий UUID, текст твита и метка времени.
  • ВызовaddPost(...)Толкает новую запись в массив локально и транслирует изменение каждому подключенному сверстнику.
  • Подписка черезposts.observe(...)Позволяет вам отреагировать на любое удаленное или локальное обновление - идеальное для вызова вашего сеттера REACT Catter, чтобы обновить пользовательский интерфейс.

Чтобы проверить, запустите свой сервер Vite Dev (npm run dev) и откройте приложение в двух отдельных окнах браузера (или устройств). В консоли одного окна тип:

import { addPost } from './src/crdt.js'
addPost('Hello from Tab A!')

Почти мгновенно, в другом окне, вы должны увидеть свойrenderTimelineобратный вызов активируется с новым постом. Эта единственная строка кода показывает полную P2P, CRDT-поддерживаемую репликацию без сервера. Отсюда вы можете подключитьсяaddPostв форму отправьте обработчик и звонокgetPosts()Чтобы инициализировать состояние реагирования, предоставив каждому пользователю одинаковую хронологическую подачу твитов.

Постройте твит-подобный пользовательский интерфейс

С настройкой нашего магазина CRDT, давайте создадим удобный интерфейс для написания и просмотра сообщений. Мы будем использовать Tailwind CSS для быстрого стиля, не делая индивидуальных CSS.

Во -первых, установите Tailwind и создайте его файлы конфигурации:

npx tailwindcss init -p

Это создаетtailwind.config.jsи настройка PostCSS. В вашемtailwind.config.js, обеспечитьcontentМассив охватывает все ваши файлы React:

/** @type {import('tailwindcss').Config} */
export default {
  content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
  theme: { extend: {} },
  plugins: []
}

Далее, открытsrc/index.cssи замените его содержимое на базовый импорт Tailwind:

@tailwind base;
@tailwind components;
@tailwind utilities;

Теперь каждому классу нравитсяp-4Вbg-gray-100, илиrounded-xlдоступен.

Внутриsrc/App.tsx, импортируйте ваши помощники CRDT и стили попутного ветра:

import React, { useEffect, useState } from 'react'
import './index.css'
import { addPost, getPosts } from './crdt'

export default function App() {
  const [timeline, setTimeline] = useState(getPosts())
  const [draft, setDraft] = useState('')

  // Re-render on CRDT updates
  useEffect(() => {
    const handleUpdate = () => setTimeline(getPosts())
    // assume posts.observe calls handleUpdate under the hood
    return () => {/* unsubscribe if you wire it up */}    
  }, [])

  function submitPost(e: React.FormEvent) {
    e.preventDefault()
    if (!draft.trim()) return
    addPost(draft.trim())
    setDraft('')
  }

  return (
    <div className="max-w-xl mx-auto p-4">
      <form onSubmit={submitPost} className="mb-4">
        <textarea
          value={draft}
          onChange={e => setDraft(e.currentTarget.value)}
          placeholder="What’s happening?"
          className="w-full p-2 border rounded-lg focus:outline-none focus:ring"
          rows={3}
        />
        <button
          type="submit"
          className="mt-2 px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600"
        >
          Tweet
        </button>
      </form>
      <div className="space-y-4">
        {timeline
          .sort((a, b) => b.ts - a.ts)
          .map(post => (
            <div
              key={post.id}
              className="p-4 bg-gray-100 rounded-lg shadow-sm"
            >
              <p className="text-gray-800">{post.text}</p>
              <time className="text-xs text-gray-500">
                {new Date(post.ts).toLocaleTimeString()}
              </time>
            </div>
          ))}
      </div>
    </div>
  )
}

Здесь в форме используются утилиты Tailwind для заполнения (p-2), границы (border) и фокус -состояния (focus:ring) Фон кнопки изменяется на пахни, и каждая карта имеет мягкую тень и закругленные углы (rounded-lg shadow-sm) Мы связываем Textarea сdraftГосударство и звонитеaddPostна отправке. Поскольку в нашем магазине CRDT трансляции сразу меняются, все подключенные сверстникиgetPosts()Возвращает новую запись и подсказки отреагировать на повторное возмещение списка графиков.

Подключив свою иерархию компонентов React непосредственно к обновлениям CRDT, вы создали динамичный канал в реальном времени, который выглядит и ощущается как временная шкала Twitter, но и полностью работает в браузере.

Сохраняется в автономном режиме с IndexedDB

Даже без подключения к Интернету мы хотим, чтобы каждый пользователь перезагрузил и увидел их временную шкалу как есть. YJS предоставляетy-indexeddbПоставщик, который сохраняет документ CRDT в IndexedDB вашего браузера и возвращает его, когда вы запускаете.

Начните с установки пакета:

npm install y-indexeddb

В вашемsrc/crdt.ts, импортируйте и подключите постоянство IndexedDB вместе с поставщиком WEBRTC:

import { IndexeddbPersistence } from 'y-indexeddb'

// existing imports...
import * as Y from 'yjs'
import { WebrtcProvider } from 'y-webrtc'

const doc = new Y.Doc()

// persist the “p2p-twitter” doc locally
const persistence = new IndexeddbPersistence('p2p-twitter-storage', doc)

persistence.once('synced', () => {
  // IndexedDB snapshot has been loaded into `doc`
  console.log('loaded local snapshot from IndexedDB')
})

// now connect to peers over WebRTC as before
const webrtc = new WebrtcProvider('p2p-twitter', doc, {
  // you can pass custom peer connections here if needed
})

// When you want to force a write of the current state to disk:
export function flushToDisk() {
  // flush() returns a promise that resolves once IndexedDB has been updated
  return persistence.flush()
}

Вот что происходит: когда страница загружается,IndexeddbPersistenceпроверяет существующий сериализованный документ YJS под ключомp2p-twitter-storageПолем Если он находит один, он десериализует все состояние CRDT вdoc, сразу же запускает любых наблюдателей. Это означает, что выrenderTimelineОбратный вызов покажет прошлые посты, прежде чем сверстники воссоединятся. АsyncedСобытие позволяет вам узнать, когда эта реставрация закончена.

Всякий раз, когда вы делаете новые обновления CRDT (например, сaddPost), YJS Автоматически графики записывает в IndexedDB в фоновом режиме. Если вам нужно убедиться, что последнее состояние сохраняется до того, как пользователь закрыт страницу или останется в автономном режиме, позвонитеawait flushToDisk()Полем Эта функция сериализует документ и записывает его в IndexedDB за один шаг.

Чтобы проверить, работает ли это, остановите свою сигнальную заглушку и webrtc (или просто отключите свою сеть). Перезагрузить вкладку, и вы увидите журнал консолиloaded local snapshot from IndexedDB, с вашими посты сразу же вновь появляются. С этого момента каждый сверстник хранит локальную копию временной шкалы, которая остается нетронутой во время перезагрузки и автономных сессий, и плавно восстанавливается, когда сеть возвращается.

Поделиться изображениями и большими капли

Текстовые и маленькие объекты JSON плавно перемещаются по каналам данных WEBRTC, но большие двоичные файлы, такие как изображения или видео, необходимо разделить на более мелкие кусочки, чтобы избежать превышающих пределов MTU и вызывая безмолвные капли. Мы используем тот же канал данных, который мы настраиваем ранее, настраиваем его для надежной, упорядоченной бинарной передачи, и добавим простую логику черновиков сверху.

Начните с того, чтобы убедиться, что ваш канал передачи данных подключения вwebrtc.tsиспользует эти параметры:

const channel = pc.createDataChannel('file', {
  ordered: true,
  maxPacketLifeTime: 0  // 0 means retry indefinitely until delivered
});
channel.binaryType = 'arraybuffer';

Сordered: trueИ нет ограничения срока службы пакетов, браузер будет буферизовать и отправлять потерянные фрагменты, пока не будет получен весь каплей или не закроется соединение. Далее напишите две вспомогательные функции: одна, чтобы отправитьFileилиBlobв кусках, а другой, чтобы собрать их вместе, когда их

const CHUNK_SIZE = 16 * 1024; // 16 KB per packet

export function sendBlob(blob: Blob) {
  const total = blob.size;
  let offset = 0;
  const id = crypto.randomUUID();

  function sliceAndSend() {
    const end = Math.min(offset + CHUNK_SIZE, total);
    blob.slice(offset, end).arrayBuffer().then(buffer => {
      channel.send(JSON.stringify({ type: 'chunk-meta', id, total, offset }));
      channel.send(buffer);
      offset = end;
      if (offset < total) sliceAndSend();
      else channel.send(JSON.stringify({ type: 'chunk-end', id }));
    });
  }

  sliceAndSend();
}

const incomingBuffers: Record<string, Uint8Array[]> = {};

channel.onmessage = async event => {
  if (typeof event.data === 'string') {
    const meta = JSON.parse(event.data);
    if (meta.type === 'chunk-meta') {
      if (!incomingBuffers[meta.id]) incomingBuffers[meta.id] = [];
    } else if (meta.type === 'chunk-end') {
      const full = new Blob(incomingBuffers[meta.id]);
      incomingBuffers[meta.id] = [];
      displayImage(full); // your UI hook
    }
  } else {
    // binary ArrayBuffer payload
    const id = /* track current id based on protocol, e.g. last seen meta.id */
      Object.keys(incomingBuffers).pop()!;
    incomingBuffers[id].push(new Uint8Array(event.data));
  }
};

Вот как это работает: когда вы звонитеsendBlob(file), он разбивает файл на срезы на 16 КБ. Перед каждым ломтиком он отправляет небольшое сообщение JSON «Chunk-Meta», содержащее уникальный идентификатор передачи, общий размер и смещение тока. Затем он отправляет сырой ArrayBuffer. После того, как это сделано, это излучает маркер «конец». На приемном конце строковые сообщения инициализируют или завершают массив Uint8Arrays; двоичные сообщения добавляют в текущий буфер. Послеchunk-end, вы реконструируете каплей и вызываете функцию рендеринга пользовательского интерфейса - возможно, преобразуя в URL -адрес объекта или инъекции в<img>ярлык.

Чтобы проверить, добавьте ввод файла в форму реагирования:

<input
  type="file"
  accept="image/*"
  onChange={e => {
    const file = e.target.files?.[0];
    if (file) sendBlob(file);
  }}
/>

Отбросьте изображение, и через несколько мгновений оно должно появиться на временной шкале каждого подключенного сверстника, как твит, без какого -либо централизованного сервера загрузки. Этот разбитый, одноранговая передача разблокирует по-настоящему без серверного обмена богатыми медиа.

Закрепить сетку

В совершенно без серверной сетки важно аутентифицировать сверстников и предотвратить добавление плохих актеров. Мы будем использовать Tweetnacl от ECDH Exchange и подписи ED25519, чтобы каждое сообщение было проверяемой подписью, а коллеги могут проверять отпечатки пальцев с открытым ключом отдельно, прежде чем доверять друг другу.

Запустите эту команду один раз в корне проекта, чтобы установить крипто -библиотеку:

npm install tweetnacl tweetnacl-util

Здесь,tweetnaclОбеспечивает быстрые, проверенные инструменты для генерации пар ключей, получения общих ключей и подписания.tweetnacl-utilПредлагает помощников для конвертации между строками, Uint8Arrays и Base64.

В новом модуле (src/crypto.ts), установите функции идентификации и экспорта каждого сверстника:

// src/crypto.ts
import nacl from 'tweetnacl'
import { encodeBase64, decodeUTF8, encodeUTF8 } from 'tweetnacl-util'

// Generate or load a persistent keypair; here we generate fresh on each reload
const { publicKey, secretKey } = nacl.sign.keyPair()

// Compute a stable fingerprint for display (first 8 bytes of SHA-256 of pubkey)
async function fingerprint(pk: Uint8Array) {
  const hash = await crypto.subtle.digest('SHA-256', pk)
  const bytes = new Uint8Array(hash).slice(0, 8)
  return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('')
}

// Sign arbitrary JSON-serializable payloads
export function signMessage(payload: any) {
  const json = JSON.stringify(payload)
  const msgUint8 = decodeUTF8(json)
  const signed = nacl.sign(msgUint8, secretKey)
  return encodeBase64(signed)
}

// Verify and decode a signed message
export function verifyMessage(signedB64: string) {
  const signed = Uint8Array.from(atob(signedB64), c => c.charCodeAt(0))
  const opened = nacl.sign.open(signed)
  if (!opened) throw new Error('Invalid signature')
  const json = encodeUTF8(opened)
  return JSON.parse(json)
}

export { publicKey, fingerprint }

Вот что делает каждая часть: вы генерируете клавиатуру ED25519, используяnacl.sign.keyPair()Полем АfingerprintПомощный хэши, общедоступный ключ и отображает первые 16 шестнадцатеричных символов, что позволяет сверстникам легко сравнивать и подтвердить через чат или QR -код.signMessageПреобразует вашу полезную нагрузку в JSON, подписывает ее и возвращает строку BASE64.verifyMessageОбращается с этим процессом: он базовый 64-декодирует строку, проверяет подпись против открытого ключа в подписанном сообщении, и анализирует JSON, если она действительна (в противном случае, она бросает ошибку).

Затем интегрируйте подпись в ваши обновления CRDT вcrdt.tsПолем Вместо того, чтобы выдвигать необработанные объекты, оберните их:

import { signMessage, verifyMessage, publicKey } from './crypto'

// Modify addPost:
export function addPost(text: string) {
  const entry = { id: crypto.randomUUID(), text, ts: Date.now() }
  const signed = signMessage({ ...entry, author: encodeBase64(publicKey) })
  posts.push([{ signed }])
}

// When loading posts from CRDT:
posts.observe(event => {
  event.changes.added.forEach(item => {
    const { signed } = item.content.getContent()[0]
    try {
      const { id, text, ts, author } = verifyMessage(signed)
      renderTimelineEntry({ id, text, ts, author })
    } catch {
      console.warn('Discarded forged post')
    }
  })
})

Каждое сообщение в настоящее время несет публичный ключ своего автора и подпись ED25519. При получении вы звонитеverifyMessage; Если проверка не удается, вы молча сбрасываете обновление. В вашем пользовательском интерфейсе (например, в заголовке приложения) отобразите свой собственный отпечаток пальца:

const [fp, setFp] = useState<string>()
useEffect(() => {
  fingerprint(publicKey).then(setFp)
}, [])
// Render: Your fingerprint: {fp}

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

Заключение

Теперь вы успешно создали полностью без серверного, подобного Twitter микроблог, который полностью работает в браузере. Используя простую сигнальную заглушку, прямые каналы WEBRTC, хранилище временной шкалы CRDT, автономное хранилище в IndexedDB, бинальные бинарные передачи и подпись с сквозными сообщениями, вы устранили все централизованные детали. Ваши сообщения идут непосредственно от сверстников к сверстнику, ваши данные остаются под вашим контролем, и пользователи могут проверить личности друг друга без посредника. Отсюда вы можете сделать приложение прогрессивным веб -приложением (PWA), чтобы мобильные пользователи могли установить его как нативное приложение или усилить ваши варианты отступления с помощью выделенного реле поворота для сверстников со строгими NAT. Вы также можете использовать хранилище объектов Vultr для обслуживания статического пакета по всему миру, сохраняя при этом ваш сигнальный заглушка или даже заменить его на децентрализованную сигнальную сеть. Что бы вы ни решили, установленная вами основа показывает, что истинные, без серверных социальных сетей не только возможны, но и легко доступны для ваших пользователей.


Оригинал
PREVIOUS ARTICLE
NEXT ARTICLE