vitrine

docs: a Forwarding section in the README

commit db16d53845b564e6f09415db2a49f5aa5d35e8ec · parent 06f4ceb908 · browse this tree
Ximianth, 2026-08-14 16:40:33 UTC

Also softens two now-stale absolutes nearby: the intro line said
"no dynamic anything", and config errors said "zero exhibits" where
a forwards-only config is now legitimate.
diff --git a/README.md b/README.md
index 1bbf98c..4303631 100644
--- a/README.md
+++ b/README.md
@@ -7,6 +7,7 @@ Point it at some directories via a small config file, and it serves them:
 markdown renders to HTML, directories become listings,
 and `?fmt=raw` on any URL serves the file bytes directly.
 No javascript required, no uploads, no auth, no dynamic anything beyond rendering.
+(URL prefixes can also forward to other local services; see Forwarding below.)
 
 Config
 ------
@@ -38,7 +39,7 @@ exhibits:
   Trailing slashes on names and paths are accepted and normalized away.
 - `expose-git` (optional, default false): see below.
 
-A bad config (parse error, unknown key, missing path, or zero exhibits)
+A bad config (parse error, unknown key, missing path, or nothing configured at all)
 is a startup error: vitrine refuses to run rather than serve the wrong thing.
 
 An exhibit whose directory is absent is not a bad config, though:
@@ -50,6 +51,48 @@ Config changes apply live: vitrine watches the config file
 so a change lands within ~5s worst case, usually instantly).
 Edit freely; a typo mid-edit logs a rejection and leaves the running server untouched.
 
+Forwarding
+----------
+
+Besides serving file trees, vitrine can reverse-proxy URL prefixes to other services,
+so one publicly routable port can carry both the docs and, say, a game server:
+
+```yaml
+exhibits:
+  some-repo:
+    path: "%"
+forwards:
+  /:
+    to: http://localhost:8866
+  tools/dashboard:
+    to: http://localhost:9090
+```
+
+- `forwards` maps URL prefixes to backend base URLs, sibling to `exhibits`.
+  Names share the URL namespace with exhibits (a collision is a config error)
+  and appear in listings like any other mount.
+- The name `/` is the root forward: it catches every request
+  that no exhibit, forward, or listing claims.
+  Exhibits keep winning at their own prefixes -- dispatch is longest-prefix-match --
+  so a backend at the root simply never sees URLs that collide
+  with an exhibit prefix, its ancestors, or the reserved `/-/` prefix.
+- `to` must be an absolute `http://` or `https://` URL; the scheme is mandatory.
+  Websocket backends are also `http://`: a websocket connection begins
+  as an ordinary http request with an `Upgrade` header, which the proxy passes through,
+  so websockets and SSE streams work with no extra configuration.
+- A path on the `to` URL becomes a prefix on every proxied request.
+- The mount prefix is stripped before proxying:
+  a backend behind `tools/dashboard` sees `/x`, not `/tools/dashboard/x`.
+  It should therefore use relative URLs in its pages
+  (or be told its public prefix by its own means; vitrine does not rewrite response bodies).
+- An unreachable backend answers 502 per-request.
+  Backend liveness is deliberately not part of config validation:
+  backends come and go while vitrine runs.
+
+Forwarding is one of two deliberate exceptions to "read-only, no dynamic anything"
+(the other is `expose-git`); vitrine adds no auth, no TLS, and no rewriting --
+the forward is a pane of glass to another local port.
+
 Theming
 -------
 
diff --git a/main.go b/main.go
index 8cda9ea..dd0f22e 100644
--- a/main.go
+++ b/main.go
@@ -7,6 +7,8 @@
 //
 // Exhibits come from a YAML config file given via the required -config flag;
 // see config.go for the schema and README.md for examples.
+// The config can also declare forwards: URL prefixes reverse-proxied
+// to other http services (websockets and SSE pass through); see serve.go.
 package main
 
 import (