Client-Server Synchronisation
Gamehoster mirrors your local games to the server over a private, token-protected API on the server's IP. Caddy terminates TLS and reverse-proxies to a localhost-only game server, and the sync client generates each game to static code and then pushes only the files that changed. The read-only endpoints behind the logs and stats browsers share this shape and live on the Tools page.
GET /api/version
GET /api/tree
PUT /api/file
DELETE /api/file
Private API
Every call but the version probe is authenticated with a bearer token — the
gamehoster-config-apiToken from your client
configuration, sent as Authorization: Bearer <token>. There is nothing else to the surface:
no dashboard, login, session or cookie. A correctly authenticated call to a real endpoint always returns
200 with a JSON object — the outcome, including a failed write, lives in the body. Anything else —
a missing or wrong token, an unknown route, or a path pointing outside the games root — returns an identical
plain 404, so a probe cannot tell the API is there at all (see
Networking). The sync routes sit directly under
/api/; the version probe is deliberately unauthenticated so a client can always ask what the server
speaks.
| Endpoint | Purpose |
|---|---|
GET /api/version | Report the server's API version, so the client can detect skew and pick its path prefix. Public. |
GET /api/tree | Fetch a content hash of every file under the games root, so the client can diff and push only what differs. |
PUT /api/file | Upload one file to its path under the games root, creating parents and scheduling a reload. |
DELETE /api/file | Delete a file, or a whole game, that is on the server but gone locally. |
GET /api/version
Reports the server's wire-contract version. It is public and unauthenticated, so a client whose version has
drifted can still ask what the server speaks and turn an otherwise opaque 404 into a clear
“upgrade the client” message. It also names the /api/vN prefix the read-only
tools routes live under.
Request
| Method | GET |
|---|---|
| Path | /api/version |
| Auth | None — public. |
| Parameters | None. |
Response
A 200 JSON object:
{ "api": 1 }
| Field | Type | Meaning |
|---|---|---|
api | integer | The server's API/protocol version. The client compares it to its own; on a mismatch it reports which side to upgrade. |
GET /api/tree
Returns a flat map of every file under the games root to its content hash, so the client can compare hashes
file by file and send only what differs. Each hash is sha256 of the file's bytes,
computed the same way on both sides, so equal hashes mean identical content. The whole tree comes back in one
response — there is no descent — which is cheap because a games root holds a bounded set of small definition and
generated files.
Request
GET /api/tree HTTP/1.1
Authorization: Bearer <token>
Authorization: Bearer <token>; no parameters, no body.
Response
A 200 JSON object mapping each relative path to its sha256:
{
"game.example.com/asteroids/gamehoster-game.json": "9f2b…",
"game.example.com/asteroids/gamehoster.server.js": "1a77…",
"game.example.com/asteroids/gamehoster.client.js": "3c04…"
}
sha256 of the file's bytes per file under the games root. The client hashes its own files the same way and takes the difference.| Field | Type | Meaning |
|---|---|---|
<path> | string | Each key is a file's path relative to the games root; each value is the sha256 hex digest of its contents. An empty root returns {}. |
PUT /api/file
Uploads one file — creating or overwriting it — at a path under the games root, creating any parent
directories as needed. The write is confined to the games root: a path escaping it gets the uniform
404. Every write schedules a debounced reload, so a burst of files pushed together triggers a
single re-read of the changed games rather than one per file.
Request
PUT /api/file?path=game.example.com/asteroids/gamehoster.server.js HTTP/1.1
Authorization: Bearer <token>
Content-Type: application/octet-stream
<file bytes>
| Parameter | In | Meaning |
|---|---|---|
path | query | The file's path relative to the games root. A path that normalises outside the root is rejected with the uniform 404. |
| (body) | body | The raw file bytes. |
Method PUT, plus Authorization: Bearer <token>.
Response
A 200 JSON object describing the write:
{ "ok": true, "size": 20144 }
| Field | Type | Meaning |
|---|---|---|
ok | boolean | true on success; false with an error string when the write failed. |
size | integer | The number of bytes written on disk. |
error | string | Present only when ok is false. |
DELETE /api/file
Removes a file, or a whole directory subtree (a game), that exists on the server but is gone locally. Like the write, it is confined to the games root and schedules a reload. Deleting a path that is already absent is still a success — status codes carry no meaning here.
Request
DELETE /api/file?path=game.example.com/asteroids/old-entity.js HTTP/1.1
Authorization: Bearer <token>
| Parameter | In | Meaning |
|---|---|---|
path | query | The relative file or directory path to remove. A path escaping the games root gets the uniform 404. |
Method DELETE, plus Authorization: Bearer <token>; no body.
Response
A 200 JSON object:
{ "ok": true }
| Field | Type | Meaning |
|---|---|---|
ok | boolean | true once the path is gone (or was already absent). |
Synchronisation Process
Synchronisation is content-addressed: the client hashes its files, asks the server for its hashes, and sends
only the difference. It begins with a generate step that turns each game's definition into the
two static files the server runs — this is where the “generated, not interpreted” contract is met, so the
server only ever requires ready code. A run is stateless: it is driven by
client/sync.js, recomputing everything each
time.
Generate the static code
For each game, regenerate its gamehoster.server.js module and gamehoster.client.js
library from the definition, written into the game folder — so the ordinary push carries them and the server
never compiles at request time.
Negotiate the version
GET /api/version — read the server's
version. A skew turns into a clear “upgrade the client”, not a silent misparse.
Diff by content hash
GET /api/tree — fetch the server's hash of
every file and compare it to the local hashes. Files whose sha256 differs are the upload list;
managed server files absent locally are the delete list.
Push new & changed files
PUT /api/file?path=<rel> — send the raw
bytes of each new or changed file. Because the hash is of the contents, the next run's hashes match with no
timestamp to keep in step.
Prune what's gone
DELETE /api/file?path=<rel> — anything
the server holds under a <domain>/<game>/ it manages, but which is absent locally,
is removed.
Server reloads, debounced
Each write and delete schedules a short debounce; when it fires the server re-reads the games that changed
and swaps to the new code. Nothing stays resident; the client watches no files unless you pass
--watch.
What syncs: structure, not a blocklist
What travels is decided by the shape of the tree, so there is nothing to configure and no way to
leak a stray file. The client mirrors exactly <root>/<domain>/<game>/**:
- Domain. A directory whose name contains a dot and does not start with
_or.— e.g.game.example.com. Loose files and non-dotted directories at the root are left alone. - Game. A directory inside a domain whose name does not start with
_or.and holds agamehoster-game.json. Its whole subtree is taken, skipping only hidden entries andnode_modules. - Reserved. Any
_-prefixed name (a future_shared/library, say) is neither pushed nor pruned, so the server can hold things the client does not own. - Prune-safe. Only a path at least
<domain>/<game>/deep may be deleted on the server. Everything shallower — reserved dirs, loose top-level files — the server keeps untouched, so a sync can never remove what it did not put there.
Why it stays correct and cheap
- Content-addressed. Every hash is
sha256of the file's bytes, computed the same way on both sides, so identical content always agrees and any real edit is always caught — with no mtime to keep in step. The client is stateless; the server hashes from disk on each request. - Generated ahead of time. Generation happens on your machine before the push, so the
server loads static modules with no
new Functionat request time. - One small surface. A bearer token guards every call but the version probe; there is no dashboard, login, session or cookie to attack.
- Status codes say nothing. Every authenticated call returns
200with an object, even a failed write. Anything else returns an identical404, so a probe can't tell an API is there at all.