Skip to content

Commit 4ac0364

Browse files
feat: lightweight HTTP server for Verso projects (#876)
Adds a lightweight HTTP server for previewing generated Verso HTML locally, run with `lake exe serve`. The server binds only 127.0.0.1, reducing the temptation to deploy it to production. This replaces Python or Node local servers, with defaults that are better-suited to hosting Verso correctly. It also doesn't rely on users having any other programming language ecosystem available. It can be configured from the command line, or for slightly more complex setups, via `serve.toml`. Along the way, this branch encountered a bug in the TeX code generation that needed fixing in order to render the docs for the feature. --------- Co-authored-by: Rob Simmons <rob@lean-fro.org> Co-authored-by: Robert J. Simmons <442315+robsimmons@users.noreply.github.com>
1 parent d6127db commit 4ac0364

20 files changed

Lines changed: 2218 additions & 5 deletions

File tree

.github/workflows/ci.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,10 @@ jobs:
100100
run: |
101101
lake test -- --verbose --check-tex
102102
103+
- name: Test the dev server
104+
run: |
105+
./src/tests/run_serve_test.sh
106+
103107
- name: Generate the test website
104108
run: |
105109
lake exe demosite --output _out/test-projects/demosite

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -284,7 +284,7 @@ To use this in your project:
284284
```
285285

286286
You can preview the resulting files by running
287-
`python3 -m http.server 8000 -d html` and pointing a web browser to
287+
`lake exe verso-serve html` and pointing a web browser to
288288
http://localhost:8000/
289289

290290
In this output, Verso docstrings and moduledocs are rendered. Setting

doc/UsersGuide/Basic.lean

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import UsersGuide.Extensions
1212
import UsersGuide.Output
1313
import UsersGuide.Releases
1414
import UsersGuide.Literate
15+
import UsersGuide.Serve
1516

1617
open Verso.Genre Manual
1718

@@ -113,6 +114,8 @@ Mixing incompatible features results in an ordinary Lean type error.
113114

114115
{include 0 UsersGuide.Literate}
115116

117+
{include 0 UsersGuide.Serve}
118+
116119
# Index
117120
%%%
118121
tag := "index"

doc/UsersGuide/Releases.lean

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ Author: Emilio J. Gallego Arias
66

77
import VersoManual
88

9+
import UsersGuide.Releases.«v4_32_0»
910
import UsersGuide.Releases.«v4_31_0»
1011
import UsersGuide.Releases.«v4_30_0»
1112
import UsersGuide.Releases.«v4_29_0»
@@ -29,6 +30,7 @@ Verso versioning follows Lean's.
2930
This means that we release a new version for each Lean release, usually once per month.
3031
In particular, note that Verso doesn't follow the [semantic versioning model](https://semver.org/).
3132

33+
{include 0 UsersGuide.Releases.«v4_32_0»}
3234
{include 0 UsersGuide.Releases.«v4_31_0»}
3335
{include 0 UsersGuide.Releases.«v4_30_0»}
3436
{include 0 UsersGuide.Releases.«v4_29_0»}

doc/UsersGuide/Releases/v4_31_0.lean

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import VersoManual
99
open Verso.Genre Manual InlineLean
1010

1111

12-
#doc (Manual) "Verso 4.31.0 (unreleased)" =>
12+
#doc (Manual) "Verso 4.31.0" =>
1313
%%%
1414
tag := "release-v4.31.0"
1515
file := "v4.31.0"
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
/-
2+
Copyright (c) 2026 Lean FRO LLC. All rights reserved.
3+
Released under Apache 2.0 license as described in the file LICENSE.
4+
Author: David Thrane Christiansen
5+
-/
6+
7+
import VersoManual
8+
9+
open Verso.Genre Manual InlineLean
10+
11+
#doc (Manual) "Verso 4.32.0 (unreleased)" =>
12+
%%%
13+
tag := "release-v4.32.0"
14+
file := "v4.32.0"
15+
%%%
16+
17+
* Added a {ref "feat-serve"}[development server] for previewing generated HTML locally with `lake exe verso-serve` (#876).
18+
19+
# Development Server
20+
%%%
21+
tag := "feat-serve"
22+
%%%
23+
24+
Verso now includes a small HTTP server for previewing generated HTML on your own machine.
25+
Running `lake exe verso-serve` serves the current directory at `http://127.0.0.1:8000/`, and a directory and `--port` may be given on the command line.
26+
27+
The server is meant for local writing and development.
28+
It binds to `127.0.0.1` only and offers no HTTPS or authentication.
29+
30+
Its defaults suit Verso output.
31+
Additionally, a `verso-serve.toml` file configures mounts, redirects, and custom headers for projects that need more than a single directory.
32+
Because the server ships with Verso, previewing a site no longer depends on having another language ecosystem installed.
33+
34+
See the {ref "serve"}[development server documentation] for the full set of options.

doc/UsersGuide/Serve.lean

Lines changed: 269 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,269 @@
1+
/-
2+
Copyright (c) 2026 Lean FRO LLC. All rights reserved.
3+
Released under Apache 2.0 license as described in the file LICENSE.
4+
Author: David Thrane Christiansen
5+
-/
6+
import Lean.DocString.Syntax
7+
import VersoManual
8+
9+
open Verso Genre Manual
10+
open Verso.Doc
11+
12+
#doc (Manual) "Development Server" =>
13+
%%%
14+
tag := "serve"
15+
htmlSplit := .never
16+
%%%
17+
18+
Verso includes a small HTTP server for previewing generated HTML on your own machine.
19+
Run it with `lake exe verso-serve`.
20+
21+
The server is for *local development only*.
22+
It offers no HTTPS and no authentication.
23+
Do not use it to serve a real site.
24+
25+
# Running the Server
26+
%%%
27+
tag := "serve-running"
28+
%%%
29+
30+
:::paragraph
31+
The `verso-serve` command takes an optional port (8000 by default) and an optional directory (the current directory by default).
32+
33+
```
34+
$ lake exe serve --port 8000 _out/html
35+
```
36+
:::
37+
38+
If the requested port is already in use, the server scans the following ports for a free one and prints the port it settled on, so several servers started with default settings can run at once.
39+
Pass `--strict-port` to fail instead.
40+
41+
42+
# Command-Line Options
43+
%%%
44+
tag := "serve-options"
45+
%%%
46+
47+
A single optional positional argument is accepted, which is the directory to be served at `/`.
48+
It defaults to the current directory unless {ref "serve-config"}[a configuration file] is provided.
49+
If the configuration file defines {ref "serve-mounts"}[mounts], then the positional argument is rejected.
50+
The following options are also accepted:
51+
52+
: `--port PORT`, `-p PORT`
53+
54+
The port to listen on. Defaults to `8000`.
55+
56+
: `--strict-port`
57+
58+
Fail if the port is in use instead of trying another.
59+
60+
: `--config FILE`
61+
62+
Load configuration from `FILE`. Defaults to `./verso-serve.toml` if it exists.
63+
If no configuration file is provided, the default values {ref "serve-config"}[for the configuration format] are used.
64+
65+
: `--quiet`
66+
67+
Suppress per-request logging.
68+
69+
: `--verbose`, `-v`
70+
71+
Log additional detail.
72+
73+
: `--help`, `-h`
74+
75+
Show usage and exit.
76+
77+
Command-line options override values from the configuration file.
78+
79+
# Configuration File
80+
%%%
81+
tag := "serve-config"
82+
%%%
83+
84+
For anything beyond serving a single directory, place a `verso-serve.toml` file next to the project, or point at one with `--config`.
85+
Directories named in the configuration file are resolved relative to the configuration file itself.
86+
Every setting is optional, and an empty file behaves the same as no file.
87+
88+
Every option is described here.
89+
The sections that follow add detail on mounts, redirects, and custom headers.
90+
91+
: `port`
92+
93+
The port to listen on. Must be a number from `1` to `65535`. Defaults to `8000`. If a port is also specified on the command line, the command-line option will be preferred.
94+
95+
: `banner`
96+
97+
A line shown in the command-line's startup banner to identify the project. Must be a string. No banner is shown by default. This can be helpful to distinguish between multiple Verso projects or to inform co-authors about relevant project-specific information.
98+
99+
: `cors`
100+
101+
Whether to send permissive cross-origin headers and answer `OPTIONS` preflight requests. Must be a boolean. Defaults to `false`.
102+
103+
: `directory_listing`
104+
105+
Whether to generate an HTML listing for a directory that has no `index.html`. Must be a boolean. Defaults to `true`.
106+
107+
: `trailing_slash_redirect`
108+
109+
Whether a request for a directory without a trailing slash is redirected to add one. Must be a boolean. Defaults to `true`.
110+
111+
: `follow_symlinks_outside_root`
112+
113+
Whether a symbolic link may resolve to a target outside every mounted directory. Must be a boolean. Defaults to `false`.
114+
115+
: `[[mounts]]`
116+
117+
Configures a mount, serving a directory at a URL prefix. The mount with the longest matching prefix wins.
118+
119+
: `path`
120+
121+
The URL prefix to serve at. Must be a string that begins with `/`.
122+
123+
: `dir`
124+
125+
The directory served under the prefix. Must be a string, resolved relative to the configuration file.
126+
127+
: `[[redirects]]`
128+
129+
Configures a redirect. Redirects are matched by their path prefix in order. The first match wins.
130+
131+
: `from`
132+
133+
The path prefix to match. Must be a string that begins with `/`.
134+
135+
: `to`
136+
137+
The location to redirect to. Must be a string. The path beneath the matched prefix is appended to it.
138+
139+
: `status`
140+
141+
The redirect status code. Must be one of `301`, `302`, `303`, `307`, or `308`. Defaults to `301`.
142+
143+
: `[[headers]]`
144+
145+
Configures a header rule, adding response headers to requests whose path begins with its prefix.
146+
147+
: `path`
148+
149+
The path prefix the rule applies to. Must be a string that begins with `/`.
150+
151+
: `set`
152+
153+
A table mapping header names to the values to set on matching responses.
154+
155+
For example, this configuration serves a built site at `/` with the API reference mounted under `/api`, names the project in the banner, turns off directory listings, redirects an old entry point, and adds a header to everything:
156+
157+
```
158+
port = 4000
159+
banner = "ACME Docs"
160+
directory_listing = false
161+
162+
[[mounts]]
163+
path = "/"
164+
dir = "_out/html"
165+
166+
[[mounts]]
167+
path = "/api"
168+
dir = "_out/api"
169+
170+
[[redirects]]
171+
from = "/index.htm"
172+
to = "/"
173+
174+
[[headers]]
175+
path = "/"
176+
set = { "X-Frame-Options" = "DENY" }
177+
```
178+
179+
## Mounts
180+
%%%
181+
tag := "serve-mounts"
182+
%%%
183+
184+
A mount maps a URL prefix to a directory on disk.
185+
The mount whose prefix matches the most path segments wins, so more specific mounts override more general ones.
186+
187+
```
188+
[[mounts]]
189+
path = "/"
190+
dir = "_out/html"
191+
192+
[[mounts]]
193+
path = "/foo"
194+
dir = "../foo-output"
195+
196+
[[mounts]]
197+
path = "/foo/x"
198+
dir = "../special"
199+
```
200+
201+
With these mounts, `/foo/x/page.html` is served from `../special`, other paths under `/foo` from `../foo-output`, and everything else from `_out/html`.
202+
Two mounts may name the same directory.
203+
If a directory named by a mount does not exist, the server reports an error and exits rather than starting up in a broken state.
204+
205+
:::paragraph
206+
The directory served at `/` is chosen in this order:
207+
1. If a directory `DIR` is provided as a positional command-line parameter, then it is used.
208+
If the configuration file includes a mount for `/`, then it is replaced by `DIR`.
209+
2. If no `DIR` is provided, then the `/` mount from the configuration file is served.
210+
3. Otherwise, the current directory is served at `/`.
211+
:::
212+
213+
## Redirects
214+
%%%
215+
tag := "serve-redirects"
216+
%%%
217+
218+
Redirect rules are matched against the request path by prefix, in order, and the first match wins.
219+
The path beneath the matched prefix is appended to the target.
220+
221+
```
222+
[[redirects]]
223+
from = "/old"
224+
to = "/new"
225+
status = 301
226+
227+
[[redirects]]
228+
from = "/legacy"
229+
to = "/current"
230+
status = 302
231+
```
232+
233+
## Custom Headers
234+
%%%
235+
tag := "serve-headers"
236+
%%%
237+
238+
Header rules add response headers to requests whose path begins with a prefix.
239+
Later rules override earlier ones for the same header name.
240+
241+
```
242+
[[headers]]
243+
path = "/assets"
244+
set = { "Cache-Control" = "no-cache", "X-Frame-Options" = "DENY" }
245+
```
246+
247+
# Caching
248+
%%%
249+
tag := "serve-caching"
250+
%%%
251+
252+
Every response carries `Cache-Control: no-cache`, which directs the browser to revalidate before reusing a cached copy rather than to skip caching entirely.
253+
Each file response also carries an `ETag` derived from the file's contents and a `Last-Modified` time.
254+
255+
When a browser revalidates with `If-None-Match` or `If-Modified-Since` and the file is unchanged, the server replies `304 Not Modified` with no body.
256+
Because the `ETag` follows the contents, a rebuild that rewrites a file without changing it still will still be cheap to load.
257+
258+
# Other Behavior
259+
%%%
260+
tag := "serve-behavior"
261+
%%%
262+
263+
* A file's `Content-Type` comes from its extension.
264+
A file whose extension is unknown is served as `text/plain` when its contents are valid UTF-8 text, so text-like files open in the browser instead of downloading, and as `application/octet-stream` otherwise.
265+
* Directories are served by their `index.html` when one is present; otherwise a generated listing is shown unless listings are disabled, in which case the request is refused with `403 Forbidden`.
266+
* A request for a directory without a trailing slash is redirected to add one, so relative links resolve correctly. This can be turned off.
267+
* `Range` requests are supported, so media files can be sought and large files resumed.
268+
* Symbolic links are followed only while their target stays within a mounted directory. A link pointing outside every mount is refused, unless `follow_symlinks_outside_root` is set.
269+
* Only `GET` and `HEAD` are served. Other methods receive `405 Method Not Allowed`. With CORS enabled, `OPTIONS` preflight requests are answered.

lakefile.lean

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,16 @@ lean_exe «verso» where
7676
needs := #[ghSetupLiteratePages]
7777
supportInterpreter := true
7878

79+
@[default_target]
80+
lean_lib VersoServe where
81+
roots := #[`VersoServe]
82+
srcDir := "src/verso-serve"
83+
84+
@[default_target]
85+
lean_exe «verso-serve» where
86+
root := `VersoServeMain
87+
srcDir := "src/verso-serve"
88+
7989
@[default_target]
8090
lean_lib VersoLiterate where
8191
roots := #[`VersoLiterate]

0 commit comments

Comments
 (0)