Перейти к содержимому
Поиск
Разработка

Yjs: совместные данные и редакторы — мастерская и 6 живых сценариев

Две копии документа на настоящем Yjs: общий текст, задачи, работа без связи, своя отмена, присутствие и восстановление. Шесть живых сценариев и исходники с Node/WebSocket для Windows/Linux.

Александр Некрасов
Yjs — совместный документ, синхронизация и правки без соединения

Yjs на практике: общий текст, задачи, правки без связи, локальная отмена, присутствие и сохранение. Шесть сценариев, настоящий runtime и отдельный Node/WebSocket проект.

Yjs: совместная работа, которую можно проверить руками

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

Yjs — библиотека JavaScript для общих структур данных и совместного редактирования. Она представляет изменения так, чтобы независимые копии могли обменяться ими и прийти к согласованному состоянию. Это полезная основа для заметок, редакторов, досок, карточек и других интерфейсов, где несколько людей работают с одним содержимым.

На этой странице не только объяснение терминов. Ниже работает настоящий Yjs с двумя копиями документа: можно выключить доставку, изменить обе стороны, применить сообщения в обратном порядке, отменить свою вставку и сохранить состояние. В исходниках есть отдельный Node/WebSocket сервер для двух настоящих вкладок. Мы разберём также то, что остаётся задачей приложения: доступ, сохранение, проверку данных и удобное поведение интерфейса.

Где применять: документы, CRM, доски и работа без связи

Самый очевидный сценарий — совместная заметка: протокол встречи, описание задачи, техническое задание или черновик статьи. Людям не требуется передавать файл друг другу и ждать, пока коллега закончит правки. Для редактора важны не только итоговые символы, но и ощущение устойчивости: ввод не исчезает, курсор не скачет без причины, а состояние соединения объясняется человеческим языком.

Другой вариант — рабочая карточка с названием, статусом и списком пунктов. Например, в учебной CRM на Vue можно добавить общую заметку клиента или план следующего контакта. При этом платёж, остаток товара или изменение прав лучше оставлять отдельными серверными операциями с проверяемыми правилами.

Yjs особенно интересен там, где правки возникают независимо: сотрудник едет между объектами, связь нестабильна, вкладка временно отключена. Но отсутствие сети и перезагрузка браузера — разные события. Пока документ живёт в памяти, правки остаются в открытой вкладке; для переживания перезапуска понадобится отдельное постоянное хранилище. Важно показать эту границу пользователю до того, как он доверит приложению важный текст.

Документ, структура, binding и транспорт: кто за что отвечает

Y.Doc хранит общий документ. Внутри него находятся структуры: Y.Text для текста, Y.Map для полей, Y.Array для упорядоченных элементов. Интерфейс читает эти структуры и записывает в них действия человека. Связующий слой между редактором и Y.Text называют binding: именно он переводит ввод, выделение и события редактора в понятные библиотеке операции.

Транспорт доставляет бинарные изменения другим участникам. Это может быть WebSocket или другой выбранный канал. Само наличие соединения ещё не делает обычную строку совместно редактируемой: нужно договориться, какие изменения отправлять, как их применять и как восстанавливаться после разрыва. Официальный вводный материал объясняет такое разделение редактора, документа и provider.

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

import * as Y from 'yjs';
const doc = new Y.Doc();
const note = doc.getText('note');
const card = doc.getMap('card');
const tasks = doc.getArray('tasks');

Честный механизм демонстрации: два документа в одной вкладке

Основная мастерская создаёт две независимые Y.Doc. У каждой есть свои локальные изменения, история отмены и состояние присутствия. Когда включена автодоставка, бинарное сообщение одной копии сразу применяется к другой. Когда она отключена, сообщение остаётся в очереди. Здесь нет искусственно нарисованного результата с заранее подготовленной строкой: объединение выполняет установленная библиотека Yjs.

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

В скачиваемом проекте есть второй режим. Страница ws.html подключается к своему Node-серверу, а две вкладки получают изменения через настоящее сетевое соединение. Этот режим специально ограничен текстом: основной интерфейс показывает больше типов данных, а небольшой сервер позволяет изучить сам путь сообщения. Его протокол описан в исходниках и не выдается за готовый совместимый сервер y-websocket.

Мини-мастерская: шесть сценариев и свои изменения

Начните с обычного ввода: допишите строку у Анны и посмотрите на Бориса. Затем выберите опыт «Правки без связи» и нажмите «Подготовить опыт заново». Обе стороны получат разные добавления. Сравните состояния, доставьте очередь в обратном порядке с повтором и убедитесь, что обе независимые строки сохранились, а повторное сообщение не размножило текст.

Следующие опыты показывают карточку и задачи, локальную отмену, присутствие и сохранение. Пункты списка меняют подсказку; только отдельная кнопка явно сбрасывает учебные данные. Внизу есть читаемый JSON обеих копий и журнал размеров сообщений. Это помогает увидеть связь между действием человека и фактическим результатом, а не просто наблюдать движущиеся индикаторы.

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

Версии и воспроизводимый проект

В проекте закреплены Yjs 13.6.33, y-protocols 1.0.7, ws 8.22.2 и Bootstrap 5.3.8. Для сборки используется esbuild 0.28.2. На момент подготовки материала версия Yjs 13.6.33 обозначена как стабильный релиз, а ветка 14 опубликована отдельно как предварительная; это можно проверить в официальных релизах. Мастерская сознательно использует стабильную ветку.

Архив содержит package.json и package-lock.json. Команда npm ci восстанавливает зафиксированный набор зависимостей, а npm run build создаёт локальную браузерную сборку. Библиотеки не подгружаются со случайного CDN при каждом посещении. Это упрощает размещение на собственном домене, проверку лицензий и воспроизведение найденной ошибки.

Обновление версии — отдельная работа: сначала запустите тесты на копии проекта, затем сравните ввод, отмену, восстановление и сетевой режим. Даже если API выглядит прежним, у приложения есть собственные договорённости о структуре документа и поведении интерфейса. Проверять нужно именно их, а не только успешный импорт пакета.

npm ci --ignore-scripts
npm run build
npm test
npm audit --audit-level=low

Windows: первый запуск и две настоящие вкладки

Установите поддерживаемый Node.js 24 LTS версии не ниже 24.15. Распакуйте архив и откройте PowerShell непосредственно в каталоге yjs-project. Сначала проверьте версию Node, затем установите зависимости и соберите проект. Готовая папка public уже включена, но воспроизводимая сборка полезна, если вы собираетесь менять код.

Команда serve открывает локальный HTTP-сервер для основной мастерской. Перейдите на указанный адрес в браузере; двойной щелчок по HTML через file:// для модулей не подходит. Для остановки процесса нажмите Ctrl+C в том же терминале. Если порт занят другим приложением, выберите другой через переменную PORT, не останавливая чужой процесс.

Для сетевого опыта запустите npm.cmd run ws во втором окне PowerShell. Откройте http://127.0.0.1:8312/ws.html дважды, оставьте одинаковое имя комнаты и нажмите «Подключить» в обеих вкладках. Первоначально текст пустой. Наберите заметку, отключите одну вкладку, внесите независимые правки и подключитесь снова. Так вы проверите уже реальную доставку через Node.

node --version
npm.cmd ci --ignore-scripts
npm.cmd run build
npm.cmd test
npm.cmd run serve
# http://127.0.0.1:8313/
# Во втором терминале:
npm.cmd run ws
# http://127.0.0.1:8312/ws.html

Linux и VPS: запуск без публичного открытия стенда

На Linux используются те же исходники и lock-файл. Работайте обычным пользователем с установленным Node.js 24 LTS. После npm ci, сборки и тестов запустите WebSocket-режим. По умолчанию оба учебных сервера слушают только 127.0.0.1: они доступны на самом компьютере, но не выставляют комнату в интернет.

Если проект работает на VPS, откройте SSH-туннель с рабочего компьютера. Локальный порт будет перенаправлен на loopback-порт сервера. После этого браузер использует привычный адрес 127.0.0.1, а транспорт остаётся внутри SSH-соединения. Замените user и your-server своими значениями; не добавляйте для этой проверки публичное правило firewall на 8312.

Такой способ полезен и для диагностики: вы можете проверить сборку, соединение и сообщения до настройки домена и reverse proxy. После успешного опыта уже решайте, кто получит доступ к сервису, как будет проверяться комната и где хранить документы. Приватный запуск — законченный практический шаг, а не обещание готовой многопользовательской платформы.

node --version
npm ci --ignore-scripts
npm run build
npm test
npm run ws
# С рабочего компьютера:
ssh -L 8312:127.0.0.1:8312 user@your-server

Пример 1. Общий текст: меняем участок, а не всю строку

В первом опыте используется Y.Text с именем note. Это общее имя структуры, поэтому обе копии должны обращаться к нему одинаково. Вставка и удаление описывают конкретную правку. Для начального наполнения достаточно создать содержимое в одном документе и передать состояние второму; если независимо вставить один и тот же исходный текст в оба, библиотека увидит две разные вставки.

При изменении обычного textarea наша обвязка сравнивает предыдущую и новую строку, находит общий префикс и суффикс и применяет маленький splice. Это заметно лучше удаления всего текста при каждом нажатии: независимая правка другого участника сохраняет собственную структуру. Границы сравнения учитывают Unicode code points, чтобы замена одного emoji не разрезала суррогатную пару.

На практике сначала испытайте исправление слова в середине, вставку новой строки и полное очищение поля. Затем повторите это при отключённой доставке. Если вы строите полноценный редактор документов, используйте поддерживаемый binding, а не расширяйте учебный textarea до бесконечности. Сам Y.Text и его индексы описаны в API текста.

const doc = new Y.Doc();
const note = doc.getText('note');
doc.transact(() => {
  note.insert(0, 'План встречи');
  note.insert(note.length, '\nПроверить форму.');
}, 'local-input');

Бинарные updates: что именно передаётся между копиями

После изменения документ выдаёт бинарный update. Его не нужно превращать в JSON со всем текстом или раскодировать собственным парсером. Получатель применяет сообщение к своей Y.Doc. Для демонстрации можно передать Uint8Array напрямую; для сети понадобится выбранный транспорт и договорённость о формате сообщения.

Мастерская показывает размер каждого update в байтах. Небольшое исправление часто отличается от отправки полного состояния, но фиксированного отношения «один символ равен одному байту» здесь нет. Размер зависит от служебной структуры и истории. Журнал ограничен последними событиями, чтобы сам интерфейс оставался читаемым при долгом наборе.

У обработчика должен быть способ отличать своё изменение от полученного. Мы помечаем применение удалённого update специальным origin и не пересылаем его обратно. Origin — локальная метка обработки, а не доказательство личности автора. В документации Document Updates описаны применение, полное состояние и обмен недостающими частями.

const remote = Symbol('remote');
a.on('update', (bytes, origin) => {
  if (origin !== remote) Y.applyUpdate(b, bytes, remote);
});
b.on('update', (bytes, origin) => {
  if (origin !== remote) Y.applyUpdate(a, bytes, remote);
});

Пример 2. Без связи: обратный порядок и повтор сообщения

Отключите автодоставку. Теперь Анна и Борис видят собственные правки, а статус сообщает, сколько сообщений ждёт передачи. Допишите разные строки в конец заметки. Состояния разойдутся — это ожидаемое состояние работы без связи, а не повод автоматически стирать одну из копий.

Нажмите «В обратном порядке + повтор». Мастерская берёт накопленные сообщения, разворачивает порядок и применяет каждое дважды. После получения всех изменений копии должны совпасть. Точный порядок одновременно вставленных фрагментов не следует объявлять бизнес-приоритетом: библиотека обеспечивает согласование, а смысл текста оценивает человек.

Этот опыт помогает объяснить, зачем приложение хранит идентичность изменений. Простой повтор POST с полной строкой и CRDT-update ведут себя по-разному. Однако сходимость не отменяет необходимость доставки: если одна сторона никогда не получила часть правок, одинакового результата ждать нельзя. В рабочем интерфейсе полезно различать «записано локально», «отправлено» и «подтверждено серверным хранилищем».

const queue = [];
a.on('update', bytes => queue.push(new Uint8Array(bytes)));
a.getText('note').insert(0, 'Анна: ');
for (const bytes of queue.reverse()) {
  Y.applyUpdate(b, bytes);
  Y.applyUpdate(b, bytes); // тот же update второй раз
}

State vector: синхронизировать недостающие изменения

Повторное подключение не обязательно должно пересылать всю историю отдельными сообщениями. State vector описывает уже известные части состояния. С его помощью одна копия может подготовить update, содержащий недостающее для другой. В мастерской кнопка «Сверить state vector» выполняет такой обмен в обе стороны.

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

Нельзя сравнивать только длину строки или число задач и считать документы синхронизированными. Два текста одинаковой длины могут отличаться, а одинаковый видимый текст не означает одинаковую историю. В интерфейсе мы сравниваем читаемое содержимое для удобства человека; протокол обмена при этом использует собственные бинарные структуры Yjs. Это два разных уровня проверки.

const forB = Y.encodeStateAsUpdate(a, Y.encodeStateVector(b));
const forA = Y.encodeStateAsUpdate(b, Y.encodeStateVector(a));
Y.applyUpdate(b, forB);
Y.applyUpdate(a, forA);

Пример 3. Карточка и задачи: Y.Map внутри Y.Array

Карточка проекта содержит название и статус в Y.Map. Список задач — Y.Array, а каждый пункт представлен вложенной Y.Map. Такой выбор позволяет менять выполненность конкретной задачи как отдельное поле, не заменяя целиком массив обычных JavaScript-объектов. Идентификатор пункта помогает интерфейсу найти именно его после вставок и удалений.

Попробуйте отключить доставку и добавить по задаче с каждой стороны. Затем измените название карточки у Анны и статус у Бориса. После объединения вы увидите обе задачи и оба независимых изменения полей. Теперь отметьте один пункт выполненным или удалите его: другая копия должна отразить ту же операцию.

Проверка количества задач в браузере ограничивает удобство одного учебного ввода, но не создаёт глобальную транзакцию. Два отключённых клиента способны независимо добавить элементы и после объединения превысить локальный лимит. Мастерская не отбрасывает такие правки. Если продукт обязан иметь строго не более определённого числа позиций, это отдельное правило сервера и пользовательского процесса.

const task = new Y.Map();
task.set('id', 'task-104');
task.set('title', 'Проверить письмо');
task.set('done', false);
doc.transact(() => {
  doc.getArray('tasks').push([task]);
}, 'local-input');
task.set('done', true);

Если оба меняют одно поле: сходимость и бизнес-смысл

Не все правки можно показать как два одновременно сохранённых значения. Отключите связь и задайте разные названия одной карточки. После объединения обе копии будут показывать одно согласованное значение. Из этого нельзя делать вывод, что библиотека выбрала более правильное название или учла полномочия автора.

Для скалярного поля приложение должно заранее определить ожидаемое поведение. Иногда достаточно согласованного последнего видимого состояния. Иногда нужно показать историю, запросить подтверждение, запретить параллельное действие или сохранить предложения как отдельные элементы. Например, изменение контактной заметки и утверждение суммы договора имеют разные последствия.

Поэтому CRDT удобно применять к совместному содержимому, но не стоит автоматически переносить туда каждую колонку базы. Сначала перечислите конфликтующие действия и решите, что должен увидеть пользователь. В статье и исходниках название карточки служит наглядным примером такого ограничения; подробные операции с ключами доступны в документации Y.Map.

a.getMap('card').set('title', 'Название Анны');
b.getMap('card').set('title', 'Название Бориса');
// После обмена updates обе стороны согласуются.
// Это не проверка полномочий и не выбор бизнес-приоритета.

Пример 4. UndoManager: отменить своё, сохранить чужое

В обычном локальном редакторе кнопка отмены часто возвращает предыдущий снимок всей строки. В совместном документе это может убрать и недавнюю правку коллеги. Здесь используется UndoManager, который отслеживает операции нужного происхождения. У каждого участника своя история; начальное наполнение и доставленные изменения в неё не входят.

Подготовьте опыт «Своя отмена». В заметке появятся отдельные строки Анны и Бориса. Нажмите отмену у Анны: её добавление исчезнет, а независимое добавление Бориса останется. Затем выполните повтор. Такой конкретный результат легче оценить, чем общее обещание «поддерживается undo».

Продумайте границы действия: серия быстро набранных символов обычно должна отменяться как понятный фрагмент. captureTimeout объединяет близкие изменения, а stopCapturing позволяет явно разделить операции. При смене документа историю нужно уничтожить вместе с прежними подписками. API, trackedOrigins и управление группировкой описаны в официальном разделе UndoManager.

const local = Symbol('local');
const undo = new Y.UndoManager(doc.getText('note'), {
  trackedOrigins: new Set([local]), captureTimeout: 500
});
doc.transact(() => doc.getText('note').insert(0, 'Моя правка. '), local);
undo.stopCapturing();
undo.undo();
undo.redo();

Пример 5. Awareness: присутствие отдельно от документа

Имя участника в панели присутствия — временное состояние. Если Борис закрыл вкладку, его текст не должен исчезнуть вместе с именем. Для такого слоя используется awareness из y-protocols. В мастерской он передаётся отдельно от изменений заметки и не включается в файл резервного состояния документа.

Измените имя, нажмите «Применить присутствие», затем снимите «В сети». В другой копии список участников обновится, а карточка и задачи сохранятся. При отключении автодоставки удалённое присутствие скрывается явно; ручная синхронизация снова обменивает текущие состояния. Это выбранное поведение учебного транспорта, которое видно в коде.

В реальном интерфейсе присутствие не доказывает личность и не выдаёт права. Клиентское имя можно изменить, а clientID документа не следует использовать как постоянный идентификатор аккаунта. Сервер авторизации связывает соединение с пользователем отдельно. В API awareness описаны локальное состояние, его удаление и механизм временного присутствия.

import { Awareness } from 'y-protocols/awareness.js';
const presence = new Awareness(doc);
presence.setLocalState({ user: { name: 'Анна', color: '#147d70' } });
presence.setLocalState(null); // уйти из присутствия
// Сам doc при этом не удалён.

Пример 6. Скачать состояние и восстановить документ

Кнопка «Скачать .yjs» сохраняет бинарное состояние выбранной копии. Такой файл содержит данные Yjs, необходимые для продолжения общей истории. Рядом можно скачать JSON обеих копий — это удобный читаемый отчёт для изучения, но он не является взаимозаменяемой CRDT-резервной копией.

Для проверки допишите заметку, скачайте файл, измените текст ещё раз и используйте отдельный блок «Восстановить сохранённый документ». Он создаёт обе учебные копии из файла. История локальной отмены начинается заново, а прежние экземпляры и подписки уничтожаются. Важно, что исходная заметка не вставляется повторно поверх восстановленного состояния.

Рабочее резервное копирование должно учитывать не только возможность скачать байты, но и возможность их прочитать позже. Сохраняйте версию формата приложения, принадлежность документа, время и правила доступа рядом с самим бинарным состоянием. Проверяйте восстановление на отдельной копии. Успешный ответ файловой системы ещё не означает, что пользовательский документ действительно восстановлен.

import { writeFile, readFile } from 'node:fs/promises';
await writeFile('note.yjs', Y.encodeStateAsUpdate(doc));
const restored = new Y.Doc();
Y.applyUpdate(restored, new Uint8Array(await readFile('note.yjs')));
console.log(restored.getText('note').toString());

Импорт объединяет, восстановление заменяет: почему это важно

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

Поэтому в интерфейсе разведены два действия. «Импорт» подходит для обмена состоянием общей истории. «Восстановить обе копии» открывает сохранённое состояние как основу нового опыта без дополнительного seed. Это небольшое различие в названии кнопки предотвращает очень заметную путаницу для пользователя.

Файл проверяется на временном документе: размер до 64 КиБ, известные разделы и допустимая структура карточки и задач. Если проверка не прошла, рабочее состояние сохраняется. Впрочем, ограниченная проверка нашего учебного формата не делает произвольные чужие бинарные документы доверенными. Для публичного приёма файлов нужен отдельный ресурсный и прикладной контракт.

const trial = new Y.Doc();
Y.applyUpdate(trial, Y.encodeStateAsUpdate(doc));
Y.applyUpdate(trial, uploadedBytes);
// validate(trial) — собственные ограничения приложения.
// Только после успешной проверки применяем к рабочему doc.
trial.destroy();

Курсор, emoji и составной ввод: тонкости plain-text binding

При удалённой вставке обычный числовой индекс курсора может указывать уже на другой символ. RelativePosition связывает позицию с элементами документа, а затем переводится обратно в текущий индекс. Наш binding использует это для выделения активного textarea. Основная идея и API описаны в разделе относительных позиций.

Второй нюанс — кодировка. JavaScript и Y.Text считают длину в UTF-16 единицах, а один видимый emoji может занимать больше одной такой единицы. При поиске общего префикса наивное сравнение отдельных единиц способно разрезать пару. В исходниках сравнение выполняется по code points, затем позиции переводятся в индексы строки; на замену emoji есть отдельная проверка.

Третий случай — composition, когда ввод собирается несколькими событиями, например через IME. Binding сохраняет опорные позиции начала ввода и применяет завершённую правку. Это обучающая реализация plain text, не универсальная замена профессиональному редактору. Для CodeMirror или Tiptap выбирайте соответствующую поддерживаемую интеграцию и отдельно проверяйте ввод на устройствах вашей аудитории.

const anchor = Y.createRelativePositionFromTypeIndex(note, 3);
// После доставленных изменений:
const position = Y.createAbsolutePositionFromRelativePosition(anchor, doc);
if (position) {
  console.log(position.index);
}

Настоящий WebSocket: две вкладки и одна комната

В исходниках сервер создаёт пустую Y.Doc для комнаты, принимает бинарные updates и отправляет изменения остальным подключённым клиентам этой комнаты. Новая вкладка получает текущее состояние, затем передаёт собственное. После разрыва её локальная копия остаётся в памяти; повторное соединение с той же комнатой объединяет накопленные правки.

Имя комнаты выбирается до подключения и ограничено латинскими буквами, цифрами и дефисом. При переключении на другую комнату клиент создаёт новый документ, чтобы случайно не отправить туда прежние данные. Для исходного проекта это важнее красивой анимации: смешивание комнат является ошибкой продукта, даже если библиотека корректно объединила полученные байты.

Это небольшой собственный протокол: WebSocket binary frame несёт update. Он не реализует wire-протокол пакета y-websocket, поэтому подключать к нему произвольный provider с таким названием нельзя. Для полноценного сервиса можно выбрать готовый provider и его сервер, но тогда нужно согласованно заменить обе стороны. С общими принципами каналов реального времени поможет и наш материал о Socket.IO.

const socket = new WebSocket('ws://127.0.0.1:8312/sync?room=team-demo');
socket.binaryType = 'arraybuffer';
socket.addEventListener('message', event => {
  Y.applyUpdate(doc, new Uint8Array(event.data), 'socket');
});
socket.addEventListener('open', () => {
  socket.send(Y.encodeStateAsUpdate(doc));
});

Серверные ограничения: очередь, размер и проверка комнаты

Даже учебный WebSocket-сервер не должен бесконечно принимать любое сообщение. В архиве ограничены размер frame, частота сообщений, число комнат и подключений. Входной update сначала применяется к временной копии. Сервер разрешает только структуру note, проверяет длину текста и размер полного состояния, затем меняет рабочий документ.

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

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

// Ограничения текущего учебного сервера:
// 8 комнат; 8 клиентов на комнату; всего 32 соединения.
// Frame <= 64 KiB; до 30 сообщений/с от соединения.
// Текст <= 4000 UTF-16 единиц; состояние <= 128 KiB.
// Неверный update / лимит документа или частоты: close(1008).
// Слишком большой frame: close(1009); текстовый frame: close(1003).
// Медленный получатель: close(1013); нет места в комнате: HTTP 503.
// Отклонённый update не меняет рабочее состояние комнаты.

Reverse proxy: HTTPS и WebSocket на Linux

Статическую мастерскую можно разместить в каталоге существующего HTTPS-сайта: ей достаточно HTML, CSS и JavaScript. Node для основной страницы не нужен. Сетевой режим требует отдельного процесса и проксирования WebSocket, но сначала хост должен быть защищён подходящей системой входа или доступен только через VPN.

Для Caddy базовый reverse_proxy обрабатывает WebSocket-соединение. В Nginx требуется передать Upgrade и Connection, а также сохранить ожидаемый Host. Готовые фрагменты лежат в deploy; они рассчитаны на существующий защищённый хост, а не на публикацию анонимной комнаты. Origin-проверка нашего сервера полезна, но сама по себе не заменяет учётную запись.

После настройки проверяйте обычный HTTP, открытие соединения и обмен между двумя вкладками. Если HTML загружается, а текст не синхронизируется, смотрите upgrade, выбранный протокол ws/wss, Host, Origin и журнал сервиса. Подробности общей настройки разобраны в материалах о Caddy и Nginx.

# Фрагмент уже защищённого HTTPS server в Nginx
location / {
    proxy_pass http://127.0.0.1:8312;
    proxy_http_version 1.1;
    proxy_set_header Host $host;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "upgrade";
    proxy_read_timeout 60s;
}

systemd: запуск от отдельного пользователя и диагностика

Для постоянного приватного стенда на Linux удобнее systemd, чем открытый терминал. В архиве есть unit для отдельного пользователя yjs-demo, каталога /opt/yjs-demo и Node по пути /usr/bin/node. Перед применением проверьте реальные пути и создайте непривилегированного пользователя средствами вашей системы; unit не должен запускаться от root ради удобства.

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

После установки проверьте status, последние строки journal и локальный /health. Затем откройте две вкладки через SSH-туннель. При обновлении зависимостей сначала соберите и протестируйте новый релиз, продумайте остановку соединений и сохранение документов. Автоматический restart не является заменой резервной копии: память процесса после него будет новой.

sudo install -m 0644 deploy/yjs-demo.service /etc/systemd/system/yjs-demo.service
sudo systemctl daemon-reload
sudo systemctl enable --now yjs-demo
systemctl status yjs-demo --no-pager
journalctl -u yjs-demo -n 60 --no-pager
curl --fail http://127.0.0.1:8312/health
# Остановить только этот стенд:
sudo systemctl stop yjs-demo

Постоянное хранение: открытая вкладка не является базой

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

В рабочем продукте можно хранить бинарные изменения, периодические полные состояния или сочетание этих подходов. Отдельно определите момент подтверждения сохранения: получено в память, записано в устойчивое хранилище, включено в резервную копию. Пользовательское сообщение «Сохранено» должно соответствовать одному явно выбранному событию.

Для восстановления проверяйте версии приложения и схему документа. Удалённые с экрана данные и компактный читаемый JSON не описывают все особенности внутреннего состояния. Не придумывайте собственную очистку бинарных байтов. Измеряйте рост документов, выбирайте проверенный механизм хранения и испытывайте возврат из резервной копии на отдельном стенде до того, как это понадобится в аварии.

// Минимальный снимок; политику атомарной записи и backup задаёт приложение.
const bytes = Y.encodeStateAsUpdate(doc);
// Сохраняйте рядом: documentId, schemaVersion, время и права владельца.
// JSON из doc.toJSON() удобен для чтения, но не заменяет bytes.

Авторизация, личные данные и бизнес-операции

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

Origin защищает только часть сценариев браузерного обращения: его проверка не доказывает владельца аккаунта. Точно так же имя из awareness и clientID нельзя принимать за серверную идентичность. Не складывайте в общий документ токены, пароли и служебные поля, которые не должны попасть всем участникам комнаты.

Финансовые и другие необратимые операции лучше оформлять отдельными серверными командами с явным подтверждением и проверкой. Совместная заметка может содержать обсуждение счёта, но само списание денег не должно возникать из того, что клиент выставил поле paid в Y.Map. Такое разделение делает продукт понятнее и помогает сохранить проверяемую историю важных действий.

Производительность и жизненный цикл: что измерять

Быстрый короткий опыт не доказывает, что тот же интерфейс выдержит большой документ и сотни участников. Измеряйте размер полного состояния, частоту updates, время применения, число подписок, размер исходящего буфера и задержку реакции интерфейса. Наблюдение за сервером можно строить по подходу из нашего материала о Prometheus.

При уходе со страницы уничтожайте binding, UndoManager, Awareness и Y.Doc, закрывайте соединение. Иначе старые таймеры и подписки могут продолжать работать, даже когда пользователь уже открыл другой документ. Особенно легко получить такую ошибку в компоненте, который несколько раз монтируется и размонтируется.

Наш журнал ограничен 32 событиями, а длинная очередь объединяется через mergeUpdates. Это удобство демонстрации, а не универсальная политика памяти для любого сервиса. Если операция заметно блокирует вкладку, сначала выясните размер данных и объём перерисовки. Часто дорого обходится не сам CRDT, а полное пересоздание большого интерфейса после каждого символа.

unbindTextarea();
undo.destroy();
awareness.destroy();
socket?.close();
doc.destroy();

Проверки: что действительно подтверждает рабочий пример

В исходниках есть проверки сходимости при обратной и повторной доставке, обмена state vector, независимых полей и задач, локальной отмены, присутствия, ошибочного импорта и Unicode. Отдельный DOM-тест запускает реальную собранную логику интерфейса, а сетевые тесты соединяют настоящие WebSocket-клиенты с локальным сервером.

Сетевой слой проверяется также на чужой Origin, неправильный update и попытку добавить неизвестный раздел документа. При ошибке рабочая комната не должна молча принимать неподдерживаемое состояние. Такие проверки важнее теста, который просто подтверждает существование кнопки или повторяет присваивание из исходника.

Автоматические тесты не заменяют ручное использование: на телефоне важны размеры кнопок, порядок блоков, клавиатура и прокрутка внутри статьи. После собственного изменения проекта повторите основные сценарии и проверьте скачанный файл восстановлением. При переносе на хост дополнительно убедитесь, что JavaScript отдаётся с подходящим MIME и все локальные ресурсы доступны.

npm test
npm run build
npm audit --audit-level=low
# Затем: две вкладки, offline → merge, undo, binary restore.

С чего начать внедрение в свой сервис

Выберите один небольшой документ с понятным назначением: заметку встречи, описание задачи или общий список подготовительных действий. Опишите, какие поля редактируются совместно, какие действия остаются серверными и что пользователь увидит без связи. Затем добавьте редактор, подходящий binding и транспорт, сохраняя границы этих слоёв.

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

Скачиваемая мастерская даёт основу для изучения: настоящий runtime, шесть проверяемых сценариев, явную очередь сообщений и отдельный сетевой пример. Возьмите из неё структуры и способы проверки, а production-редактор, аккаунты и хранилище подберите под свой продукт. Начните с конкретного законченного пути пользователя — тогда станет ясно, какую пользу даёт совместное редактирование и какие обязанности остаются у приложения.