Client-Server Synchronisation
Sitehoster synchronises your local sites to the server over a private, token-protected API on the server's IP. Caddy terminates TLS and reverse-proxies to a localhost-only Node service, and the sync client 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/v1/tree
PUT /api/v1/file
DELETE /api/v1/file
Private API
Every call is authenticated with a bearer token — the
sitehoster-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 “that path is absent”, lives in the body. Anything else — a missing or wrong token,
an unknown route, or a version mismatch — returns an identical plain 404, so a probe
cannot tell the API is there at all (see Server). The file
routes are versioned under /api/v1/; the client discovers the server's version from
/api/version and builds the prefix
from it, so a version bump fails loudly instead of misparsing.
| Endpoint | Purpose |
|---|---|
GET /api/version | Report the server's API version, so the client can detect skew and build its path prefix. |
GET /api/v1/tree | Fetch the Merkle node at a path and depth, so the client can compare and descend only where it differs. |
PUT /api/v1/file | Upload one file and stamp it with its source mtime, so both sides hash it identically. |
DELETE /api/v1/file | Delete a file, or a whole site, that is on the server but gone locally. |
GET /api/version
Reports the server's wire-contract version. It is deliberately unversioned (no /api/vN
prefix), 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. The client calls it once
at the start of a run to build the /api/v<N> path prefix every other request uses —
see common/net.js and
shared/version.js.
Request
| Method | GET |
|---|---|
| Path | /api/version |
| Auth | Authorization: Bearer <token> |
| Parameters | None. |
| Body | 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/v1/tree
Returns the Merkle node for a directory, so
the client can compare hashes and descend only into the subtrees that differ. A file node is its
metadata hash; a directory node is the hash of its children, optionally with those children one level
down. Every hash is derived from stat() alone — size and whole-second mtime — so this
reads no file contents. The server answers it from an in-memory tree, so it never walks the disk per
request.
Request
GET /api/v1/tree?path=example.com&depth=1 HTTP/1.1
Authorization: Bearer <token>
example.com directory one level deep.| Parameter | In | Meaning |
|---|---|---|
path | query | The relative directory path. Empty (path=) is the content root. An illegal or unsafe path is treated as absent. |
depth | query | 0 = this node's hash only, no children; 1 = include the immediate children; omitted / -1 = the whole subtree. |
Plus Authorization: Bearer <token>; no body.
Response
The tree node. A directory fetched at depth=1 looks like:
{
"type": "dir",
"hash": "b1946ac9…",
"children": {
"example.com": { "type": "dir", "hash": "9f86d081…" },
"robots.txt": { "type": "file", "hash": "3a7bd3e2…" }
}
}
depth=1. At depth=0 the children map is omitted.| Field | Type | Meaning |
|---|---|---|
type | string | "dir" or "file". |
hash | string | The node's Merkle hash — for a file, sha256(size:mtime); for a directory, sha256 of its sorted name:childhash lines. Equal hashes ⇒ identical subtrees. |
children | object | Present on a directory only when depth ≠ 0: a map of child name → node, each trimmed to the remaining depth. |
absent | boolean | Returned instead — { "absent": true } — when the path does not exist or is not a legal path. The client reads this as “the server has nothing here”. |
PUT /api/v1/file
Uploads one file — creating or overwriting it — and stamps it with the client's mtime, so the
metadata hash matches on both sides next run. The body streams straight to a hidden
.shtmp-* temp file in the same directory and is renamed into place atomically, so a
half-written file is never served and memory stays bounded regardless of file size (see the
content root). HTML is uploaded with its
sitehoster: directive comments
stripped.
Request
PUT /api/v1/file?path=example.com/index.html&mtime=1784514159000 HTTP/1.1
Authorization: Bearer <token>
Content-Type: application/octet-stream
<file bytes>
| Parameter | In | Meaning |
|---|---|---|
path | query | The relative file path. It must sit inside a domain folder; a bare top-level file, or an unsafe path, is rejected with { ok: false }. |
mtime | query | The source modification time in milliseconds; the server applies it to the written file so the hashes agree. |
| (body) | body | The raw file bytes, streamed with backpressure. |
Method PUT, plus Authorization: Bearer <token>.
Response
A 200 JSON object describing the write:
{ "ok": true, "size": 1234, "mtime": 1784514159000 }
| Field | Type | Meaning |
|---|---|---|
ok | boolean | true on success; false with an error string when the path is invalid or the write failed. |
size | integer | The number of bytes written on disk. |
mtime | integer | The applied modification time in milliseconds (floored to the whole millisecond). |
error | string | Present only when ok is false, e.g. "invalid path". |
DELETE /api/v1/file
Removes a file, or a whole directory subtree (a site), that exists on the server but is gone locally. A directory delete removes its subtree and then prunes any now-empty parent directories, since the server never stores empty directories. Deleting a path that is already absent is still a success — status codes carry no meaning here.
Request
DELETE /api/v1/file?path=example.com/old-page.html HTTP/1.1
Authorization: Bearer <token>
| Parameter | In | Meaning |
|---|---|---|
path | query | The relative file or directory path to remove. An unsafe path is rejected with { ok: false }. |
Method DELETE, plus Authorization: Bearer <token>; no body.
Response
A 200 JSON object:
{ "ok": true, "deleted": true }
| Field | Type | Meaning |
|---|---|---|
ok | boolean | true unless the path was invalid (then false with error). |
deleted | boolean | true if something was removed; false if the path was already absent. |
error | string | Present only when ok is false. |
Synchronisation Process
Synchronisation is a recursive comparison of two Merkle trees built from file metadata;
the client only ever sends the differences. Each file's hash is
sha256(size : mtime-seconds), derived from stat() alone, so building the tree
reads no file data; a directory's hash is the hash of its sorted child name:hash lines.
Two directories therefore share a hash iff every file beneath them has identical size and
timestamp — which is what lets the client compare one root hash and descend only where it differs. The
tree builder is shared with the server in
shared/manifest.js, so both
sides can never disagree. A run is stateless: it is driven by
client/sync.js, recomputing
everything from stat() each time (an
index only skips re-reading unchanged
files — never changing the result). Automation runs first: --sync always
rewrites every copy from its master before
pushing, so un-automated content is never published.
Negotiate the version
GET /api/version — read the
server's version and build the /api/v<N> prefix all later calls use. A skew
turns into a clear “upgrade the client”, not a silent misparse.
Compare the root hash
GET /api/v1/tree?path=&depth=0 —
if the server's root hash equals the local one, nothing changed and the run is done in a single
request. This is the common case.
Descend the differences
GET /api/v1/tree?path=<dir>&depth=1
— for each directory whose hash differs, fetch one level and compare child by child. Identical
subtrees are skipped entirely; this builds the upload and delete lists.
Push new & changed files
PUT /api/v1/file?path=<rel>&mtime=<ms>
— streamed with backpressure, so a multi-GB file stays near-constant in memory. The server applies
the sent mtime so the hashes match next time.
Remove what's gone
DELETE /api/v1/file?path=<rel>
— anything on the server but absent locally is deleted. Deletes run first (so a file↔directory
type change is handled cleanly), then the uploads.
Done — hashes agree
Because the upload carried the source mtime, the next run's root hashes match on the first request. Nothing stays resident; the client watches no files.
Why it stays correct and cheap
- No caches on the wire. Hashes are recomputed from
stat()each run, on both sides. The client is stateless; the server keeps its tree in memory but rebuilds it from disk on restart, and the recomputed hashes still match because the upload preserved the timestamps. - Quick-check semantics. Like rsync's default, a change is detected by a difference in size or whole-second mtime. Editing a file updates its mtime, so real edits are always caught.
- One small surface. A bearer token guards every call; there is no dashboard, login, session or cookie to attack.
- Status codes say nothing. Every authenticated call returns
200with an object, even “that path is absent”. Anything else returns an identical404, so a probe can't tell an API is there at all. - Versioned, so skew is loud. The routes are
/api/v1/…; a version mismatch hits the 404, then the client checksGET /api/versionto say “upgrade the client” instead of failing silently.