Skip to content

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:

json
{ "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 ​

  1. 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.
  2. That list goes to every player on join, in one packet — the manifest. Nothing but the list; the files themselves are not sent yet.
  3. 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.
  4. A page is opened at http://127.0.0.1:25580/<session-token>/index.html. The mod substitutes that for webgui:/index.html when it opens the page.
  5. 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.
  6. The bytes are verified against the SHA-256 from the manifest before they are cached, then handed to the browser.
  7. HTML gets the window.webgui bridge 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>
Originhttp://127.0.0.1:25580 — this is what your backend sees and what you name in CORS.
Port25580 by default. If it is taken, the next free one up to 25599.
Session token16 random bytes, hex, minted once per world session. Requests without it get a 404.
Secure contextYes. 127.0.0.1 is a trustworthy origin, so crypto.subtle, service workers and the clipboard API all work.
Read it from a pagewindow.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:

html
<!-- 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:

ToolSetting
Vitebase: './' in vite.config.js
Create React App"homepage": "." in package.json
Next.js (static export)assetPrefix: '.'
Nuxt (static)app.baseURL: './'
Webpackoutput.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.html

Requested 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:/dashboard 404s unless dashboard is a real file. Use hash routing (#/dashboard) for a router, or generate real .html files.
  • No index for subdirectories. Only the empty path maps to index.html; sub/ does not become sub/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 ​

ExtensionServed as
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, icothe matching image/*
woff2, woff, ttf, otfthe matching font/*
wasmapplication/wasm
mp3, ogg/oga, wav, mp4, webmthe matching audio/* or video/*
txt, xml, pdftext/plain, application/xml, application/pdf
anything elseapplication/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 ​

LimitValueWhat happens
Per file8 MiBThe file is skipped; the rest are served.
Total64 MiBThe scan stops there.
File count2000The scan stops there.
Directory depth12Deeper files are not seen.
The file list itself512 KiBNothing 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: true

The 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:25580

So 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 ​

SymptomCauseFix
Page is blank, client log shows page asset /assets/app.js was requested without this session's prefixAbsolute asset pathsBuild with a relative base — see the table above
One file 404s, client log shows <path> is not in this server's pagesIt was skipped by a limit, or the case does not matchCheck the server log after a reload; match the on-disk name exactly
webgui:/dashboard 404s but index.html worksNo SPA fallbackHash routing, or emit a real dashboard.html
Everything 404s on a page that worked a minute agoThe page is from the previous world session; its token is deadReopen it; do not persist assetsBase between sessions
504, page shows The server did not send <path>The transfer timed out (20 s) or hit assetBytesPerSecondRaise the limit, or make the file smaller
Script or stylesheet is fetched but ignoredUnknown extension, served as application/octet-streamUse a known extension
Nothing is served, log says no free port in 25580..2559920 loopback ports are takenFree a port; external http(s) pages still work
Nothing is served, no port line eitherserveBundledPages is off, or the folder is emptyCheck config/webgui/server.json and the folder
Works for you, CORS error for one playerThat player has cef-disable-web-security=falseConfigure CORS properly
Second client on the machine cannot reach your APIIt is on port 25581, which your CORS rule does not nameAllow 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 hostsYour own host
SetupA folderA domain, TLS, deployment
Works behind NATYesNeeds to be reachable
Server-side renderingNoYes
WebSockets to your backendYesYes
Originhttp://127.0.0.1:25580Yours — no CORS to think about
Update flowEdit a file, /webgui reloadYour deploy
Size64 MiB, 2000 filesWhatever 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.