Pages the server hosts
WebGUI can serve your page files itself, straight down the connection the player is already on. Drop them in config/webgui/web/ and address them with the webgui: scheme:
{ "mainMenuUrl": "webgui:/index.html", "deathScreenUrl": "webgui:/death.html" }No web host, no domain, no port to open, and it works behind NAT like anything else a server sends. This page explains exactly how that happens, what your build has to look like, and what to check when a file does not load.
If you already host your pages somewhere, nothing here applies to you — http:// and https:// URLs are untouched and behave as they always did. The two mix freely.
How a file gets from the server to the page
- The server scans the folder on startup and on
/webgui reload. Each file becomes an entry with its path, SHA-256, size and content type. - That list goes to every player on join, in one packet — the manifest. Nothing but the list; the files themselves are not sent yet.
- The client starts a small HTTP server on loopback,
http://127.0.0.1:25580, and only then — a client that never joins a server using this feature listens on nothing. - A page is opened at
http://127.0.0.1:25580/<session-token>/index.html. The mod substitutes that forwebgui:/index.htmlwhen it opens the page. - When the browser asks for a file, the client checks its cache (
webgui-cache/in the game directory, keyed by hash). On a miss it asks the server, which sends the bytes in 32 KiB chunks over the same game connection. - The bytes are verified against the SHA-256 from the manifest before they are cached, then handed to the browser.
- HTML gets the
window.webguibridge woven in just before</head>, so an inline script at the top of your document can call it immediately.
Two consequences worth internalising:
- Files are fetched on demand, not up front. Only what a page actually requests travels.
- Content is addressed by hash. A file that changed is a different hash, so a stale page is impossible; a file that did not change is never transferred twice, across sessions included.
The origin your page runs on
http://127.0.0.1:25580/<session-token>/<path>| Origin | http://127.0.0.1:25580 — this is what your backend sees and what you name in CORS. |
| Port | 25580 by default. If it is taken, the next free one up to 25599. |
| Session token | 16 random bytes, hex, minted once per world session. Requests without it get a 404. |
| Secure context | Yes. 127.0.0.1 is a trustworthy origin, so crypto.subtle, service workers and the clipboard API all work. |
| Read it from a page | window.webgui.assetsBase — the origin and the token, e.g. http://127.0.0.1:25580/ab12…. |
The port is deliberately fixed rather than random: your backend has to name this origin in CORS, and a port that moved every launch could not be configured. The token exists so that another process on the machine cannot enumerate what a server sent you, and so that a page left over from the previous server cannot read the current one's files — leaving a world clears it.
A second game instance on the same machine gets a different port
The first client holds 25580, the second gets 25581. Their origins differ, so a CORS rule naming only 25580 will not cover the second one. Allow 25580–25583 if your players run more than one client, and have pages read window.webgui.assetsBase instead of hard-coding the port.
Rules your build has to follow
Use relative asset paths
This is the one that catches everybody. Your document lives under /<session-token>/, so an absolute path leaves that prefix behind and 404s:
<!-- 404: resolves to http://127.0.0.1:25580/assets/app.js, with no token -->
<script src="/assets/app.js"></script>
<!-- works: resolves under the token -->
<script src="./assets/app.js"></script>Every bundler emits absolute paths by default, and every bundler can be told not to:
| Tool | Setting |
|---|---|
| Vite | base: './' in vite.config.js |
| Create React App | "homepage": "." in package.json |
| Next.js (static export) | assetPrefix: '.' |
| Nuxt (static) | app.baseURL: './' |
| Webpack | output.publicPath: '' |
| Parcel | --public-url ./ |
If a page comes up blank, this is the first thing to check — and the client log says so outright, once per distinct path:
[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.htmlRequested by is the document that asked, so with several pages open you can tell which one is at fault.
There is no server-side routing
The client serves files, and nothing else. Specifically:
- No SPA fallback. A deep link such as
webgui:/dashboard404s unlessdashboardis a real file. Use hash routing (#/dashboard) for a router, or generate real.htmlfiles. - No index for subdirectories. Only the empty path maps to
index.html;sub/does not becomesub/index.html. - No directory listing, no rewrites, no redirects, no server-side rendering. If you need SSR, host the page yourself and point the URL at
https://your-domain/…; that path is unchanged.
Paths are case-sensitive
The file list is keyed by the name on disk, even on Windows where the filesystem is not. Index.html will not find index.html.
Query strings and fragments are ignored when resolving
app.js?v=3 and app.js#x both resolve to app.js, so cache-busting query strings do no harm — and no good either, since the hash already does that job.
Content types come from the extension
| Extension | Served as |
|---|---|
html, htm | text/html; charset=utf-8 |
js, mjs | text/javascript; charset=utf-8 |
css | text/css; charset=utf-8 |
json, map | application/json; charset=utf-8 |
svg, png, jpg/jpeg, gif, webp, avif, ico | the matching image/* |
woff2, woff, ttf, otf | the matching font/* |
wasm | application/wasm |
mp3, ogg/oga, wav, mp4, webm | the matching audio/* or video/* |
txt, xml, pdf | text/plain, application/xml, application/pdf |
| anything else | application/octet-stream |
Source maps work — .map is served as JSON. An extensionless file, or one with an extension not in this table, arrives as application/octet-stream, which the browser will refuse to execute as a script or apply as a stylesheet.
Limits, and what happens when you exceed them
| Limit | Value | What happens |
|---|---|---|
| Per file | 8 MiB | The file is skipped; the rest are served. |
| Total | 64 MiB | The scan stops there. |
| File count | 2000 | The scan stops there. |
| Directory depth | 12 | Deeper files are not seen. |
| The file list itself | 512 KiB | Nothing is served at all — one packet cannot carry it. |
| Symlinks | — | Never followed, and not served. |
Everything skipped is named in the server log at startup and after every reload. If a file you expect is missing, that log says why. The last row is the harsh one: a set with thousands of long paths is refused wholesale rather than half-published, and the log says to serve fewer files or host them yourself.
There is also a transfer budget per player, assetBytesPerSecond (4 MiB/s by default, 0 disables it). A page that blows through it sees its requests fail rather than being queued.
Talking to your backend
CORS
Name the page's origin in your API's response:
Access-Control-Allow-Origin: http://127.0.0.1:25580
Access-Control-Allow-Credentials: trueThe in-game browser has web security off by default — do not rely on it
Rinku ships with cef-disable-web-security=true in config/rinku/rinku.properties, so by default the same-origin policy is not enforced and a cross-origin fetch succeeds even with no CORS headers at all. That is a client-side setting a player can switch off, and then CORS applies exactly as in a normal browser.
So configure CORS properly regardless. Otherwise your page works on your machine and fails on the one player who changed that file — the worst kind of bug report to receive.
The other direction — an externally hosted page reading a file out of the bundled set — is possible but has more edges. The mod's own responses carry Access-Control-Allow-Origin: *, and a plain <img src> or a simple GET needs nothing more. But the local server answers only GET and HEAD, so any request that triggers a preflight fails, and browsers restrict requests from a public page to a local address in ways that keep changing. Treat it as something to verify for your case rather than something to build on.
WebSockets
WebSockets are not subject to CORS at all — no preflight, no Access-Control-Allow-Origin. The browser sends the page's origin as a header and your server decides:
Origin: http://127.0.0.1:25580So new WebSocket('wss://your-domain/hud') from a bundled page connects, as long as your server's own origin check accepts that value. Add it to whatever allowlist your framework uses (Socket.IO's cors.origin, Django's ALLOWED_HOSTS/origins, an nginx map, …).
Mixed content is not a problem here either: only an https page is barred from opening ws://, and this page is http, so both ws:// and wss:// are allowed.
Cookies and auth
A bundled page is a different origin from your domain, so cookies you set on https://your-domain are third-party here: they need SameSite=None; Secure, the request needs credentials: 'include', and the response needs your exact origin (not *) plus Access-Control-Allow-Credentials: true. Even then, third-party cookie blocking can take them away.
Prefer the token the mod already gives you: with enableTokens on, every URL WebGUI opens carries a signed webgui_token naming the player. Read it from location.search and send it in a header. See Token verification.
/webgui reload
Reload re-scans the folder and pushes the new file list to everyone online. A bundled page a player already has open is refreshed in place, so you can edit a file and watch the result without anyone rejoining. Pages loaded from a web host are left alone — reload those the way you would in a browser.
The page's URL does not change across a reload, session token included, so anything holding a URL keeps working.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
Page is blank, client log shows page asset /assets/app.js was requested without this session's prefix | Absolute asset paths | Build with a relative base — see the table above |
One file 404s, client log shows <path> is not in this server's pages | It was skipped by a limit, or the case does not match | Check the server log after a reload; match the on-disk name exactly |
webgui:/dashboard 404s but index.html works | No SPA fallback | Hash routing, or emit a real dashboard.html |
| Everything 404s on a page that worked a minute ago | The page is from the previous world session; its token is dead | Reopen it; do not persist assetsBase between sessions |
504, page shows The server did not send <path> | The transfer timed out (20 s) or hit assetBytesPerSecond | Raise the limit, or make the file smaller |
| Script or stylesheet is fetched but ignored | Unknown extension, served as application/octet-stream | Use a known extension |
Nothing is served, log says no free port in 25580..25599 | 20 loopback ports are taken | Free a port; external http(s) pages still work |
| Nothing is served, no port line either | serveBundledPages is off, or the folder is empty | Check config/webgui/server.json and the folder |
| Works for you, CORS error for one player | That player has cef-disable-web-security=false | Configure CORS properly |
| Second client on the machine cannot reach your API | It is on port 25581, which your CORS rule does not name | Allow the range, read window.webgui.assetsBase |
The client log is where page-side problems show up (requests, missing files, browser console output); the server log is where scan problems show up (skipped files, limits). Both are worth having open the first time you set this up.
Choosing between this and your own host
| Pages the server hosts | Your own host | |
|---|---|---|
| Setup | A folder | A domain, TLS, deployment |
| Works behind NAT | Yes | Needs to be reachable |
| Server-side rendering | No | Yes |
| WebSockets to your backend | Yes | Yes |
| Origin | http://127.0.0.1:25580 | Yours — no CORS to think about |
| Update flow | Edit a file, /webgui reload | Your deploy |
| Size | 64 MiB, 2000 files | Whatever you have |
They are not exclusive. A common shape is a bundled shell that talks to your API over WebSocket, or an externally hosted page that pulls a few big images out of the bundled set.