Перейти к содержанию
Mercury
Русский
Дизайн книга
Меню
Русский

Серверный рендеринг: своя область для каждого запроса

На сервере одна модель одновременно обслуживает много запросов. Каждый получает свою изолированную область состояния (scope), дожидается завершения её работы и передаёт значения браузеру в кадре состояния (frame) с ключами sid. Клиент применяет кадр к собственной области.

Сервер рендерит одну модель для множества одновременных запросов. Hypergraph разделяет их с помощью областей: по одной на запрос. Сервер дожидается завершения работы области и передаёт её состояние браузеру как данные.

Порядок действий

  1. Создайте область для запроса.
  2. Дождитесь завершения работы: запустите событие загрузки страницы через allSettled, который ждёт завершения всех эффектов (effect), начатых этим запуском.
  3. Сделайте снимок (snapshot): кадр значений области с ключами sid.
  4. Завершите серверную область.
  5. Передайте кадр вместе со страницей; на клиенте примените его к собственной области.

На сервере

import { allSettled, dispose, effect, event, fork, sample, snapshot, store } from "@fiberfx/hypergraph";

export const pageOpened = event<number>();
export const loadUser = effect(async (id: number) => {
  const response = await fetch(`https://api.example.com/users/${id}`);
  return (await response.json()) as { name: string };
});
export const $name = store("", { sid: "name" }).on(loadUser.doneData, (_, user) => user.name);

sample({ clock: pageOpened, target: loadUser });

export async function render(id: number): Promise<string> {
  const scope = fork();
  await allSettled(pageOpened, { scope, params: id });
  const frame = snapshot(scope);
  dispose(scope);
  return JSON.stringify(frame);
}

allSettled завершается, только когда в области не остаётся выполняющейся работы. Поэтому эффект, запущенный через sample, уже завершён, а $name содержит имя пользователя — только в этой области. Область другого запроса и работа вне всех областей не затронуты.

Запрос может также заменить обработчик эффекта только для своей области: fork({handlers: [[loadUser, handler]]}), например чтобы прочитать данные самого сервера вместо HTTP-вызова.

На клиенте

import { apply, fork, type Snapshot } from "@fiberfx/hypergraph";

export function resume(text: string) {
  const frame = JSON.parse(text) as Snapshot;
  const scope = fork();
  const misses = apply(scope, frame); // [] when the client's stores match
  return { scope, misses };
}

fork({values: frame}) делает то же самое одним вызовом и сообщает о каждом несовпадении наблюдением reset. apply также возвращает несовпадения, что полезно при разработке: каждое имеет вид {unit, reason}. Причина removed означает, что ни одно хранилище (store) клиента не использует этот sid; причина shape указывает оба описания структуры (shape), если структура клиентского хранилища отличается.

Что передаётся в кадре

Кадр имеет вид {format: "mhg-scope/1", gen, values}, где values каждому sid сопоставляет запись {shape, schema?, value}. Ключи — sid, а не положения в файле или идентификаторы времени выполнения, поэтому кадр серверной сборки применим в клиентской.

  • Передаются только хранилища с sid. Sid назначает компилятор либо автор в поле sid; в примерах он задан явно, чтобы ключи были видны. У единицы Hypergraph (unit), которой компилятор не назначил sid, есть лишь идентификатор времени выполнения: в другом процессе он ничего не означает, поэтому такая единица не включается в кадр.
  • В кадр входит только то, что область изменила или получила извне. Хранилище с начальным значением не включается, и на клиенте остаётся его собственное начальное значение.
  • Производные хранилища не включаются. Клиент вычисляет их заново из переданных хранилищ.
  • serialize: 'ignore' оставляет значение хранилища на сервере — например, секрет или кеш.
  • Значению, не представимому обычным JSON, нужен serialize: {write, read}: write преобразует его в передаваемый вид, а apply вызывает read, чтобы восстановить значение.
import { store } from "@fiberfx/hypergraph";

export const $seenAt = store(new Date(0), {
  sid: "seenAt",
  serialize: { write: (date) => date.toISOString(), read: (text: string) => new Date(text) },
});

Параллельные запросы и переносчик контекста

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

  • Без переносчика контекста — поведение по умолчанию. Эффект, вызванный в области, при завершении возвращает вызвавший его код в эту область. Это корректно, когда одновременно выполняется один поток работы, как во вкладке браузера. Но вызов извне этого потока во время ожидания — например, из таймера — может попасть в приостановленную область.
  • Переносчик контекста (carrier) передаёт область по каждой асинхронной цепочке отдельно. Поэтому продолжения каждого запроса остаются в его области, а посторонняя работа в неё не попадает.

На JavaScript-сервере установите переносчик контекста Node один раз при запуске:

import { setCarrier } from "@fiberfx/hypergraph";
import { nodeCarrier } from "@fiberfx/hypergraph/node";

setCarrier(nodeCarrier());

Тесты пакета показывают различие на проверке утечки. Эффект в области ждёт 50 мс; через 25 мс таймер извне потока вызывает событие, увеличивающее значение хранилища. Без переносчика посторонний вызов попадает в приостановленную область: глобальное значение 0, в области 1. С nodeCarrier() он остаётся снаружи: глобальное значение 1, в области 0. Второй тест запускает эффекты трёх областей параллельно, без таймеров, и проверяет, что с переносчиком каждая область сохраняет свои вызовы, а глобальные счётчики остаются равны 0.

@fiberfx/hypergraph/node — отдельная точка входа на основе серверного AsyncLocalStorage, поэтому браузерному коду не нужно импортировать серверные модули. Никогда не импортируйте её из кода, загружаемого браузером.

Обратные вызовы извне цепочки

Обратный вызов, который не был запущен графом, например обработка сообщения из сокета, не имеет собственной области. Привяжите его, пока область доступна: scopeBind(fn, {scope}) возвращает функцию, которая выполняется в этой области при каждом вызове. С переносчиком привязанная асинхронная функция сохраняет область через каждый внутренний await: тест пакета привязывает функцию, по очереди ожидающую четыре вызова эффектов, и два увеличения счётчика среди них дают 2 в области и 0 снаружи.

Что проверить

  • Один fork() на запрос; никогда не используйте общую область для нескольких запросов.
  • await allSettled(...) перед snapshot, чтобы не осталось выполняющихся эффектов.
  • dispose серверной области после получения кадра.
  • setCarrier(nodeCarrier()) один раз при запуске сервера.
  • Sid у каждого хранилища, значение которого нужно клиенту; serialize: 'ignore' у каждого хранилища, которое клиент видеть не должен.
  • При разработке проверяйте результат apply на клиенте: пустой список означает, что все значения переданы.