A compact reference for building multi-page web applications in Turmeric using serializable continuations. This guide assumes you understand serial-reset / serial-shift / serial-resume and the capture grammar -- see serializable-continuations-guide.md for that background.
For a full step-by-step walkthrough with the complete guestbook example (examples/guestbook/), see web-continuations-tutorial.md.
The unit of a flow is a page: a serial-reset whose rest is a call to that
page's submitted function with the hole in argument position, and a named
receiver that stores the continuation and sends the form.
;; The page: capture "what happens when the message form is posted".
(defn page-message [] : int
(serial-reset
(message-submitted flow-name (serial-shift suspend-message-page 0))))
;; The receiver: store k under a signed token, send the form whose action
;; carries the token. It returns WITHOUT resuming k -- the request is over.
(defn suspend-message-page [k : serial-cont] : int
(send-html (render-message-form (action-for k) flow-name flow-message)))
;; The leaf: runs when the browser posts. `name` is the frame's env (what
;; page-message captured), `body-i` is the POST body arriving through the hole.
(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))))
Three rules of the capture grammar shape this:
name above, an int or a
cstr, or a value with a Serializable instance). Pack more than one into
one cstr (the preview page carries name<TAB>message).int. The POST body travels as its address
(cstr-as-int / int-as-cstr); both ends are the same process, and the
bytes on disk hold the frame, not the body.serial-reset of their
own, no effects, no unsafe block, no call through a function value --
transitively. So a leaf does not start the next page; it returns a step
code, and advance, called by the router outside every reset, starts the
next page's reset (or renders a terminal page):(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))
When to use it: any time the flow needs to pause, show a page, and resume when the user submits a form -- "click to continue" pages included (a form with one submit button).
When not to use it: for pages that resume nothing (a static about page, a 404) -- send the HTML directly.
The continuation store (conts.tur) satisfies:
;; Store k and return the signed token that resumes it.
(store-continuation k) : cstr
;; Rebuild the continuation a signed token names.
(load-continuation signed) : (Result serial-cont cstr)
;; Delete continuation files older than the TTL; returns how many.
(evict-expired-conts!) : int
<64 hex chars>.<64 hex chars>: 256 bits of randomness (the file name)
followed by the HMAC-SHA256 of it under GUESTBOOK_SECRET. verify-token
compares signatures in constant time; a token the server did not issue never
reaches the filesystem.
File-per-token storage in data/conts/:
| Operation | Path | Notes |
|---|---|---|
store-continuation k |
Writes data/conts/<token>.bin |
serial-cont->bytes framed by cont-to-file |
load-continuation signed |
Reads data/conts/<token>.bin |
Err for a bad signature, a missing file, a file older than the TTL, or bytes bytes->serial-cont cannot rebuild |
The main loop calls evict-expired-conts! after every request; it removes
files whose mtime is older than the TTL (30 minutes). load-continuation
applies the same age check, so a token that outlived the sweep is still
refused.
A frame's environment marshals by value: an int inline, a cstr as its
bytes, anything else through its Serializable instance. The guestbook keeps
every captured value a cstr (already HTML-escaped) so no instance is needed;
a flow that captures a struct writes one (serializable-continuations-guide.md,
"The Serializable Typeclass"). Resource types (file handles, sockets) cannot
be captured -- the listener lives outside every reset, in httpd.tur's static
storage.
The router maps POST /submit?k=TOKEN to serial-resume:
POST /submit?k=TOKEN
-> parse TOKEN from the query string (form-field (httpd/query) "k")
-> verify the signature, age and bytes (load-continuation t)
-> resume with the POST body (serial-resume k (cstr-as-int (httpd/body)))
-> the page's leaf runs, returns a step code
-> advance starts the next page's reset (advance step)
-> its receiver sends the next form (or the thank-you page is sent)
Every response is sent by whoever produces it (httpd/send!); a request's
response is exactly one send.
| Method | Path | Handler |
|---|---|---|
| GET | / |
Start a new flow (start-flow, page 1) |
| POST | /submit |
Resume a continuation (resume-handler, then advance) |
| GET | /entries |
List all entries (no continuation needed) |
| anything else | any | 404 |
Pages compose through step codes, not through nesting: a leaf returns the
code of the page that should follow, and advance is the one place that
knows the order. Adding a page is one leaf, one receiver, one page function,
one cond arm.
The preview page's form has two submit buttons posting the same
continuation, distinguished by a decision field. preview-decided reads
it: back stashes name and message and returns step-message (the message
form comes back prefilled), confirm appends the entry and returns
step-thankyou. One token per page; the browser's own Back button reaching an
older form works the same way, since an unexpired token can be posted again.
Do not nest serial-reset inside a reset context, and do not call a function
that contains one from a leaf: the context collector rejects the capture
(TUR-E0706). One reset per page, started from outside.
A token stored by one request can be resumed by any code path that can read
the file -- an admin approval endpoint, a CLI -- as long as it is the same
program: frames are marshalled by name and bytes->serial-cont validates
every name against the running program's registry.
send/suspend| Concept | Racket | Turmeric |
|---|---|---|
| Boundary | (send/suspend proc) |
(serial-reset ...) per page |
| Pause and hand URL to renderer | send/suspend calls proc with the resume URL |
(serial-shift receiver 0) hands k to the named receiver, which stores it and renders the form with /submit?k=<token> |
| Resume URL token | Racket generates a URL using an in-memory store | store-continuation generates a signed hex token backed by files |
| Resume | Browser follows URL -> Racket resumes heap closure | Browser POSTs token -> router calls serial-resume k body |
| Persistence | Continuations lost on server restart (default) | Continuations persist across restarts (files on disk) |
| Type safety | Dynamic | k : serial-cont -- an opaque, typed handle; bytes->serial-cont validates before resuming |
| Back navigation | Re-using an earlier URL | Re-using an earlier token (same mechanism) |
No true call/cc semantics. serial-shift is a delimited shift -- it captures only up to the enclosing serial-reset. You cannot capture the continuation of the entire program.
One frame, one env, one int hole. The capture grammar is a single-scalar-hole chain; a page's state is one cstr (or Serializable value) and the resume value is an int. Pack state, and pass bulky data by reference (an id into the store), not by value.
Uncolored leaves and receivers. Anything the reset context calls must stay out of the CPS backend's colored set; the page-transition work that needs colored code (templates that concatenate through str+, effects) happens in advance.
Token size. A guestbook continuation is one frame: a few hundred bytes. Monitor data/conts/ if a flow captures larger values.
No sharing. If the same value is reachable via two different paths in a captured continuation, serialization produces two independent copies. On resume, mutations to one copy do not affect the other.
Single-threaded listener. The tutorial HTTP listener handles one request at a time. Two simultaneous form submissions are serialized at the socket level. For concurrent flows, run multiple server processes or switch to a multi-threaded listener.
Schema versioning. If you change the code between writing a token and resuming it, the frame names in the stored bytes may no longer exist in the running program; bytes->serial-cont then returns Err "bytes->serial-cont: unknown frame (written by a different program?)" rather than resuming garbage, and the router shows it as a 404 page. Either drain all pending continuations before deploying, or keep a version beside each token and reject stale ones up front. See serializable-continuations-guide.md -- Error Handling.