A step-by-step guide to building a multi-page web form application in Turmeric using serializable continuations. Each step introduces one new concept. By the end you will have a working guestbook server that captures, stores, and resumes continuations across HTTP round-trips.
Prerequisites: Read serializable-continuations-guide.md first. You should understand
serial-reset,serial-shift, andserial-resumebefore starting.Related guides: web-continuations-guide.md (the compact reference), c-integration-guide.md, checkpointing-guide.md, developing-spices-guide.md (
build.turand:c-sources)The code: every snippet below is lifted from
examples/guestbook/, which builds and is driven end to end bytests/run-guestbook.sh. Where the shape is dictated by the capture grammar of serializable continuations (one frame, one env value, aninthole, uncolored leaves), the tutorial says so rather than pretending otherwise.
| Step | Title | Key Concepts |
|---|---|---|
| 0 | Project Layout | build.tur, one defmodule per file, :c-sources, build targets |
| 1 | Minimal HTTP Listener | The C shim, inline-C bindings, request accessors |
| 2 | Hello World Handler | Rendering HTML strings, routing, sending a response |
| 3 | Single-Page Form | Parsing form bodies, escaping, the templates |
| 4 | Introducing Continuations | serial-reset, serial-shift, serial-resume, the continuation store |
| 5 | Two-Page Flow | Name form -> message form: a leaf, a receiver, a page, advance |
| 6 | Three-Page Preview | Preview page, Back as a decision field on one continuation |
| 7 | Persisting the Store | The entries file, atomic append |
| 8 | Confirmation and Posting | Confirm, writing the entry, the thank-you page |
| 9 | Security Hardening | HMAC token signing, expiry, input sanitization, the smoke test |
HTTP is stateless. A web form spanning multiple pages is inherently stateful. The standard solutions all require the programmer to manually manage state:
| Approach | Mechanism | Drawbacks |
|---|---|---|
| Hidden form fields | Encode state in <input type="hidden"> |
Tedious, error-prone, exposed to tampering |
| Server-side sessions | Cookie holds session ID, server stores map | Memory leak risk, sticky sessions, scaling issues |
| URL query params | State embedded in URL | URL length limits, bookmarking problems |
| Explicit state machine | Each page is a named step in a DB table | Lots of boilerplate, business logic fragmented |
The continuation approach, popularized by PLT Scheme's web-server/servlet and Racket's send/suspend, threads the control-flow problem away entirely:
handler-1 runs to a "send this form, then resume" point
-> captures continuation k
-> stashes k under token T
-> sends HTML page with action="/?k=T"
browser submits form
-> server looks up T, loads k
-> resumes k with form data
-> k is now inside handler-1 again, with form data in hand
-> proceeds naturally to the next step
From the programmer's perspective, each page is "capture what happens when this form is posted, send the form". There is no session table and there are no hidden form fields: the state a later page needs rides inside the captured frame, on disk, under a token only the server can mint.
Racket's send/suspend API looks like this (for comparison):
(define (enter-name)
(send/suspend
(lambda (k-url)
(response/xexpr
`(html (body
(form ([action ,k-url])
(input ([name "name"]))
(input ([type "submit"] [value "Next"])))))))))
Turmeric's serial-shift plays the same role as send/suspend, with the difference that the continuation is serialized to bytes and stored on disk rather than living in a heap closure -- and that the captured region is one frame (the page's "submitted" call), not an arbitrary stack.
The guestbook lives at examples/guestbook/ and is a spice: a build.tur
manifest, one module per file under src/, and the C shim vendored through
:c-sources.
examples/guestbook/
build.tur -- the manifest: (defpackage guestbook ... :c-sources ["httpd.c"])
CMakeLists.txt -- optional CMake target (emit-c main.tur + httpd.c)
httpd.c -- tiny raw-socket HTTP listener (~230 lines)
src/
main.tur -- entry point: starts the listener, runs the serve loop
httpd.tur -- inline-C bindings over httpd.c
strutil.tur -- str+, slicing, form-field, the pointer casts for the hole
security.tur -- html-escape, HMAC-SHA256, sign/verify tokens
templates.tur -- HTML rendering for every page
store.tur -- the entries file: append / read
conts.tur -- the continuation store: token -> data/conts/<token>.bin
handlers.tur -- the pages: leaves, receivers, resets, advance
router.tur -- GET /, GET /entries, POST /submit?k=TOKEN
data/ -- created at runtime
entries.txt -- one line per entry
conts/ -- one file per continuation
```turmeric no-manifest-check ;; build.tur (defpackage guestbook :name "guestbook" :build-opts #map{ :c-sources ["httpd.c"] })
Because there is a manifest, every per-file command resolves the imports on
its own: `tur check src/router.tur` works, `tur run src/main.tur` links
`httpd.c`, and `tur build examples/guestbook` puts the binary in
`examples/guestbook/build/bin/guestbook`.
### Modules
Each file is a `defmodule` that imports what it uses by name:
```turmeric
(defmodule router
(import strutil :refer [form-field cstr-eq? cstr-as-int])
(import templates :refer [render-error])
(import conts :refer [load-continuation])
(import handlers :refer [start-flow advance list-entries])
(import httpd :refer [httpd/method httpd/path httpd/query httpd/body httpd/send!])
(export dispatch)
...)
tur run src/main.tur is the everyday build. The CMake target exists so the
example sits beside the others (cmake -DTUR_EXAMPLES=ON, then
cmake --build build --target guestbook); it runs tur emit-c src/main.tur
-- which emits every imported module into one C file -- and compiles that
with httpd.c. The root Justfile keeps its guestbook / run-guestbook
recipes.
Run it:
GUESTBOOK_SECRET="a long random string" PORT=8080 tur run examples/guestbook/src/main.tur
Rather than pulling in a third-party library, the tutorial ships a tiny
httpd.c that uses only POSIX sockets. Three functions are exported:
/* Bind a TCP socket and start listening on port. 0 on success, -1 on error. */
int httpd_start(int port);
/* Block until the next HTTP request arrives. Writes method, path, query and
* body into the out-params; the caller frees the four strings. */
void httpd_next_request(char **out_method, char **out_path,
char **out_query, char **out_body);
/* Send an HTTP/1.1 response and close the connection. */
void httpd_send_response(int status, const char *content_type, const char *body);
One thing the shim needs that is easy to miss: strdup and strncasecmp are
POSIX, not ISO C. Without _DEFAULT_SOURCE / _POSIX_C_SOURCE at the top of
the file the compiler sees no prototype under -std=c11, assumes int
strdup(), truncates the returned pointer, and the first request segfaults.
httpd.tur wraps the shim in inline-C. The four out-params are awkward to
carry across the boundary, so one function owns them as static storage and
the accessors read from it:
(defmodule httpd
(export httpd/start httpd/wait! httpd/method httpd/path httpd/query httpd/body
httpd/send!)
;;; httpd/request-slot -- static request storage (internal).
;;; op 0: accept + parse the next request (frees the previous one).
;;; op 1..4: method / path / query / body of the current request.
(defn httpd/request-slot [op : int] : cstr
```c
extern void httpd_next_request(char **m, char **p, char **q, char **b);
static char *method = NULL, *path = NULL, *query = NULL, *body = NULL;
switch (op) {
case 0:
free(method); free(path); free(query); free(body);
method = path = query = body = NULL;
httpd_next_request(&method, &path, &query, &body);
return "";
case 1: return method ? method : "";
case 2: return path ? path : "";
case 3: return query ? query : "";
case 4: return body ? body : "";
default: return "";
}
```)
(defn httpd/start [port : int] : int
```c
extern int httpd_start(int port);
return httpd_start((int)port);
```)
(defn httpd/wait! [] : int
(do (httpd/request-slot 0) 0))
(defn httpd/method [] : cstr (httpd/request-slot 1))
(defn httpd/path [] : cstr (httpd/request-slot 2))
(defn httpd/query [] : cstr (httpd/request-slot 3))
(defn httpd/body [] : cstr (httpd/request-slot 4))
(defn httpd/send! [status : int content-type : cstr body : cstr] : int
```c
extern void httpd_send_response(int status, const char *ct, const char *body);
httpd_send_response((int)status, content_type, body);
return status;
```))
The extern declarations inside the bodies are all the glue there is: the
symbols resolve because build.tur compiles and links httpd.c.
There is no request struct. A request is "whatever httpd/wait! last read",
and a response is a call to httpd/send! -- the shim owns the socket.
The smallest handler renders a string and sends it:
(defn send-html [html : cstr] : int
(httpd/send! 200 "text/html" html))
(defn list-entries [] : int
(send-html (render-entries (store-lines))))
An earlier draft of this example routed every response through an algebraic
effect (perform HttpEffect (send-html ...)) handled in the main loop. The
shipped example sends directly, because the pages of Step 4 run inside
serializable-continuation contexts, and a function that performs an effect
is colored for the CPS backend -- which the capture grammar does not admit
in a context callee. Direct sends keep every page function plain.
main.tur binds the port and loops: read a request, dispatch it, sweep
expired continuations.
(defn serve-loop [max : int] : int
(let [^mut served 0]
(do
(while (or (= max 0) (< served max))
(do
(httpd/wait!)
(dispatch)
(evict-expired-conts!)
(set! served (+ served 1))))
served)))
(defn main [] : int
(let [port (cstr->int-or (env-or "PORT" "8080") 8080)
max (cstr->int-or (env-or "GUESTBOOK_MAX_REQUESTS" "0") 0)]
(do
(store-init!)
(if (< (httpd/start port) 0)
(do (println (str+ "guestbook: failed to start HTTP listener on port " (int->cstr port)))
1)
(do
(println (str+ "guestbook: listening on http://127.0.0.1:" (int->cstr port) "/"))
(serve-loop max)
0)))))
GUESTBOOK_MAX_REQUESTS=N serves N requests and exits; that is how the
smoke test in Step 9 drives the server without having to kill it.
(defn dispatch [] : int
(let [get? (cstr-eq? (httpd/method) "GET")
post? (cstr-eq? (httpd/method) "POST")
path (httpd/path)]
(cond
(and get? (cstr-eq? path "/")) (start-flow)
(and get? (cstr-eq? path "/entries")) (list-entries)
(and post? (cstr-eq? path "/submit")) (resume-handler)
:else (send-error 404 "Page not found."))))
templates.tur builds every page from str+, a variadic concatenation
helper from strutil.tur, inside a shared shell:
(defn page-shell [title : cstr content : cstr] : cstr
(str+ "<!doctype html><html><head><meta charset=\"utf-8\"><title>" title
"</title>...</head><body><h1>" title "</h1>" content
"<p class=\"muted\"><a href=\"/\">Sign the guestbook</a> · "
"<a href=\"/entries\">Read the entries</a></p></body></html>"))
(defn render-name-form [action : cstr] : cstr
(page-shell "Guestbook"
(str+ "<form method=\"post\" action=\"" action "\">"
"<label>Your name <input name=\"name\" autofocus></label>"
"<button>Next</button></form>")))
action is the URL the form posts to. In Step 4 it will carry a
continuation token; for now imagine /submit.
A POST body is application/x-www-form-urlencoded: name=Ada+%3CL%3E&x=1.
form-field in strutil.tur finds a field and percent-decodes it,
returning an (Option cstr) built with the typed inline-C builders
(tur_some_ptr / tur_none):
(defn form-field [encoded : cstr name : cstr] : (Option cstr)
```c
... walk `&`-separated segments, match the key, decode %XX and '+' ...
return tur_some_ptr(raw); /* or tur_none() */
```)
;; handlers.tur
(defn field-or [body : cstr name : cstr dflt : cstr] : cstr
(match (form-field body name)
(Some v) v
(None) dflt))
Everything a visitor typed is escaped once, when it is read, and stays escaped for the rest of its life (frames, the entries file, the pages):
(html-escape "<b>") ; => "<b>" (& < > " escaped; newline -> <br>)
Page 1 wants to say: "send the name form; when it comes back, run
name-submitted with the posted body." That sentence is a serial-reset
whose rest is the call, with the shift in argument position:
(defn start-flow [] : int
(serial-reset
(name-submitted "" (serial-shift suspend-name-page 0))))
serial-shift captures the rest of the reset -- the pending call
(name-submitted "" <hole>) -- as a serial-cont and hands it to the
receiver suspend-name-page. The receiver does not resume it:
;;; action-for -- store k and return the form action URL that resumes it.
(defn action-for [k : serial-cont] : cstr
(str+ "/submit?k=" (store-continuation k)))
;;; suspend-name-page -- page 1's receiver.
(defn suspend-name-page [k : serial-cont] : int
(send-html (render-name-form (action-for k))))
The reset then returns the receiver's value and the request is over. The continuation lives on only as a file. When the browser posts the form, the router rebuilds it and resumes it with the body:
(defn resume-handler [] : int
(match (form-field (httpd/query) "k")
(None) (send-error 400 "Missing continuation token.")
(Some t)
(match (load-continuation t)
(Err m) (send-error 404 m)
(Ok k) (advance (serial-resume k (cstr-as-int (httpd/body)))))))
and name-submitted runs, with the body in the hole:
(defn name-submitted [ignored : cstr body-i : int] : int
(let [body (int-as-cstr body-i)]
(do
(set! flow-name (html-escape (field-or body "name" "Anonymous")))
(set! flow-message "")
(step-message))))
Three things the capture grammar dictates, stated once:
int. The body is passed as its address
(cstr-as-int) and read back with int-as-cstr. The bytes on disk hold
the frame, never the body -- it is only ever in the process that read it."" here; the name on page 2). It must be an int, a cstr, or a
Serializable value.name-submitted does not start the next page's
reset; it stashes what the next page needs and returns a step code.
advance, called by the router outside any reset, does the rest.conts.tur turns a serial-cont into a file and back:
(defn store-continuation [k : serial-cont] : cstr
(let [token (random-hex-64)
bytes (serial-cont->bytes k)]
(do
(cont-to-file bytes (cont-path token))
(bytes-release bytes)
(sign-token token (server-secret)))))
(defn load-continuation [signed : cstr] : (Result serial-cont cstr)
(match (verify-token signed (server-secret))
(None) (err "That link was not issued by this server.")
(Some token)
(let [path (cont-path token)
age (file-age-seconds path)]
(if (< age 0)
(err "Unknown or already used link. Please start over.")
(if (> age (cont-ttl-seconds))
(err "That link has expired. Please start over.")
(bytes->serial-cont (cont-from-file path)))))))
serial-cont->bytes marshals the frame by its stable name plus its
environment; bytes->serial-cont validates every frame against this
program's registry before rebuilding, so a file written by an older build
comes back as an Err, which the router shows as a 404 page.
Page 2 is the same three pieces again -- a page, a receiver, a leaf -- plus
one arm in advance. What differs is that the frame now carries state:
;; What the next page needs, handed from a resumed leaf to `advance`.
(def ^mut flow-name : cstr "")
(def ^mut flow-message : cstr "")
(defn page-message [] : int
(serial-reset
(message-submitted flow-name (serial-shift suspend-message-page 0))))
(defn suspend-message-page [k : serial-cont] : int
(send-html (render-message-form (action-for k) flow-name flow-message)))
(defn message-submitted [name : cstr body-i : int] : int
(let [body (int-as-cstr body-i)]
(do
(set! flow-name name)
(set! flow-message (html-escape (field-or body "message" "")))
(step-preview))))
(defn advance [step : int] : int
(cond
(= step (step-message)) (page-message)
(= step (step-preview)) (page-preview)
(= step (step-thankyou)) (send-html (render-thankyou (store-lines)))
:else 0))
page-message captures the value of flow-name at capture time as the
frame's env; when the message form is posted -- minutes later, after other
visitors' requests, after a server restart -- message-submitted receives
that name as its first argument. The globals are only a hand-off between a
leaf and advance within one request.
GET / start-flow: capture (name-submitted "" _), write k1 to
data/conts/<t1>.bin, send name form (action=/submit?k=t1.sig)
POST /submit?k=t1.sig load k1, resume with "name=Ada": name-submitted -> step-message
advance -> page-message: capture (message-submitted "Ada" _),
write k2, send message form (action=/submit?k=t2.sig)
POST /submit?k=t2.sig load k2, resume: message-submitted "Ada" "message=..." -> step-preview
advance -> page-preview ...
Each POST resumes exactly the page that produced its form, with exactly
that page's state, from a file.
The preview shows the entry with Confirm and Back. Both are submit
buttons in one form posting one continuation; the pressed button's
decision value says which:
(defn render-preview [action : cstr name : cstr message : cstr] : cstr
(page-shell "Preview"
(str+ "<blockquote><p>" message "</p><footer>— " name "</footer></blockquote>"
"<form method=\"post\" action=\"" action "\">"
"<button name=\"decision\" value=\"confirm\">Confirm</button> "
"<button name=\"decision\" value=\"back\">Back</button></form>")))
The preview's frame needs both the name and the message, and a frame carries
one env value -- so they travel as one tab-separated cstr:
(defn pack-state [name : cstr message : cstr] : cstr
(str+ name "\t" message))
(defn page-preview [] : int
(let [state (pack-state flow-name flow-message)]
(serial-reset
(preview-decided state (serial-shift suspend-preview-page 0)))))
preview-decided splits the state and reads the decision. Back returns
step-message with the message left in flow-message, so advance shows
the message form again, prefilled; Confirm goes on to Step 8.
(defn preview-decided [state : cstr body-i : int] : int
(let [body (int-as-cstr body-i)
tab (cstr-index-of state "\t" 0)
name (cstr-slice state 0 tab)
message (cstr-slice state (+ tab 1) (cstr-len state))
decision (field-or body "decision" "confirm")]
(do
(set! flow-name name)
(set! flow-message message)
(if (cstr-eq? decision "back")
(step-message)
(do
(store-append! name message)
(step-thankyou))))))
The browser's own Back button works too: an earlier page's form still names
an unexpired token, and posting it resumes that page again -- the same
mechanism Racket's send/suspend gets for free.
The entries file is one line per entry, posted-at<TAB>name<TAB>message.
Name and message were HTML-escaped when read (which also removed tabs and
newlines), so the format needs no quoting:
(defn store-append! [name : cstr message : cstr] : int
```c
#include <time.h>
FILE *old = fopen("data/entries.txt", "rb");
FILE *tmp = fopen("data/entries.tmp", "wb");
... copy old into tmp, append the new line ...
fprintf(tmp, "%lld\t%s\t%s\n", (long long)time(NULL), name, message);
fclose(tmp);
return rename("data/entries.tmp", "data/entries.txt") == 0 ? 1 : 0;
```)
(defn store-lines [] : cstr ;; the whole file, or "" when there is none yet
...)
Writes go through a temp file and rename, so a crash mid-write leaves the
previous file intact. render-entries / render-thankyou walk the lines
with cstr-index-of / cstr-slice and emit one <li> per entry.
The store needs no Serializable instance because nothing in it is
captured: pages capture the escaped strings, and the store is read fresh on
every render.
Confirm is the else branch of preview-decided above: append the entry,
return step-thankyou. The thank-you page is rendered by advance, not by
the leaf -- the templates go through str+, and a leaf that called into them
would be colored and rejected as a context callee. (str+ is a plain
inline-C cons walk with no unsafe block precisely so the receivers may
call the templates; they are context callees too.)
(defn render-thankyou [lines : cstr] : cstr
(page-shell "Thank you!"
(str+ "<p>Your entry has been added.</p><ul>" (entry-rows lines) "</ul>")))
A raw token names a file under data/conts/. Signing it means a client
cannot probe the store by guessing names: the router refuses anything whose
signature does not verify before it touches the filesystem.
(defn sign-token [token : cstr secret : cstr] : cstr
(str+ token "." (hmac-sha256-hex secret token)))
(defn verify-token [signed : cstr secret : cstr] : (Option cstr)
(let [dot (cstr-index-of signed "." 0)]
(if (< dot 0)
(none)
(let [token (cstr-slice signed 0 dot)
sig (cstr-slice signed (+ dot 1) (cstr-len signed))]
(if (cstr-eq-ct sig (hmac-sha256-hex secret token))
(some token)
(none))))))
hmac-sha256-hex is a self-contained inline-C SHA-256 (the same core as
stdlib/image.tur's build stamp) with the ipad/opad construction;
cstr-eq-ct compares in constant time. The key is GUESTBOOK_SECRET; the
default dev-insecure-secret is for local testing only.
load-continuation refuses a file older than cont-ttl-seconds (30
minutes) by its mtime, and the serve loop calls evict-expired-conts!
after every request to delete them.
html-escape at the moment of reading (name-submitted,
message-submitted) -- the only place raw visitor text exists.form-field percent-decodes; a field that is absent is (None), never
an empty pointer.tests/run-guestbook.sh (ctest tur_guestbook_smoke) is the proof that all
of this works through real HTTP. It builds the example with tur run, starts
it with GUESTBOOK_MAX_REQUESTS=9 on a private port, and uses curl to:
fetch page 1, post a name, post a message, press Back, post a new message,
Confirm, post a tampered token (refused), and read /entries -- every hop
resuming a continuation rebuilt from a file the previous response named.
PASS page 1 is the name form
PASS page 1 action carries a signed token
PASS page 2 greets the escaped name
PASS page 3 previews the message
PASS page 3 previews the name
PASS Back returns to the message form, prefilled
PASS Confirm shows the thank-you page with the entry
PASS a tampered token is refused
PASS /entries lists the posted entry
PASS /entries does not list the abandoned draft
guestbook: 10 passed, 0 failed
advance arm.Serializable
instance (serializable-continuations-guide.md) and pass it as the frame
env.store-continuation / load-continuation are the whole
contract (web-continuations-guide.md, "Continuation Store Contract").