Skip to content

Страницы, которые раздаёт сервер ​

WebGUI умеет раздавать файлы страниц сам — по тому соединению, по которому игрок и так играет. Положите их в config/webgui/web/ и адресуйте схемой webgui::

json
{ "mainMenuUrl": "webgui:/index.html", "deathScreenUrl": "webgui:/death.html" }

Ни хостинга, ни домена, ни открытого порта — и это работает за NAT, как всё остальное, что присылает сервер. Здесь подробно: как именно это происходит, каким должен быть ваш билд и что смотреть, когда файл не загрузился.

Если хостинг у вас уже есть, ничего из этого вас не касается: http:// и https:// URL не затронуты и ведут себя как раньше. Смешивать одно с другим можно свободно.

Как файл доезжает от сервера до страницы ​

  1. Сервер сканирует папку при старте и на /webgui reload. Каждый файл превращается в запись: путь, SHA-256, размер, content type.
  2. Этот список уходит каждому игроку при входе одним пакетом — манифест. Только список; сами файлы пока не отправляются.
  3. Клиент поднимает небольшой HTTP-сервер на loopback, http://127.0.0.1:25580, и только в этот момент: клиент, который на такой сервер не заходил, не слушает ничего.
  4. Страница открывается по адресу http://127.0.0.1:25580/<токен-сессии>/index.html — мод сам подставляет его вместо webgui:/index.html.
  5. Когда браузер просит файл, клиент смотрит в кэш (webgui-cache/ в папке игры, ключ — хеш). Если файла нет, он запрашивает его у сервера, и тот присылает байты чанками по 32 КиБ по тому же игровому соединению.
  6. Байты проверяются по SHA-256 из манифеста до попадания в кэш, и только потом уходят в браузер.
  7. В HTML вплетается мост window.webgui прямо перед </head> — чтобы инлайновый скрипт в начале документа мог сразу его вызвать.

Два следствия, которые стоит держать в голове:

  • Файлы тянутся по требованию, а не заранее. Едет только то, что страница реально запросила.
  • Адресация по хешу. Изменённый файл — это другой хеш, поэтому устаревшая страница невозможна; а неизменённый файл не передаётся дважды, в том числе между сессиями.

Origin, на котором работает страница ​

http://127.0.0.1:25580/<токен-сессии>/<путь>
Originhttp://127.0.0.1:25580 — именно это видит ваш бэкенд и это указывается в CORS.
Порт25580 по умолчанию. Если занят — первый свободный до 25599.
Токен сессии16 случайных байт в hex, выпускается один раз на сессию мира. Запрос без него — 404.
Secure contextДа. 127.0.0.1 считается доверенным origin, поэтому crypto.subtle, service workers и clipboard API работают.
Как прочитать со страницыwindow.webgui.assetsBase — origin вместе с токеном, например http://127.0.0.1:25580/ab12….

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

Второй клиент на той же машине получит другой порт

Первый держит 25580, второй получит 25581. Origin у них разный, поэтому правило CORS, где указан только 25580, второго не покроет. Если ваши игроки запускают больше одного клиента — разрешите диапазон 25580–25583, а страницы пусть читают window.webgui.assetsBase, а не хардкодят порт.

Правила, которым должен соответствовать билд ​

Относительные пути к ассетам ​

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

html
<!-- 404: превращается в http://127.0.0.1:25580/assets/app.js, без токена -->
<script src="/assets/app.js"></script>

<!-- работает: разрешается внутри токена -->
<script src="./assets/app.js"></script>

Абсолютные пути по умолчанию делает любой сборщик, и любому можно сказать не делать:

ИнструментНастройка
Vitebase: './' в vite.config.js
Create React App"homepage": "." в package.json
Next.js (статический экспорт)assetPrefix: '.'
Nuxt (статика)app.baseURL: './'
Webpackoutput.publicPath: ''
Parcel--public-url ./

Если страница открылась пустой — проверяйте это первым делом. Лог клиента говорит об этом прямым текстом, по одной строке на каждый уникальный путь:

[webgui-assets/WARN] webgui: page asset /assets/app.js was requested without this session's
prefix, so it cannot be served. A build that emits absolute paths like /assets/app.js does
this - rebuild it with a relative base (Vite base: './', CRA homepage: '.').
Requested by: http://127.0.0.1:25580/df55…/index.html

Requested by — это документ, который сделал запрос: если открыто несколько страниц, сразу видно, какая виновата.

Роутинга на стороне сервера нет ​

Клиент раздаёт файлы и больше ничего. А именно:

  • Нет SPA-fallback. Глубокая ссылка вида webgui:/dashboard даст 404, если dashboard не существует как файл. Используйте hash-роутинг (#/dashboard) либо генерируйте реальные .html.
  • Нет индекса для подкаталогов. Только пустой путь превращается в index.html; sub/ не станет sub/index.html.
  • Нет листинга каталогов, rewrite-ов, редиректов и SSR. Нужен SSR — хостите страницу у себя и укажите https://ваш-домен/…; этот путь не изменился.

Пути регистрозависимы ​

Список файлов ключуется именем на диске — даже на Windows, где файловая система регистр не различает. Index.html не найдёт index.html.

Query и фрагмент при поиске файла игнорируются ​

app.js?v=3 и app.js#x оба разрешаются в app.js, так что cache-busting в query не вредит — но и не помогает: эту работу уже делает хеш.

Content type определяется расширением ​

РасширениеОтдаётся как
html, htmtext/html; charset=utf-8
js, mjstext/javascript; charset=utf-8
csstext/css; charset=utf-8
json, mapapplication/json; charset=utf-8
svg, png, jpg/jpeg, gif, webp, avif, icoсоответствующий image/*
woff2, woff, ttf, otfсоответствующий font/*
wasmapplication/wasm
mp3, ogg/oga, wav, mp4, webmсоответствующий audio/* или video/*
txt, xml, pdftext/plain, application/xml, application/pdf
всё остальноеapplication/octet-stream

Source maps работают — .map отдаётся как JSON. Файл без расширения или с расширением не из таблицы приедет как application/octet-stream, и браузер откажется исполнять его как скрипт или применять как стиль.

Ограничения и что происходит при их превышении ​

ОграничениеЗначениеЧто будет
На файл8 МиБФайл пропускается, остальные раздаются.
Всего64 МиБСканирование останавливается.
Количество файлов2000Сканирование останавливается.
Глубина каталогов12Более глубокие файлы не видны.
Сам список файлов512 КиБНе раздаётся ничего — один пакет столько не унесёт.
Симлинки—Не разыменовываются и не раздаются.

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

Есть ещё бюджет передачи на игрока — assetBytesPerSecond (по умолчанию 4 МиБ/с, 0 отключает). Страница, которая его пробивает, получает ошибки на запросы, а не очередь.

Общение с вашим бэкендом ​

CORS ​

Назовите origin страницы в ответе своего API:

Access-Control-Allow-Origin: http://127.0.0.1:25580
Access-Control-Allow-Credentials: true

У внутриигрового браузера web security по умолчанию выключена — но полагаться на это нельзя

Rinku поставляется с cef-disable-web-security=true в config/rinku/rinku.properties. То есть по умолчанию same-origin policy не применяется и кросдоменный fetch проходит вообще без CORS-заголовков. Но это клиентская настройка, которую игрок может выключить — и тогда CORS начнёт работать как в обычном браузере.

Поэтому настраивайте CORS всё равно. Иначе у вас страница работает, а у единственного игрока, который поправил этот файл, — нет. Худший вид баг-репорта.

Обратное направление — внешняя страница читает файл из встроенного набора — возможно, но с оговорками. Ответы мода содержат Access-Control-Allow-Origin: *, и обычному <img src> или простому GET больше ничего не нужно. Но локальный сервер отвечает только на GET и HEAD, поэтому любой запрос с preflight не пройдёт, а браузеры ограничивают обращения с публичной страницы к локальному адресу, и правила эти меняются. Считайте это тем, что нужно проверить под свой случай, а не тем, на чём можно строить.

WebSocket ​

На WebSocket CORS не распространяется вообще — ни preflight, ни Access-Control-Allow-Origin. Браузер отправляет origin страницы заголовком, а решает ваш сервер:

Origin: http://127.0.0.1:25580

Так что new WebSocket('wss://ваш-домен/hud') со встроенной страницы подключится, если проверка origin на вашей стороне это значение принимает. Добавьте его в тот allowlist, который использует ваш фреймворк (cors.origin у Socket.IO, список origin-ов у Django, map у nginx и так далее).

Mixed content тоже не мешает: блокируется только открытие ws:// со страницы https, а эта страница — http, поэтому разрешены и ws://, и wss://.

Встроенная страница — другой origin относительно вашего домена, поэтому cookie, выставленные на https://ваш-домен, здесь сторонние: им нужны SameSite=None; Secure, запросу — credentials: 'include', а ответу — ваш точный origin (не *) плюс Access-Control-Allow-Credentials: true. И даже тогда блокировка сторонних cookie может их отобрать.

Лучше используйте токен, который мод и так вам даёт: при включённом enableTokens в каждый URL, который открывает WebGUI, подставляется подписанный webgui_token с именем игрока. Прочитайте его из location.search и отправьте заголовком — см. Проверку токена.

/webgui reload ​

Релоад пересканирует папку и рассылает новый список файлов всем, кто онлайн. Встроенная страница, уже открытая у игрока, перезагружается на месте — можно править файл и сразу видеть результат, никому не нужно перезаходить. Страницы с внешнего хостинга не трогаются; их перезагружают так же, как в браузере.

URL страницы при релоаде не меняется, включая токен сессии, поэтому всё, что держало ссылку, продолжает работать.

Что смотреть, когда не работает ​

СимптомПричинаЧто делать
Страница пустая, в логе клиента page asset /assets/app.js was requested without this session's prefixАбсолютные пути к ассетамСобрать с относительным base — см. таблицу выше
Один файл даёт 404, в логе клиента <путь> is not in this server's pagesПропущен по ограничению или не совпал регистрСмотреть лог сервера после релоада; имя должно совпадать с диском точно
webgui:/dashboard даёт 404, а index.html работаетНет SPA-fallbackHash-роутинг либо реальный dashboard.html
Всё стало 404 на странице, которая минуту назад работалаСтраница от предыдущей сессии мира, её токен мёртвОткрыть заново; не сохранять assetsBase между сессиями
504, на странице The server did not send <path>Передача не уложилась в 20 с или упёрлась в assetBytesPerSecondПоднять лимит или уменьшить файл
Скрипт или стиль скачался, но проигнорированНезнакомое расширение, отдан как application/octet-streamИспользовать известное расширение
Ничего не раздаётся, в логе no free port in 25580..25599Заняты все 20 loopback-портовОсвободить порт; внешние http(s) страницы при этом работают
Ничего не раздаётся, и строки про порт тоже нетserveBundledPages выключен или папка пустаПроверить config/webgui/server.json и папку
У вас работает, у одного игрока ошибка CORSУ него cef-disable-web-security=falseНастроить CORS по-человечески
Второй клиент на машине не достаёт до вашего APIОн на порту 25581, которого нет в вашем CORSРазрешить диапазон, читать window.webgui.assetsBase

Проблемы со стороны страницы видны в логе клиента (запросы, отсутствующие файлы, вывод консоли браузера), проблемы сканирования — в логе сервера (пропущенные файлы, лимиты). При первой настройке стоит держать открытыми оба.

Что выбрать: раздачу с сервера или свой хостинг ​

Раздача с сервераСвой хостинг
НастройкаПапкаДомен, TLS, деплой
Работает за NATДаНужна доступность извне
SSRНетДа
WebSocket к вашему бэкендуДаДа
Originhttp://127.0.0.1:25580Ваш — про CORS думать не надо
ОбновлениеПравка файла и /webgui reloadВаш деплой
Объём64 МиБ, 2000 файловСколько есть

Одно другого не исключает. Частая схема — встроенная оболочка, которая общается с вашим API по WebSocket, или внешняя страница, которая берёт пару тяжёлых картинок из встроенного набора.