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 -- see serializable-continuations-guide.md for that background.
For a full step-by-step walkthrough with a complete guestbook example, see web-continuations-tutorial.md.
send-form-and-wait PatternThe canonical idiom for a page in a multi-step flow:
(defn send-form-and-wait [render-fn : (-> cstr cstr)] : cstr
(serial-shift [k]
(def token (store-continuation k))
(def action (str "/submit?k=" token))
(def html (render-fn action))
(perform HttpEffect (send-html html))))
Parameters:
- render-fn -- a function that takes the form action URL and returns an HTML string
Returns: - The raw POST body that the browser submitted when the form was filled in
When to use it: - Any time the flow needs to pause, show a page to the user, and resume when the user submits a form - Works for GET-style "click to continue" pages too -- just render a form with no visible fields and a single submit button
When not to use it: - For pages that do not need to resume a continuation (e.g., a static about page or a 404 error) -- just send the HTML response directly
The continuation store (conts.tur) must satisfy:
;; Store k and return a token that can be used to resume it.
(store-continuation k) : cstr
;; Look up k by token. Returns None if missing or expired.
(load-continuation token) : (Option (serial-continuation cstr))
Tokens are 64 hex characters (256 bits of randomness). When HMAC signing is enabled (see web-continuations-tutorial.md Step 9), tokens have the form <64-hex-chars>.<64-hex-chars> (raw token + HMAC-SHA256 signature).
The tutorial uses file-per-token storage in data/conts/:
| Operation | Path | Notes |
|---|---|---|
store-continuation k |
Writes data/conts/<token>.bin |
Atomic write via temp-then-rename |
load-continuation token |
Reads data/conts/<token>.bin |
Returns None on missing file or schema mismatch |
The tutorial implementation does not evict tokens automatically. For a production deployment, add a background sweep that deletes .bin files older than the TTL:
(defn evict-expired-conts [] : unit
(def dir-entries (dir-list "data/conts/"))
(Vec.for-each dir-entries
(fn [entry]
(when (> (- (unix-now) (file-mtime entry)) CONT-TTL-SECONDS)
(file-delete entry)))))
Alternatively use the StoredCont struct (tutorial Step 9) to embed the creation timestamp inside the file itself, making expiry checks independent of filesystem mtime.
Any value captured inside a serial-reset boundary must implement Serializable. For custom structs, write a definstance:
;; Pattern: serialize as a Vec cstr (one field per element).
(definstance Serializable MyStruct
(serialize [s]
(serialize (Vec.of [s.field-a
s.field-b
(int64->cstr s.field-c)])))
(deserialize [b]
(match (deserialize b : (Result (Vec cstr) cstr))
(Err msg) -> (Err msg)
(Ok parts) ->
(if (< (Vec.len parts) 3)
(Err "MyStruct: not enough fields")
(Ok (MyStruct
:field-a (Vec.get parts 0)
:field-b (Vec.get parts 1)
:field-c (cstr->int64 (Vec.get parts 2))))))))
Rules:
- All fields must themselves implement Serializable
- Resource types (file handles, sockets) cannot be captured -- restructure to move them outside the serial-reset boundary
- The elaborator will produce a clear error if you accidentally capture a non-serializable value
The router maps POST /submit?k=TOKEN to serial-resume:
POST /submit?k=TOKEN
-> parse TOKEN from query string
-> verify HMAC signature (if signing enabled)
-> load continuation from store -> k
-> serial-resume k req.body
-> flow resumes where send-form-and-wait was called
-> eventually calls perform HttpEffect (send-html ...)
-> HTTP response sent
The flow never explicitly returns a response. All output goes through HttpEffect. The effect handler in the main loop sends the response and loops back to httpd-next-request.
| Method | Path | Handler |
|---|---|---|
| GET | / |
Start a new flow (run-guestbook-flow) |
| POST | /submit |
Resume a continuation (resume-handler) |
| GET | /entries |
List all entries (no continuation needed) |
| anything else | any | 404 |
A helper function that calls send-form-and-wait internally can be called from within any serial-reset boundary:
;; Sub-flow: collect a name and return it.
(defn collect-name [] : cstr
(def body (send-form-and-wait (fn [a] (render-name-form a))))
(or (parse-form-field body "name") "Anonymous"))
;; Main flow: call sub-flows in sequence.
(defn checkout-flow [req : HttpRequest] : unit
(serial-reset
(def name (collect-name))
(def address (collect-address))
(def card (collect-payment))
(finalize-order name address card)))
Each helper is just a function -- serial-shift works correctly across call boundaries because the entire continuation stack within the serial-reset region is serialized.
Do not nest serial-reset inside another serial-reset. The inner boundary creates a separate continuation scope, and serial-shift inside it captures only up to the inner boundary. Use a single serial-reset per logical flow.
If a continuation token is stored in one HTTP request and resumed in a completely different code path (e.g., an admin approval endpoint), the continuation still works -- the serialized bytes carry the full execution context independently of the handler that resumes them.
send/suspend| Concept | Racket | Turmeric |
|---|---|---|
| Boundary | (send/suspend proc) |
(serial-reset ...) |
| Pause and hand URL to renderer | send/suspend calls proc with the resume URL |
(serial-shift [k] ...) serializes k, builds the URL, calls render-fn |
| Resume URL token | Racket generates a URL using an in-memory store | store-continuation generates a 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-continuation cstr -- typed resume value |
| 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.
Deep-copy cost. Serialization performs a full deep copy of all values in the continuation. Large data structures captured inside serial-reset will produce large .bin files. Move bulky data outside the boundary (e.g., store it in the guestbook store and capture only an ID).
Token size. A typical serialized continuation for the guestbook flow is a few kilobytes. Deeply nested flows with large intermediate values can grow larger. Monitor file sizes in data/conts/ during development.
No sharing. If the same value is reachable via two different paths in a captured continuation (e.g., two bindings pointing to the same ref), 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 with per-request effect handlers.
Schema versioning. If you change the structure of a captured type after tokens have been written to disk, bytes->serial-cont will return Err (SchemaMismatch ...). Either drain all pending continuations before deploying, or implement a migration layer. See serializable-continuations-guide.md -- Error Handling.