Інтеграція Yjs для real-time collaboration в мобільному додатку
Y.js — CRDT-бібліотека на JavaScript, яку все частіше тягнуть у React Native-проекти, розраховуючи отримати Google Docs-опис у мобільному додатку. Реальність складніша: Y.js розроблявся під браузерне оточення, немає офіційного Flutter SDK, а Hermes на старих RN-версіях зустрічає WASM-бінарник @automerge/automerge з паніки при ініціалізації. Розбиремо, де справжні грабліФ.
Як влаштована синхронізація у Y.js
Кожен Y.Doc містить внутрішній steyт-vector — Map<clientId, maxClock>. При підключенні двох клієнтів вони обмінюються своїми стейт-векторами й запрашують тільки дельту: Y.encodeStateAsUpdateV2(doc, remoteStateVector). Це дифференційний протокол — при реконнекті не потрібно передавати весь документ.
Транспортний рівень реалізований через провайдери:
| Провайдер | Транспорт | Особливості |
|---|---|---|
y-websocket |
WebSocket | Офіційний, є серверна частина |
y-webrtc |
WebRTC DataChannel | P2P, немає у RN без polyfill |
y-indexeddb |
IndexedDB | Тільки браузер |
| Кастомний | SQLite / AsyncStorage | Потрібна ручна реалізація для RN |
Для React Native: y-websocket на транспортному рівні працює через react-native-get-random-values + нативний WebSocket. Персистентність — кастомний провайдер поверх react-native-sqlite-storage або op-sqlite.
Персистентність через SQLite у React Native
Готового y-sqlite-провайдера для RN немає. Мінімальна реалізація:
import * as Y from 'yjs';
import { openDatabase } from 'react-native-sqlite-storage';
const db = openDatabase({ name: 'collab.db' });
// Ініціалізація таблиці
db.transaction(tx => {
tx.executeSql(
'CREATE TABLE IF NOT EXISTS ydocs (id TEXT PRIMARY KEY, update BLOB, ts INTEGER)'
);
});
// Збереження при кожній зміні
ydoc.on('updateV2', (update: Uint8Array, origin: unknown) => {
if (origin === 'sqlite-load') return; // не зацикливаємось
const encoded = Buffer.from(update).toString('base64');
db.transaction(tx => {
tx.executeSql(
'INSERT OR REPLACE INTO ydocs (id, update, ts) VALUES (?, ?, ?)',
[docId, encoded, Date.now()]
);
});
});
// Завантаження при відкриванні
db.transaction(tx => {
tx.executeSql('SELECT update FROM ydocs WHERE id = ?', [docId], (_, result) => {
if (result.rows.length > 0) {
const raw = Buffer.from(result.rows.item(0).update, 'base64');
Y.applyUpdateV2(ydoc, new Uint8Array(raw), 'sqlite-load');
}
});
});
Проблема з цим підходом: при частому редагуванні (типінг у реальному часі) updateV2 триггерується при кожному символі. Батчинг обов'язковий — debounce на 300–500 мс або накопичення через Y.mergeUpdatesV2. Без цього у prodacшені у вас швидко закінчиться місце на пристрої, а транзакції у SQLite почнуть блокувати JS-тред.
Серверна частина: y-websocket vs власний сервер
Офіційний y-websocket сервер мінімалістичен — зберігає документи у пам'яті. Для prodacшену потрібно:
-
Персистентність — збереження
Y.encodeStateAsUpdateV2()при відключенні останнього клієнта. Підходить LevelDB (пакетy-leveldb) або PostgreSQL з BYTEA-колонкою. -
Авторизація —
y-websocketне перевіряє токени. Потрібен middleware, що перехоплює Upgrade-запит та перевіряє JWT до апгрейду з'єднання. - Масштабування — один процес y-websocket не знає про інші. При горизонтальному масштабуванні — Redis PubSub як шина між вузлами.
Альтернатива: Hocuspocus (надбудова над y-websocket з авторизацією, хуками та готовою персистентністю). Для більшості проектів Hocuspocus закриває 90% серверних потребі без написання custom-сервера.
Типові помилки при інтеграції у React Native
clientID Y.js генерується випадково при створенні Y.Doc. Якщо створювати новий Y.Doc при кожному маунті компонента — клієнт матиме новий ID після кожного розмонтування, і стейт-vector сервера накопичуватиме мертві записи. Фіксу: зберігати ydoc у ref або глобальному steyте, не пересоздавати.
Awareness (курсори, online-статус) через y-protocols/awareness вимагає активного WebSocket. При переході додатка у фоновий режим на iOS WebSocket може бути вбитий через 30–60 секунд. awareness.setLocalState(null) потрібно викликати в обробнику AppState.change → background, інакше користувач буде висіти у списку online-учасників після сворачивания додатка.
Flutter: Y.js через JS runtime
Для Flutter нативного порту Y.js немає. Варіанти:
-
flutter_js— запускає V8/QuickJS, важить ~5 МБ. Y.js працює, але продуктивність на великих документах залишає бажати. - Нативний Dart CRDT:
crdtпакет від Cachapa — реалізує LWW-CRDT, не сумісний з Y.js за протоколом. - Rust FFI через
yrs(Rust-реалізація Y.js) +flutter_rust_bridge— найпродуктивніший шлях, але 4–6 тижнів тільки на біндинги.
Оцінка
React Native + Y.js + кастомний SQLite-провайдер + Hocuspocus бекенд: 6–10 тижнів. Flutter через yrs FFI: 10–16 тижнів. Включає: персистентність, awareness, reconnect-логіку з exponential backoff, тести на конфлікти при одночасному редагуванні. Вартість розраховується індивідуально після аналізу вимог.







