Перейти к содержанию

Собственный локальный provider

До этого момента «Черновик» состоял из локальных объектов. Теперь добавим собственный WebSocket provider: слой, который доставляет updates документа и сведения присутствия в разрешённую комнату. Provider не является обязательным конкретным сервером Yjs. Библиотека допускает различные транспортные решения; в учебном проекте мы выберем небольшой собственный JSON-протокол, чтобы его границы были видны в исходном коде.

Состояние lesson-06 содержит src/provider.js и server.mjs. Сервер слушает только локальный интерфейс и обслуживает комнату draft-42. Он не подключается к публичному демонстрационному серверу. Анна и Борис входят через форму с самостоятельно заданными локальными паролями; затем браузер использует cookie-сессию. Паролей или скрытых внешних ключей в архиве нет.

Provider как отдельный слой

Provider подписывается на doc.on('update'). Локальные операции превращаются в сообщения с типом update и двоичными данными в base64. При входящем пакете он вызывает applyUpdate, назначив источником собственный объект. Подписка сравнивает источник и не отправляет обратно пакет, который этот же provider только что применил. Так мы убираем ненужное эхо на уровне транспорта.

Это не означает, что CRDT сломался бы от повторной доставки. Повторный update рассчитан на согласование, но бесконечный обмен лишними сообщениями расходует сеть и усложняет диагностику. Фильтр по источнику выражает направление доставки. Другой источник, например восстановление из IndexedDB, позднее должен остаться допустимым локальным изменением, которое может потребовать отправки на сервер.

Ниже дан полный src/provider.js. Он использует собственные сообщения hello, sync, update и awareness. Этот формат не совместим на уровне wire protocol с готовым y-websocket, поэтому нельзя заменить адрес сервера на чужой provider и ожидать автоматической работы. Полный серверный обработчик находится рядом в каталоге урока.

import * as Y from 'yjs';
import { encodeAwarenessUpdate, applyAwarenessUpdate } from 'y-protocols/awareness';
export function to64(bytes) {
  let value='';
  for(let start=0;start<bytes.length;start+=8192)value+=String.fromCharCode(...bytes.subarray(start,start+8192));
  return btoa(value);
}
export function from64(value) { return Uint8Array.from(atob(value), c => c.charCodeAt(0)); }
export class LocalProvider {
  constructor(doc, awareness, status, options = {}) {
    this.doc = doc; this.awareness = awareness; this.status = status;
    this.retry = options.retry; this.closed = false; this.attempt = 0; this.seq = 0;
    this.onRevision = options.onRevision || (() => {});
    this.onDoc = (update, origin) => {
      if (origin !== this) this.send({type:'update', seq:++this.seq, data:to64(update)});
    };
    this.onAwareness = ({added, updated, removed}, origin) => {
      if (origin !== this) this.send({type:'awareness', data:to64(encodeAwarenessUpdate(awareness, [...added,...updated,...removed]))});
    };
    doc.on('update', this.onDoc); awareness.on('update', this.onAwareness); this.connect();
  }
  send(message) { if (this.ws?.readyState === WebSocket.OPEN) this.ws.send(JSON.stringify(message)); }
  async connect() {
    if (this.closed) return;
    this.status.textContent = 'Подключение';
    try {
      const response=await fetch('/session-status',{cache:'no-store'});
      if(!response.ok){this.status.textContent='Повторите вход: сессия недоступна';return;}
    } catch {
      this.status.textContent='Сервер недоступен';
      if(this.retry)this.timer=setTimeout(()=>this.connect(),Math.min(10000,500*2**this.attempt++)+Math.random()*250);
      return;
    }
    if(this.closed)return;
    const ws = new WebSocket(`${location.protocol === 'https:' ? 'wss:' : 'ws:'}//${location.host}/sync?room=draft-42`);
    this.ws = ws;
    ws.onopen = () => this.send({type:'hello', clientId:this.doc.clientID, vector:to64(Y.encodeStateVector(this.doc))});
    ws.onmessage = ({data}) => {
      try {
        const m = JSON.parse(data);
        if (m.type === 'sync') {
          Y.applyUpdate(this.doc, from64(m.data), this);
          if (m.role === 'editor') this.send({type:'update', seq:++this.seq, data:to64(Y.encodeStateAsUpdate(this.doc, from64(m.vector)))});
          this.status.textContent = 'Состояния согласованы'; this.attempt = 0;
          this.send({type:'awareness', data:to64(encodeAwarenessUpdate(this.awareness,[this.doc.clientID]))});
        } else if (m.type === 'update') Y.applyUpdate(this.doc, from64(m.data), this);
        else if (m.type === 'awareness') applyAwarenessUpdate(this.awareness, from64(m.data), this);
        else if (m.type === 'ack') this.status.textContent = `Сервер сохранил пакет ${m.seq}`;
        else if (m.type === 'revisions') this.onRevision(m.items);
        else if (m.type === 'error') this.status.textContent = m.message;
      } catch { this.status.textContent = 'Ошибка протокола'; ws.close(1002); }
    };
    ws.onclose = ({code}) => {
      if (this.closed) return;
      this.status.textContent = 'Связь прервана; документ остаётся локально';
      if (this.retry && ![1008,1002].includes(code)) {
        this.timer = setTimeout(() => this.connect(), Math.min(10000,500 * 2 ** this.attempt++) + Math.random()*250);
      }
    };
  }
  destroy() {
    this.awareness.setLocalState(null);
    this.closed = true; clearTimeout(this.timer);
    this.doc.off('update', this.onDoc); this.awareness.off('update', this.onAwareness);
    this.ws?.close(1000,'editor closed');
  }
}

Первое согласование

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

Начальный план теперь создаёт сервер. Браузер больше не вставляет шаблон при каждом соединении. Если бы Анна и Борис независимо инициализировали пустые локальные документы одной фразой, сервер получил бы две разные операции вставки похожего текста. Проверка «сейчас пусто» на клиентах тоже могла бы сработать параллельно. Единственный назначенный источник стартовых данных делает учебное состояние предсказуемее.

После самостоятельной установки зависимостей, подготовки bundle и запуска собственного локального сервера две независимые браузерные сессии должны показывать общий черновик. Правка Анны должна приходить Борису через сервер, а список присутствия должен содержать подтверждённые имена. Это ожидаемая последовательность; при авторстве зависимости и приложение не запускались. Используйте разные профили браузера, поскольку вкладки одного профиля разделяют cookie.

Канал присутствия

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

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

Вход в комнату

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

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

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

Что означает успешное соединение

Надпись «Состояния согласованы» относится к завершению обмена известными операциями. В шестом состоянии сервер хранит документ только в памяти. Остановка процесса теряет это состояние; provider пока не получает подтверждения записи на диск. Интерфейс поэтому не должен выводить «Надёжно сохранено» лишь на основании открытого WebSocket. В уроке о persistence появится отдельное подтверждение серверного сохранения.

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

При чтении серверного кода проследите один пакет от проверенной сессии до комнаты. Найдите место, где определяется отправитель, и место, где байты становятся изменением документа. Эти действия не взаимозаменяемы: проверка комнаты отвечает за разрешение доступа, а применение update отвечает за структуру текста. Такая трассировка полезнее одного сообщения «соединено», поскольку показывает весь контракт принятой операции.

Оглавление серии · Исходные состояния проекта.

API сверены с первичной документацией 11 октября 2026 года. Пример имеет статус not_run: фактический запуск и наблюдение результата читателю предстоит выполнить самостоятельно.