1gr14/игрич/
  • Меню
    • Главная
    • Start0
    • Поддержать
    • Обучение
    • Группа
    • Блог
    • Автор
  • Сообщество
    • Discord
    • Telegram
  • Опенсорс
    • Point0
    • Route0
    • Error0
    • Flat
    • Agents Party
  • Аккаунт
    • Войти
    • Регистрация
1gr14/игрич/
Создаю опенсорс во славу Господа Иисуса Христа ☦️
С любовью ко всем разработчикам на свете ❤️
Условия использованияПолитика конфиденциальностиСергей Дмитриев 2026 😎

Реалтайм в Point0: канал, спейс и хэндлеры со сквозной типизацией

12 авг. 2026 г.#point0#realtime#typescript

Бывает так: есть фулстек проект, и в нём всё хорошо. Откуда-то есть сквозные типы (tRPC, генерация из OpenAPI), есть авторизация, есть основной функционал. А потом вы решаете добавить реалтайм: уведомление о новом посте в ленте, чат между пользователями, интерактивную доску. И появляется целый новый слой абстракций, в котором надо заново изобрести всё, что в проекте уже есть, только на новый лад. И дальше поддерживать две разные системы.

В Point0 я добавил четыре новых реалтайм-поинта (структурные единицы наравне со страницами, лэйаутами, квери, мутациями): канал, спейс, клиентский хэндлер, серверный хэндлер. На них собирается практически любая реалтайм-функциональность, кода получается мало, и читается он интуитивно. Эти поинты несут те же свойства, что и все остальные:

  • код сервера и клиента живут в одном файле, компилятор вырезает клиентский код из серверной сборки, а серверный из клиентской;
  • типизация сквозная и выводится из дженериков самого фреймворка, без генерации типов.

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

Парадигма

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

Один WebSocket на клиентское приложение. Всё остальное это абстракции над ним. Слои сверху вниз.

Канал это соединение. Клиент подключается через серверный .connector, который возвращает идентичность соединения identity. Она хранится на сервере, клиенту не видна и присутствует в каждом последующем действии на канале (её читают все хэндлеры, joiner'ы и селекции). Каждое живое соединение это connectionId. Сам коннект это обычный HTTP-запрос, так что заголовки, куки и middleware работают как везде. По сокету идут только сообщения после него.

Спейс вырастает из канала. Это семейство комнат одной формы. Клиент входит через серверный .joiner, который решает, в какие комнаты он попадёт. Либо сервер сам зачисляет соединения в комнаты, без запроса клиента, через .enroller. Комната это единица адресации, pub/sub-топик, в который пуши целятся по имени.

Серверный хэндлер это вызов клиент → сервер. Клиент шлёт типизированное сообщение через sendToServer, сервер отвечает в .serverReply, а на клиенте всё это может выглядеть как обычный квери или мутация.

Клиентский хэндлер это пуш сервер → клиент. Сервер шлёт через sendToClient в цель (комнату, соединение, селекцию), подписанные компоненты получают сообщение и при желании отвечают обратно.

На клиенте всё держится хуками и компонентами (useConnection, <Connection>, useMembership, .with(channel)), сокет переподключается сам, а resumable-канал делает реконнекты дешёвыми. Доставка пуша по умолчанию не гарантирована, правда живёт в квери.

На одном процессе всё работает в локальной памяти. Для нескольких процессов к движку подключается backplane: Redis по URL, Postgres, ваш уже поднятый Redis-клиент или любой KV с pub/sub. Сервер также умеет админ-команды (kick, kill, refresh) и перечисления «кто сейчас подключён», а ещё отдаёт метрики.

Подготовка

Дальше во всех примерах считаем, что у вас уже есть авторизация и Prisma. Откуда берётся prisma, как устроен getUserFromRequest, что за AppError, для примеров неважно: это ваш обычный код, который был в проекте до сокетов.

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

// engine.ts
export const engine = Engine.create({
  server: { socket: true },
})

Канал объявляется один раз на приложение.

// lib/channel.ts
import { root } from '@/lib/root'
import { getUserFromRequest } from '@/lib/auth' // ваша обычная авторизация, та же, что и в HTTP

export const appChannel = root.lets
  .channel()
  .connector(async ({ request }) => {
    // коннект это обычный HTTP-запрос: куки, заголовки, middleware на месте
    const user = await getUserFromRequest(request)
    // то, что вернул коннектор, и есть identity соединения. Она останется на сервере,
    // клиенту не видна, и будет доступна во всех дальнейших действиях на этом соединении.
    // Форма произвольная: кладите что угодно сериализуемое, хоть роль, хоть тариф,
    // хоть язык интерфейса. Тип нигде не объявляется, он выводится отсюда
    return user
      ? { authorized: true as const, id: user.id, name: user.name }
      : { authorized: false as const, id: null, name: null }
  })
  .channel()

as const на флаге тут не украшение: без него TypeScript расширит true до boolean, объединение перестанет быть размеченным, и identity.id после проверки не сузится до строки. Больше ничего писать не нужно, тип identity разъедется по всем коннекторам, joiner'ам и хэндлерам сам.

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

Соединение держит корень приложения. Одно на вкладку, его переиспользуют все спейсы на всех страницах.

// app.client.tsx
<appChannel.Connection>
  <RouterRoutes />
</appChannel.Connection>

Всё. Дальше только фичи.

Пример 1. Уведомление всем о новом посте в ленте

Допустим, у вас сайт с лентой постов. Люди пишут посты, и вы хотите, чтобы остальные онлайн видели уведомление о новом посте в момент его появления.

Комнаты тут не нужны: адресат это «все, кто подключён». Значит, хватит одного клиентского хэндлера, растущего прямо из канала.

// pages/feed.tsx

// сервер → клиент. Схема описывает payload, клиент прочитает его типизированным
export const postAddedHandler = appChannel.lets
  .clientHandler()
  .serverSend(
    z.object({
      id: z.string(),
      title: z.string(),
      authorId: z.string(),
      authorName: z.string(),
    }),
  )
  .clientHandler()

// ваша обычная HTTP-мутация создания поста. Из-за реалтайма в ней добавилась одна строка
export const postCreateMutation = root.lets
  .mutation()
  .input(z.object({ title: z.string().min(1) }))
  .loader(async ({ input, request }) => {
    const user = await getUserFromRequest(request)
    if (!user) throw new AppError('Только для авторизованных', { status: 401 })

    const post = await prisma.post.create({
      data: { title: input.title, authorId: user.id },
    })

    // без указания цели пуш уходит всем соединениям канала, гостям в том числе.
    // Не ждём его: доставка это сигнал, а не часть транзакции
    void postAddedHandler.sendToClient({
      id: post.id,
      title: post.title,
      authorId: user.id,
      authorName: user.name,
    })

    return post
  })
  .mutation()

Форма создания поста это обычная форма с обычной мутацией, сокеты её никак не касаются:

const PostForm = () => {
  const create = postCreateMutation.useMutation()
  const [title, setTitle] = useState('')

  return (
    <form
      onSubmit={(e) => {
        e.preventDefault()
        void create
          .mutateAsync({ title })
          .then(() => setTitle(''))
          .catch((error) => alert(error.message))
      }}
    >
      <input value={title} onChange={(e) => setTitle(e.target.value)} />
      <button disabled={create.isPending}>Опубликовать</button>
    </form>
  )
}

На клиенте подписка это один хук. Ни join, ни комнат, ни ручного подключения: соединение уже держит корень приложения.

const NewPostsBanner = () => {
  const { user } = useUser() // ваш обычный хук авторизации на клиенте
  const [fresh, setFresh] = useState<{ id: string; title: string }[]>([])

  // message типизирован схемой из .serverSend, без генерации типов
  postAddedHandler.useOnMessageFromServer(({ message }) => {
    if (message.authorId === user?.id) return // свой же пост, баннер не нужен
    setFresh((prev) => [message, ...prev])
  })

  if (fresh.length === 0) return null

  return (
    <button
      onClick={() => {
        void feedQuery.invalidateQuery() // правда живёт в квери, пуш только сказал, что она устарела
        setFresh([])
      }}
    >
      Новых постов: {fresh.length}. Показать
    </button>
  )
}

Здесь видно главное правило доставки: пуш это сигнал «что-то изменилось, посмотри ещё раз», а не единственная копия данных. Копия лежит в базе и читается обычным квери. Поэтому потерянный пуш (клиент был в реконнекте, сервер передеплоился) ничего не стоит: следующий рефетч всё равно принесёт правду.

Отдельно про строчку if (message.authorId === user?.id) return. Сервер тоже умеет исключать адресата, у пуша есть цель:

// так делать не надо
void postAddedHandler.sendToClient(payload, {
  $identity: { id: { $ne: user.id } },
})

Ключ с $ означает выборку в стиле Mongo (её выполняет sift), ключ без $ означает точный адрес. И вот в этом вся разница: точный адрес это попадание в pub/sub-топик, одна публикация на любое число получателей, а выборка это перебор всех соединений на каждом процессе. Ради того, чтобы один человек не увидел собственный пост, мы превращаем дешёвую рассылку в скан по всей базе соединений.

Поэтому правильный вариант такой, как в компоненте выше: кладём authorId в сообщение и сравниваем его на клиенте с текущим пользователем. Своих данных у клиента и так достаточно, useUser уже есть в проекте. Кстати, ровно так устроено и штатное подавление эха: except по connectionId не убирает фрейм из рассылки, он доезжает до клиента, и клиент его отбрасывает.

Выборки по $identity остаются для того, для чего они и сделаны: редкие админские рассылки, где перебор не жалко.

Пример 2. Интерактивная доска с множеством участников

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

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

// pages/board.tsx

const dotSchema = z.object({ x: z.number(), y: z.number(), userId: z.string() })
type Dot = z.infer<typeof dotSchema>

// без дженерика форма комнаты это пустой объект. Это частный случай: на весь проект
// в таком спейсе существует ровно одна комната. В следующих примерах на её месте будет
// нормальная структура данных, и комнат станет много
export const boardSpace = appChannel.lets
  .space()
  // .joiner это то, что вообще делает спейс доступным для входа с клиента.
  // Пускаем всех, включая гостей: смотреть доску можно всем
  .joiner(() => ({}))
  .space()

// клиент → сервер: я кликнул в точку (x, y)
export const dotPutHandler = boardSpace.lets
  .serverHandler()
  .clientSend(z.object({ x: z.number(), y: z.number() }))
  .serverReply(async ({ input, identity }) => {
    // гейт живёт здесь, на сервере, где identity можно доверять.
    // Неактивный курсор на клиенте это вежливость, а не правило
    if (!identity.authorized) {
      throw new AppError('Войдите, чтобы рисовать', { status: 401 })
    }
    const dot = { x: input.x, y: input.y, userId: identity.id }
    void dotAddedHandler.sendToClient(dot) // голый send = всем в спейсе
    return dot
  })
  .serverHandler()

// сервер → клиент: на доске появилась точка
export const dotAddedHandler = boardSpace.lets
  .clientHandler()
  .serverSend(dotSchema)
  .clientHandler()

Компонент входит в комнату, пока он на экране, и слушает пуши.

const Board = () => {
  const membership = boardSpace.useMembership()
  const [dots, setDots] = useState<Dot[]>([])

  dotAddedHandler(membership).useOnMessageFromServer(({ message }) => {
    setDots((prev) => [...prev, message])
  })

  return (
    <div
      className="board"
      onClick={(e) => {
        const box = e.currentTarget.getBoundingClientRect()
        void dotPutHandler(membership)
          .sendToServer({
            x: (e.clientX - box.left) / box.width,
            y: (e.clientY - box.top) / box.height,
          })
          .catch((error) => alert(error.message)) // ошибка сервера прилетает сюда типизированной
      }}
    >
      {dots.map((dot, i) => (
        <span
          key={i}
          style={{ left: `${dot.x * 100}%`, top: `${dot.y * 100}%` }}
        />
      ))}
    </div>
  )
}

Обратите внимание: базы здесь нет вообще, и это не упрощение ради статьи. Точки живут в стейте компонента, потому что по условию доска эфемерная. Если бы точки нужно было хранить, добавился бы prisma.dot.create в .serverReply и обычный квери на историю, ровно как в следующем примере.

membership в dotPutHandler(membership) это адресация: хэндлер спейса всегда отправляет в комнату. У мембершипа тут одна комната, поэтому его можно передать вместо неё.

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

Пример 3.1. Общий чат между пользователями

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

Каждый чат это комната. Форма комнаты объявляется в опенере спейса, примерно как пропсы у компонента, и дальше всё вниз по цепочке про неё знает.

// pages/chat.tsx

const messageSchema = z.object({
  id: z.number(),
  chatId: z.string(),
  authorId: z.string(),
  text: z.string(),
  createdAt: z.date(),
})

export const chatSpace = appChannel.lets
  // дженерик это форма комнаты. Комната может быть любым объектом, какой удобен
  // вашему домену, { chatId } тут просто частный случай
  .space<{ chatId: string }>()
  .input(z.object({ chatId: z.string() })) // это то, что клиент передаёт в join, а не комната
  .joiner(async ({ input, identity }) => {
    // вход в комнату это и есть гейт на чтение чата
    if (!identity.authorized) {
      throw new AppError('Только для авторизованных', { status: 401 })
    }
    // возврат проверяется на соответствие форме комнаты из дженерика.
    // Лишний ключ здесь это ошибка типов, а не мелочь: сериализация комнаты
    // и есть её адрес, с лишним ключом получилась бы другая комната
    return { chatId: input.chatId }
  })
  .space()

// клиент → сервер: сохранить сообщение и разослать его комнате
export const messageSendHandler = chatSpace.lets
  .serverHandler()
  .clientSend(z.object({ text: z.string().min(1).max(1000) }))
  .serverReply(async ({ input, identity, room }) => {
    // гостя сюда не пустил joiner, но TypeScript про это не знает,
    // а лишняя проверка на сервере не грех
    if (!identity.authorized) {
      throw new AppError('Только для авторизованных', { status: 401 })
    }
    const message = await prisma.message.create({
      data: { text: input.text, chatId: room.chatId, authorId: identity.id },
    })
    // точный адрес комнаты, это pub/sub-топик, а не перебор соединений
    void messageAddedHandler.sendToClient(message, { room })
    return message // это получит отправитель в ответ на свой sendToServer
  })
  .serverHandler()

// сервер → клиент: в комнате новое сообщение
export const messageAddedHandler = chatSpace.lets
  .clientHandler()
  .serverSend(messageSchema)
  .clientHandler()

// история это обычный HTTP-квери и источник правды. У него своя проверка прав:
// сокет и HTTP это две разные двери, закрывать надо обе
export const messagesQuery = root.lets
  .query()
  .input(z.object({ chatId: z.string() }))
  .loader(async ({ input, request }) => {
    const user = await getUserFromRequest(request)
    if (!user) throw new AppError('Только для авторизованных', { status: 401 })
    return {
      messages: await prisma.message.findMany({
        where: { chatId: input.chatId },
        orderBy: { id: 'asc' },
        take: 100,
      }),
    }
  })
  .query()

Компонент: вход в комнату, история из квери, пуш пишет новое сообщение прямо в кеш этого же квери.

const Chat = ({ chatId }: { chatId: string }) => {
  const membership = chatSpace.useMembership({ chatId })
  const { data } = messagesQuery.useQuery({ chatId })
  const [text, setText] = useState('')

  messageAddedHandler(membership).useOnMessageFromServer(({ message }) => {
    // пуш принёс готовые данные, значит запрос за ними не нужен
    messagesQuery.setQueryData({ chatId }, (old) => ({
      messages: [...(old?.messages ?? []), message],
    }))
  })

  return (
    <>
      <ul>
        {data?.messages.map((message) => (
          <li key={message.id}>{message.text}</li>
        ))}
      </ul>
      <form
        onSubmit={(e) => {
          e.preventDefault()
          setText('')
          void messageSendHandler(membership)
            .sendToServer({ text })
            .catch((error) => alert(error.message))
        }}
      >
        <input value={text} onChange={(e) => setText(e.target.value)} />
        <button disabled={membership.status !== 'joined'}>Отправить</button>
      </form>
    </>
  )
}

Если гостя в комнату не пустили, membership.status станет error, а membership.error будет типизированной ошибкой из joiner'а. Отдельного состояния «я не в чате» изобретать не нужно, оно уже есть.

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

const membership = chatSpace.useMembership(
  { chatId },
  // вход в комнату случается заново после каждого реконнекта,
  // так что здесь же и догоняем пропущенное
  { onEnter: () => void messagesQuery.invalidateQuery({ chatId }) },
)

Работает, но перечитывает всю историю после каждого моргания сети. Дальше покажу, как этого не делать.

Пример 3.2. Тот же чат, но без лишних перечитываний

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

Для этого у канала есть resumable. Клиент при переподключении предъявляет свой ключ соединения, а сервер восстанавливает соединение из своей записи: ту же identity, те же комнаты, тот же connectionId. Коннектор и joiner'ы при этом не запускаются, то есть после редеплоя сервер не получает лавину полных переподключений.

// lib/channel.ts
export const appChannel = root.lets
  .channel()
  .connector(/* тот же коннектор, ничего не меняется */)
  .channel({ resumable: true })

Одного resumable на канале мало: он восстанавливает соединение, но не пуши, улетевшие в разрыв. Буфер включается на том хэндлере, чьи сообщения жалко терять.

export const messageAddedHandler = chatSpace.lets
  .clientHandler()
  .serverSend(messageSchema)
  // сервер хранит последние 128 фреймов этого хэндлера на комнату
  // и доигрывает их при возврате клиента, в исходном порядке
  .clientHandler({ resumable: true })

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

Но буфер конечный, и это принципиально: он лежит в памяти процесса. Отсутствие сети на полчаса, редеплой, переполнение буфера, и дыра всё-таки будет. Поэтому Point0 не заставляет вас гадать, а сам говорит, покрыт разрыв или нет.

const membership = chatSpace.useMembership(
  { chatId },
  {
    onEnter: ({ gapless }) => {
      // gapless это доказательство сервера, что ничего не потеряно:
      // либо это первый вход, либо буфер покрыл весь разрыв целиком.
      // Есть доказательство, ничего не делаем, пропущенное уже доиграно.
      // Нет, честно перечитываем историю
      if (!gapless) void messagesQuery.invalidateQuery({ chatId })
    },
  },
)

Одно условие покрывает все случаи: первый вход, короткое моргание, долгий офлайн, редеплой, возврат после кика. Вести учёт того, что вы могли пропустить, не нужно, за вас его ведёт сервер.

Правда о сообщениях при этом всё равно живёт в базе, а не в буфере. Буфер это оптимизация, которая убирает лишние запросы в частом случае, а не хранилище.

Пример 4. Личные сообщения между пользователями

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

Комната диалога это пара пользователей. Комната это любой объект, какой вам удобен, но её сериализация и есть её адрес, поэтому список участников надо сортировать: иначе [a, b] и [b, a] дадут две разные комнаты.

// pages/dm.tsx

// dm это direct message, личное сообщение одного пользователя другому
const dmSchema = z.object({
  id: z.number(),
  fromId: z.string(),
  toId: z.string(),
  text: z.string(),
  createdAt: z.date(),
})

export const dmSpace = appChannel.lets
  .space<{ members: string[] }>()
  .input(z.object({ withUserId: z.string() }))
  .joiner(({ input, identity }) => {
    if (!identity.authorized) {
      throw new AppError('Только для авторизованных', { status: 401 })
    }
    // sort обязателен: комната адресуется своей сериализацией
    return { members: [identity.id, input.withUserId].sort() }
  })
  .space()

export const dmAddedHandler = dmSpace.lets
  .clientHandler()
  .serverSend(dmSchema)
  .clientHandler()

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

// личная комната на каждого пользователя
export const userSpace = appChannel.lets
  .space<{ userId: string }>()
  // .enroller работает на сервере при подключении к каналу, до того,
  // как клиент что-либо отрендерил
  .enroller(({ identity }) =>
    // у гостя нет личной комнаты, и это нормально: вернули ничего, никуда не зачислили
    identity.authorized ? { userId: identity.id } : undefined,
  )
  .space() // .joiner отсутствует, значит с клиента в этот спейс войти нельзя вообще

export const dmBadgeHandler = userSpace.lets
  .clientHandler()
  .serverSend(z.object({ fromUserId: z.string(), preview: z.string() }))
  .clientHandler()

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

export const dmSendHandler = dmSpace.lets
  .serverHandler()
  .clientSend(z.object({ text: z.string().min(1) }))
  .serverReply(async ({ input, identity, room }) => {
    if (!identity.authorized) {
      throw new AppError('Только для авторизованных', { status: 401 })
    }
    const toUserId =
      room.members.find((id) => id !== identity.id) ?? identity.id

    const dm = await prisma.dm.create({
      data: { text: input.text, fromId: identity.id, toId: toUserId },
    })

    void dmAddedHandler.sendToClient(dm, { room }) // в комнату диалога
    void dmBadgeHandler.sendToClient(
      { fromUserId: identity.id, preview: dm.text.slice(0, 80) },
      { room: { userId: toUserId } }, // в личную комнату получателя
    )

    return dm
  })
  .serverHandler()

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

const DmBadge = () => {
  const [unread, setUnread] = useState(0)

  dmBadgeHandler.useOnMessageFromServer(({ message }) => {
    setUnread((n) => n + 1)
    toast(`${message.fromUserId}: ${message.preview}`)
  })

  return unread > 0 ? <span className="badge">{unread}</span> : null
}

Сам диалог устроен как чат из примера 3.1, только вход по собеседнику:

const Dm = ({ withUserId }: { withUserId: string }) => {
  const membership = dmSpace.useMembership({ withUserId })
  const { data } = dmHistoryQuery.useQuery({ withUserId })
  const [text, setText] = useState('')

  dmAddedHandler(membership).useOnMessageFromServer(({ message }) => {
    dmHistoryQuery.setQueryData({ withUserId }, (old) => ({
      items: [...(old?.items ?? []), message],
    }))
  })

  return (
    <>
      <ul>
        {data?.items.map((dm) => (
          <li key={dm.id}>{dm.text}</li>
        ))}
      </ul>
      <form
        onSubmit={(e) => {
          e.preventDefault()
          setText('')
          void dmSendHandler(membership)
            .sendToServer({ text })
            .catch((error) => alert(error.message))
        }}
      >
        <input value={text} onChange={(e) => setText(e.target.value)} />
        <button disabled={membership.status !== 'joined'}>Отправить</button>
      </form>
    </>
  )
}

Разница между .joiner и .enroller тут не косметическая. В комнату диалога клиент вошёл сам и может из неё выйти, закрыв вкладку с перепиской. А зачисление через .enroller это гарантия: leave() на личной комнате ничего не сделает, и подделанный фрейм тоже, убрать оттуда соединение может только сервер. Поэтому на пуш в личную комнату можно рассчитывать: пока соединение открыто, оно точно в своей комнате сидит.

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

Пример 5. Сайт, в котором все запросы идут через WebSocket

Ничто не мешает совсем отказаться от HTTP-запросов к API и гонять всё через уже открытый сокет. Если не жалко держать соединение на каждого активного пользователя, можно заметно ускорить работу интерфейса: TLS-хендшейка нет, заголовков нет, соединение уже прогрето.

Для этого серверный хэндлер умеет объявить, чем он будет для клиента: обычным квери, бесконечным квери или мутацией. Одна строчка в цепочке, и вместо sendToServer у хэндлера появляется привычный набор хуков.

// pages/todos.tsx

// клиент → сервер, но выглядит как квери: тот же react-query, только транспорт сокет
export const todosHandler = appChannel.lets
  .serverHandler()
  .clientSend(z.object({ onlyOpen: z.boolean() }))
  .serverReply(async ({ input, identity }) => {
    if (!identity.authorized) {
      throw new AppError('Только для авторизованных', { status: 401 })
    }
    return {
      todos: await prisma.todo.findMany({
        where: {
          userId: identity.id,
          done: input.onlyOpen ? false : undefined,
        },
      }),
    }
  })
  .query() // вот и всё объявление
  .serverHandler()

// а это мутация. Мутация это дефолт, .mutation() можно и не писать
export const todoAddHandler = appChannel.lets
  .serverHandler()
  .clientSend(z.object({ title: z.string().min(1) }))
  .serverReply(async ({ input, identity }) => {
    if (!identity.authorized) {
      throw new AppError('Только для авторизованных', { status: 401 })
    }
    const todo = await prisma.todo.create({
      data: { title: input.title, userId: identity.id },
    })
    // всем вкладкам этого пользователя, через его личную комнату из примера 4.
    // Точный адрес, никакого перебора соединений
    void todosChangedHandler.sendToClient(undefined, {
      room: { userId: identity.id },
    })
    return todo
  })
  .mutation()
  .serverHandler()

// голый триггер без payload: «данные изменились, перечитай».
// Растёт из userSpace, то есть из личной комнаты пользователя (пример 4)
export const todosChangedHandler = userSpace.lets
  .clientHandler()
  .clientHandler()

На клиенте это обычный react-query, к которому вы привыкли.

const Todos = () => {
  const { data, isPending } = todosHandler.useSocketQuery({ onlyOpen: true })
  const add = todoAddHandler.useSocketMutation()

  todosChangedHandler.useOnMessageFromServer(() => {
    void todosHandler.invalidateSocketQuery(true) // перезапрос уедет по сокету
  })

  if (isPending) return <Spinner />

  return (
    <>
      {(data?.todos ?? []).map((todo) => (
        <div key={todo.id}>{todo.title}</div>
      ))}
      <button onClick={() => void add.mutateAsync({ title: 'Новая задача' })}>
        Добавить
      </button>
    </>
  )
}

Кеш, инвалидация, статусы, девтулзы react-query работают как обычно. Разница только в том, что запрос уходит фреймом в открытый сокет, а не отдельным HTTP-запросом.

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

Что осталось за кадром

Чтобы статья не превратилась в документацию, я оставил за скобками довольно много.

Presence, то есть «кто сейчас в комнате», собирается из перечислений (space.memberships.server.list) и серверных событий входа и выхода. Готовый рецепт есть в документации и в примере приложения.

Админ-команды: kill закрывает соединения, space.kick выкидывает из комнат, space.enroll наоборот зачисляет, refresh перепроверяет identity без разрыва сокета, amendIdentity правит её на месте. Всё это принимает тот же словарь целей, что и пуши.

Несколько процессов: backplane подключается в конфиг движка. Для Redis это одна строка с URL. Есть готовые адаптеры для Postgres (шина на LISTEN/NOTIFY, если Redis в проекте нет), ioredis и node-redis. Либо свой объект из пяти функций, если у вас что-то своё.

У resumable из примера 3.2 есть настройки, которых я не касался: сколько живёт запись соединения, сколько фреймов и байтов держать в буфере, можно ли конкретному спейсу отказаться от восстановления, и что делать с частично покрытым разрывом (например, для потока патчей документа хвост без начала бесполезен, и его лучше не отдавать вовсе).

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

Итог

Реалтайм в Point0 это не отдельная подсистема со своей авторизацией, своими типами и своим роутингом. Это четыре новых вида поинтов, которые живут рядом со страницами и квери, читают ту же identity, что и весь остальной сервер, и типизируются так же, как всё остальное. Файл фичи читается сверху вниз: комната, хэндлеры, компонент.

Сокеты это самая молодая часть фреймворка, и я это честно пишу и в документации: дизайн API устоялся и менять я его не собираюсь, а внутренности ещё ждёт рефакторинг. Если что-то сломается, заводите ишью.

Рабочее приложение со всем этим: examples/socket, где чат, доска, уведомления и presence лежат каждый в своём файле. А если вы пришли сюда, не зная про сам фреймворк, начните с обзора Point0.

  • Документация по сокетам
  • Пример приложения целиком
  • Вся документация Point0
  • GitHub репозиторий
Все статьи

Дополнительно

Сообщество

Вопросы и общение — Discord на английском, Telegram на русском
DiscordTelegram

Соцсети

Видео и посты на других площадках
YouTubeTwitter

Закрытая группа

Платное сообщество с наставничеством, где каждый создаёт свой IT-продукт
Про группу

Start0

SaaS-бойлерплейт на Point0 — самый быстрый способ создать свой продукт
Посмотреть Start0

Комментарии

, чтобы оставить комментарий
Пока нет комментариев. Будьте первым.