Если держать открытой вкладку vogclub.com, рано или поздно внизу появится скромная плашка: «Доступна новая версия сайта» и кнопка «Обновить». Это история о том, откуда она взялась, какую проблему решает и почему устроена именно так: сервер почти не тратит на неё сил, а вкладка всё равно знает, когда можно обновиться.
Это пятая статья серии «VOG изнутри». Предыдущая — про установщик на Go, где обновлялся Windows-клиент. Здесь обновляется браузер, и задача оказалась похожей больше, чем кажется.
В статье
- Вкладка, которая не перезагружается
- Цена: вкладка живёт на старом коде
- Номер сборки в каждом ответе
- Когда вкладка сверяется
- Плашка, а не перезагрузка
- Кэш браузера
- Одна строка ?v= и две копии React
- Что нашлось при подготовке статьи
- Итог
Вкладка, которая не перезагружается
Сайт VOG отрисовывается на сервере: Go-шаблоны, HTMX для частичных обновлений и React-«острова» там, где нужен живой интерфейс, — доска, стол, комната. Раньше каждый переход по ссылке был обычной загрузкой страницы.
Для игрового сайта это плохо. Вкладка держит постоянное соединение с лобби по WebSocket, и через него игрок присутствует в комнатах и за столами. Обычный переход рвал соединение, и игрок выходил из всех комнат разом: заглянул в рейтинги — и пропал из списка игроков.
В сентябре 2026 года сайт получил оболочку вкладки. Комнаты, столы и страницы открываются без перезагрузки: оболочка запрашивает у сервера только часть страницы (запрос с заголовком X-Shell) и показывает её в своём контейнере. Комнаты и столы живут в скрытых вкладках со всеми своими островами и соединением, а переход между ними — это переключение, а не загрузка. На проде оболочка включена с 14 сентября.
Цена: вкладка живёт на старом коде
Если страница не перезагружается, она не получает и новый код. А выкладки идут часто: в сентябре в основную ветку сайта ушло 174 коммита за 22 разных дня, в самую горячую неделю — 76. Вкладка, открытая утром, к вечеру могла отстать на несколько сборок.
Как это выглядит, показал первый же случай, ещё на тестовом стенде. 15 сентября вышла правка «рейтинг после партии обновляется без перезагрузки», а во вкладке, открытой до выкладки, рейтинг по-прежнему не обновлялся: правка до неё просто не доехала. Для игрока это выглядело бы как ошибка, которую мы уже исправили.
Есть и опасность хуже. Оболочка вставляет в страницу часть, отрисованную сервером. Если сервер уже новый, а страница старая, новая часть ждёт новый код островов, которого в старой странице нет. В лучшем случае что-то не заработает, в худшем — остров упадёт.
Механизм версии появился в тот же вечер, 15 сентября, а на прод ушёл на следующий день.
Номер сборки в каждом ответе
Каждая сборка сайта получает номер: последний тег версии и первые восемь символов коммита, например 3.1.0-dbcc6b1c. Номер вшивается в бинарник при сборке, и сервер сообщает его тремя способами:
- в разметке страницы —
<meta name="vog-app-version">: с этой сборкой страница открыта; - в заголовке каждого ответа —
X-App-Version; - отдельной ручкой
GET /api/web/versionсCache-Control: no-store.
Главная идея — во втором пункте. Заголовок ставит промежуточный обработчик для всех ответов, и вкладка узнаёт о новой сборке из трафика, который и так идёт: из ответов на переходы оболочки. Отдельный запрос нужен, только если вкладка долго ничего не спрашивает.
// AppVersion ставит заголовки версии и умолчание кэша.
//
// Умолчание — no-cache: браузер хранит копию, но перед использованием
// сверяется. …
// Раздача файлов (/dist, /static) ставит свой кэш сама; обработчик, задавший
// Cache-Control, умолчание перекрывает.
func AppVersion(version string, reloadSeq int) func(http.Handler) http.Handler {
seq := strconv.Itoa(reloadSeq)
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h := w.Header()
h.Set(HeaderAppVersion, version)
h.Set(HeaderAppReloadSeq, seq)
if !strings.HasPrefix(r.URL.Path, "/dist/") && !strings.HasPrefix(r.URL.Path, "/static/") {
h.Set("Cache-Control", "no-cache")
}
next.ServeHTTP(w, r)
})
}
}Сравнивает вкладка, а не сервер, и сравнивает на равенство, а не «новее или старше». Номера сборок не обязаны быть упорядоченными: важно только, что сборка другая.
Когда вкладка сверяется
Самое дешёвое — сверка при переходе оболочки. Ответ на запрос части страницы и так приходит, вкладка читает заголовок до того, как вставить часть, и если сборка уже другая, часть не вставляет вовсе, а загружает адрес целиком:
if (stale()) return
// Часть страницы от новой сборки не вставляется в страницу прежней: её
// острова ждут новый код. Уходим полной загрузкой того же адреса.
noteServerVersion(res.headers?.get?.('X-App-Version') ?? null, res.headers?.get?.('X-App-Reload-Seq') ?? null)
if (updateNeedsFullLoad()) {
if (!entry.background) this.go(u.href, opts.replace)
return
}Но игрок может часами сидеть за одним столом и никуда не переходить. Для этого случая три события поскромнее:
- возврат на вкладку — игрок переключился из другой вкладки или развернул окно;
- возврат сети — событие
online; - таймер — запрос к
/api/web/version.
Шаг таймера задаёт стенд, а не сборка фронтенда, и задаёт его разным. На тестовом стенде выкладки идут пачками, и узнать о новой сборке хочется в течение минуты — там шаг 60 секунд. На проде выкладки реже, а вкладок больше, и частый опрос только грел бы сервер — там шаг 30 минут. Нижняя граница — полминуты: опечатка в настройке не должна устроить шторм запросов.
Во что это обходится? Заголовок в ответе ничего не стоит, а ручка отдаёт две строки JSON. По таймеру открытая вкладка на проде спрашивает о версии не чаще двух раз в час, а фоновую или замороженную вкладку браузер может опрашивать и реже. Даже 10 000 одновременно открытых вкладок дали бы в среднем около 5,6 запроса в секунду, не считая сверок при возврате на вкладку и сети. При этом вкладка, с которой что-то делают, узнаёт о выкладке при первом же переходе.
Версию можно было бы присылать и через WebSocket-соединение с лобби, которое у вкладки и так открыто. Но тогда лобби пришлось бы знать о выкладках сайта, а это другая служба со своими выкладками. Обычных HTTP-ответов вкладке хватает.
Плашка, а не перезагрузка
Узнав о новой сборке, вкладка не перезагружается сама. Она показывает плашку с кнопкой «Обновить» и запоминает, что следующий переход нужно сделать полной загрузкой страницы.
Причина простая: перед игроком может быть партия. Перезагрузка посреди хода — худшее, что можно сделать с игроком ради исправления, которое ему, может быть, и не нужно прямо сейчас. Плашку нельзя закрыть, но она никуда не торопит: игрок обновится, когда доиграет, или новая сборка приедет сама при следующем переходе.
Граница здесь такая: в обычном режиме обнаружение другой сборки само по себе страницу не перезагружает. Открытая партия защищена от автоматической перезагрузки и в принудительном режиме, о котором ниже. Но следующий переход, который сделает сам игрок, после обнаружения другой сборки идёт полной загрузкой. Это пересоздаёт всю оболочку и соединение с лобби, и если в фоновой вкладке оболочки в это время шла партия, она закроется вместе с остальными вкладками. Решение сознательное: вставить часть новой сборки в старую страницу опаснее. Сама партия при этом живёт на сервере, и её можно открыть снова.
Один тонкий момент связан с выкладкой. На проде две копии сайта, и при раскатке какое-то время отвечают обе: новая и старая. Вкладка может получить ответ с новым номером, а следующий — снова со старым. Поэтому однажды замеченное обновление назад не откатывается. Оборотная сторона сравнения на неравенство: вкладка уже новой сборки, получив ответ старой копии, тоже увидит «другую» сборку и покажет плашку, а перезагрузка может попасть на старую копию. Это лишнее уведомление и лишняя загрузка в окне раскатки. Когда раскатка закончилась и отвечает одна версия, всё сходится само:
export function noteServerVersion(version: string | null, reloadSeq: string | null): UpdateState {
const mine = pageVersion()
if (!version || !mine || mine === 'dev' || version === 'dev') return state
const seq = parseInt(reloadSeq ?? '', 10)
const forced = Number.isFinite(seq) && seq > pageReloadSeq()
const next: UpdateState = forced ? 'forced' : version !== mine ? 'soft' : 'none'
// Однажды замеченное обновление назад не откатывается: ответ старой копии
// службы во время раскатки не отменяет новой.
if (next === 'none' || (state === 'forced' && next === 'soft')) return state
if (next === state) return state
state = next
if (state === 'forced' && !onGamePage() && document.visibilityState === 'visible') {
reloadPage()
return state
}
showBanner()
return state
}В коде видно и второе состояние — forced. Это запасной рубильник на случай, когда новая сборка несовместима со старой страницей настолько, что ждать нельзя: например, поменялся формат общения с игровыми службами. Номер X-App-Reload-Seq поднимается вручную настройкой стенда. Если он вырос, видимая вкладка перезагружается сразу, фоновая — когда на неё вернутся, а если открыта партия, видимая или в любой фоновой вкладке оболочки (это и проверяет onGamePage()), — показывается плашка «Сайт обновился — страница перезагрузится при переходе», и перезагрузка ждёт перехода. Ни разу этот рубильник ещё не понадобился: обычная плашка справлялась.
После «Обновить» открывается тот же адрес, а фоновые вкладки оболочки не восстанавливаются: оболочка их нигде не сохраняет.
Кэш браузера
Номер сборки бесполезен, если браузер после перезагрузки возьмёт старый код из своего кэша. А так и было: сборка островов islands.js с постоянным именем отдавалась вовсе без Cache-Control. Без явных правил браузер может назначить срок свежести эвристически, и прежний файл остаётся в кэше дольше ожидаемого — после выкладки иногда часами.
Теперь у разных файлов разные правила:
| Что | Cache-Control | Почему |
|---|---|---|
| страницы целиком, API | no-cache |
копию хранить можно, но перед использованием сверяться с сервером |
часть страницы для оболочки (X-Shell) |
no-store |
персональна и без обёртки страницы: в кэш и в восстановление сессии ей нельзя |
/api/web/version |
no-store |
ответ о версии всегда свежий |
islands.js, style.css |
no-cache |
имя постоянное, содержимое меняется с каждой сборкой |
куски сборки /dist/chunks/* |
public, max-age=31536000, immutable |
в имени хеш содержимого: другой код — другое имя |
no-cache — не «не кэшировать». Браузер хранит копию, но перед использованием сверяется с сервером и не берёт её «на глаз».
Один и тот же адрес отдаёт то страницу целиком, то её часть для оболочки, поэтому на любом ответе страницы стоит Vary: X-Shell: кэш должен различать эти представления. А саму часть страницы, кроме того, запрещено хранить вовсе: Vary разные пути браузера соблюдают неровно, и человек мог бы получить голый фрагмент вместо страницы.
Ограничения остаются, и оба связаны с раскаткой. На проде две копии сайта; при раскатке сначала поднимается новая, потом гасится старая, и какое-то время отвечают обе, без привязки вкладки к одной копии. no-cache требует проверки свежести, но не привязывает ресурсы к одной сборке: страница может прийти от новой копии, а islands.js с постоянным именем — от старой. Полная перезагрузка в этом окне согласованную сборку не гарантирует. Окно короткое — пока живы обе копии, — и после раскатки всё сходится, но закрытым его назвать нельзя.
Второе: в образ новой сборки попадают только её собственные куски, кусков прежней сборки сервер после выкладки уже не отдаёт. Старая вкладка, которая запросит ещё не загруженный ленивый кусок своей сборки, получит ошибку загрузки, а отдельного перехвата такой ошибки пока нет. В отличие от первого ограничения, этот риск не кончается вместе с раскаткой: он сохраняется, пока старая вкладка не перезагружена. Плашка о новой версии появится, но код вкладки от этого не сменится. Сужает риск только то, что переход оболочки сначала сверяет номер сборки и при расхождении грузит страницу целиком.
Одна строка ?v= и две копии React
Вместе с механизмом версии к стилям и островам добавили привычный приём против кэша: style.css?v=<сборка> и islands.js?v=<сборка>. Со стилями всё хорошо. С островами через пять дней сломались две страницы.
Страница загружала islands.js?v=…, а ленивый кусок сборки, который подгружается по требованию, импортировал тот же islands.js, но без параметра. Для браузера адрес с параметром и без — два разных модуля. Код выполнялся дважды, на странице оказывалось две копии React, и ленивый остров падал на useState с «Cannot read properties of null». Страница оставалась пустой.
Нашли по отчёту об ошибке на тестовом стенде. Параметр у islands.js убрали — свежесть ему и так обеспечивает no-cache — и добавили тест, который не даст вернуть его обратно. Урок общий: параметр в адресе ES-модуля меняет не только кэш, но и личность модуля.
Что нашлось при подготовке статьи
Состояние на 8 октября 2026 года. Разбор кода для этой статьи нашёл две ошибки, и обе исправлены до её выхода:
- Плашка говорила только на двух языках. Надписи были зашиты в код по-русски и по-английски, а сайт переведён ещё на немецкий, французский и испанский. Теперь надписи приходят из сервиса переводов вместе со страницей, а русский и английский в коде остались запасным путём.
- Партия в фоновой вкладке не была защищена. Принудительная перезагрузка ждала перехода, только если партия была на экране: проверка смотрела на адрес видимой страницы. Если партия шла в фоновой вкладке, а на экране была комната, перезагрузка сработала бы сразу. Теперь оболочка сама сообщает, открыта ли партия в любой из её вкладок. Ошибка была только в запасном рубильнике, который ни разу не включался, поэтому игроков она не задела.
Итог
- Страница без перезагрузок живёт на старом коде. Если сайт отказывается от полной загрузки ради живого соединения, ему нужен способ сказать вкладке, что код устарел.
- Номер сборки — в каждом ответе. Тогда о выкладке вкладка узнаёт из трафика, который и так идёт, а отдельный опрос нужен редко.
- Шаг опроса — настройка стенда. На тестовом — минута, на проде — полчаса: редкий опрос ограничивает дополнительную нагрузку, а работающая вкладка узнаёт о выкладке при первом переходе.
- Часть страницы новой сборки не вставляется в старую. Вместо этого — полная загрузка того же адреса.
- Предлагать, а не перезагружать. Перед игроком может быть партия. Принудительный режим есть, но как рубильник на крайний случай.
- Во время раскатки отвечают обе версии. Замеченное обновление назад не откатывается.
- Кэш:
no-cacheдля постоянных имён,immutableдля хешированных. А параметр?v=у ES-модуля превращает один модуль в два.
Комментарии