24 июня 2026 г.

Как уместить всё состояние приложения в ссылку без бэкенда.

Диалог «Поделиться» со ссылкой, которая восстанавливает всё выравнивание

У Word-by-word Aligner есть кнопка «Поделиться» и нет базы данных за ней. Ссылка, которую вы копируете, несёт всю схему: текст каждой строки, связи между словами, цвета, шрифты и визуальные настройки. Откройте её на другой машине, и вернётся ровно то же выравнивание. Дальше о том, как это состояние умещается в URL и как такая ссылка читается обратно, без доверия к ней.

Почему не сырой JSON

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

Урезаем: короткие ключи и никаких дефолтов

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

function settingsToCompact(rounded: VisualSettingsV2): CompactSettings3 | undefined {
  const def = roundVisualSettings(defaultVisualSettingsV2());
  const o: CompactSettings3 = {};
  if (rounded.lineThickness !== def.lineThickness) o.lt = rounded.lineThickness;
  if (rounded.lineOpacity !== def.lineOpacity) o.lo = rounded.lineOpacity;
  if (rounded.lineStyle !== def.lineStyle) o.ls = rounded.lineStyle === 'straight' ? 0 : 1;
  if (rounded.palette !== def.palette) o.pl = rounded.palette;
  // ... булевы кодируются как 0 / 1, остальное выбрасывается, если оно дефолтное
  return Object.keys(o).length > 0 ? sortKeys(o) : undefined;
}

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

Строки и связи используют плоский текстовый формат, а не вложенный JSON: табы между полями, вертикальные черты между строками:

function encodeLines(lines: LineV2[]): string {
  return lines
    .map((l) => {
      const cols = [
        l.id, encodeURIComponent(l.rawText), l.font.family,
        l.font.source === 'custom' ? 1 : 0,
        l.font.customName ? encodeURIComponent(l.font.customName) : '',
        String(l.textSizePx), String(l.gapWordPx)
      ];
      if (l.rtl) cols.push('1');
      return cols.join('\t');
    })
    .join('|');
}

Сжимаем и кодируем

Урезанный объект превращается в строку JSON, сжимается алгоритмом deflate и кодируется в URL-безопасный base64 без выравнивающих символов. Сжатие берёт на себя fflate, а base64url заменяет символы, которые URL иначе экранировал бы:

import { deflateSync, inflateSync, strFromU8, strToU8 } from 'fflate';

export function deflateBase64url(s: string): string {
  const bytes = deflateSync(strToU8(s), { level: 9 });
  return toBase64url(bytes);
}

Весь путь кодирования укладывается в два вложенных вызова:

export function encodeState(state: AppStateV2): string {
  return deflateBase64url(toCompactJSON(state));
}

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

Читаем обратно, не доверяя

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

const MAX_DECOMPRESSED_BYTES = 2 * 1024 * 1024; // 2 MB

export function inflateBase64url(s: string): string | null {
  try {
    const bytes = fromBase64url(s);
    const decompressed = strFromU8(inflateSync(bytes));
    if (decompressed.length > MAX_DECOMPRESSED_BYTES) return null;
    return decompressed;
  } catch {
    return null;
  }
}

Формат также несёт номер версии, и это сохраняет работоспособность старых ссылок после изменения схемы. Декодер сначала пробует текущий формат, затем предыдущий, затем откатывается к пустому документу:

export function decodeState(data: string | null | undefined): AppStateV2 {
  if (!data || typeof data !== 'string') return migrate({});
  const v3 = tryDecodeV3(data);
  if (v3) return v3;
  const v2 = tryDecodeV2(data);
  if (v2) return v2;
  return migrate({});
}

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

Где это уместно

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

Word-by-word Aligner бесплатен и работает в браузере на aligner.tinygods.dev. Код открыт (MIT): github.com/tinygodsdev/bitext-word-alignment; сериализация лежит в src/lib/serialization/.