Safsaf


Overview

Safsaf is a web framework for Guile Scheme, built on Guile Fibers using the Guile Knots web server.

Table of Contents


1 Guidance

This chapter explains how the pieces of Safsaf fit together. Each section covers one concept with a short code example. For the full list of parameters and options, see API.


1.1 Getting Started

A Safsaf application is a list of routes passed to run-safsaf. Each route binds an HTTP method and URL pattern to a handler procedure. The handler receives a Guile <request> and a body port, and returns two values: a response and a body.

(use-modules (safsaf)
             (safsaf router)
             (safsaf response-helpers))

(define (index-page request body-port)
  (html-response '(h1 "Welcome")))

(define (hello-page request body-port)
  (let ((name (assoc-ref (current-route-params) 'name)))
    (text-response (string-append "Hello, " name "!"))))

(define routes
  (list
   (route 'GET '() index-page
          #:name 'index)
   (route 'GET '("hello" name) hello-page)
   (route '* '* (lambda (request body-port)
                  (not-found-response)))))

(run-safsaf routes #:port 8080)

The last route must be a catch-all ('* method, '* pattern), so that every request is handled; a route table without one is rejected at startup. run-safsaf sets up a Fibers scheduler, starts the HTTP server, and blocks until SIGINT or SIGTERM. See Running the Server, for stopping it cleanly and for the other options it takes.

The three modules used above are documented in full in the API chapter: (safsaf) for run-safsaf, (safsaf router) for route and the rest of the routing vocabulary, and (safsaf response-helpers) for the response constructors.


1.2 Routing

Patterns

Route patterns are lists of segments. A string matches literally, a symbol captures that segment into current-route-params, and a two-element list (predicate name) captures only when predicate returns true. A dotted tail captures the remaining path.

;; Literal:          /about
(route 'GET '("about") about-handler)

;; Capture:          /users/:id
(route 'GET '("users" id) show-user)

;; Predicate:        /posts/:id where id is numeric
(route 'GET `("posts" (,string->number id)) show-post)

;; Wildcard (rest):  /files/* — captures remaining segments
(route 'GET '("files" . path) serve-file)

Route groups

route-group nests routes under a shared prefix:

(route-group '("api" "v1")
  (route 'GET '("users") api-list-users)
  (route 'GET '("users" id) api-show-user))

This matches /api/v1/users and /api/v1/users/:id.

Named routes and path-for

Give a route a #:name and use path-for to generate its URL, so paths are never hard-coded. The first argument is always a route group:

(define my-routes
  (route-group '()
   (route 'GET '("posts" id) show-post #:name 'show-post)))

(define all-routes
  (list my-routes
        (route '* '* (lambda (r b) (not-found-response)))))

;; In a handler or template:
(path-for my-routes 'show-post '((id . "42")))
;; => "/posts/42"

path-for also accepts #:query and #:fragment keyword arguments.

path-for reads the compiled route table from a parameter that run-safsaf binds for each request. It therefore works inside a handler and anything a handler calls, such as a template; called outside a request it raises.

See (safsaf router), for route, route-group, path-for and wrap-routes in full.


1.3 HTTP Compliance

Safsaf dispatches every request through the route table, with no special cases in the dispatcher. Most frameworks build three pieces of HTTP behaviour into that dispatcher: answering HEAD requests, answering OPTIONS, and answering 405 Method Not Allowed for a path served by another method. Safsaf adds them to the route table instead, using the procedures in (safsaf http-compliance).

1.3.1 Adding the generated routes

add-generated-http-compliance-routes applies all three, and is how most applications will want to use this module. It takes a route table ending in the catch-all and returns it with the generated routes inserted before that catch-all:

(use-modules (safsaf http-compliance))

(define all-routes
  (wrap-routes
   (add-generated-http-compliance-routes
    (list (route 'GET '("posts") list-posts)
          (route 'POST '("posts") create-post)
          (route '* '* (lambda (r b) (not-found-response)))))
   logging-handler-wrapper))

The generated routes are ordinary routes, so handler wrappers apply to them as they do to the rest of the table. That is why the call sits inside wrap-routes above. The other way round leaves the 405 responses unlogged, unmeasured, and missing whatever headers the wrappers add. (The generated HEAD routes reuse the GET handlers as they stand, so those keep the wrappers either way.)

Pass #:head?, #:options? or #:method-not-allowed? as #f to leave out that set of generated routes, and #:options-handler or #:method-not-allowed-handler to control the corresponding response.

1.3.2 Automatic HEAD handling

add-head-routes generates a HEAD route for every path that has a route for GET but none for HEAD. Each one runs the GET handler, and the web server drops the body from the response, so a HEAD request returns the header fields its GET would have returned, including Content-Length, with no body.

Paths already served for HEAD are left alone, whether by an explicit HEAD route, a multi-method route like '(GET HEAD), or a route matching any method.

1.3.3 OPTIONS

add-options-routes generates an OPTIONS route for every path that has no route matching OPTIONS. The response is a 204 whose Allow header lists the methods that path allows. Without it, an OPTIONS request to a known path is answered 405 by the routes add-method-not-allowed-routes generates, or 404 by the catch-all where there are none.

Paths already served for OPTIONS are left alone, whether by an explicit OPTIONS route, a multi-method route like '(GET OPTIONS), or a route matching any method.

The second reason to generate these routes is the CORS preflight. cors-handler-wrapper answers the preflight inside the handler it wraps (see Security), so the preflight has to be routed there — and a table written as (route 'GET …) and (route 'POST …) routes no OPTIONS at all. Generating these routes inside the same wrap-routes as the CORS-wrapped group is what routes it:

(define api-routes
  (wrap-routes
   (add-generated-http-compliance-routes
    (list (route-group '("api")
            (route 'GET '("items") api-list-items))))
   (make-cors-handler-wrapper
    #:origins '("https://app.example.com"))))

The preflight now matches the generated OPTIONS route, reaches the wrapper, and comes back with the CORS headers. A request carrying no Origin is not a preflight, so the wrapper passes it through to the generated handler and it gets the Allow response instead.

Pass #:handler to control that response. It takes the same arguments as the 405 handler below.

1.3.4 Method Not Allowed

A request whose path matches a route but whose method does not falls through to the catch-all, which usually means a 404. add-method-not-allowed-routes answers those requests with 405 instead. For each distinct path in the table it generates a route matching any method. Such a route is only ever reached by a request that no real route matched. A DELETE /posts then gets a 405 whose Allow header lists GET, HEAD and POST.

Paths a route matching any method covers are left alone: that route comes before the generated ones, so a 405 route for its path could never match.

The Allow header lists the methods of every route whose path matches the request, so it reflects the route table as it stands — including HEAD for the paths add-head-routes covered.

Pass #:handler to control the response. It is a procedure of (request body-port allowed-methods) returning a response and a body, like any other handler, with the methods the path does allow:

(add-method-not-allowed-routes
 routes
 #:handler (lambda (request body-port allowed-methods)
             (values (build-response
                      #:code 405
                      #:headers `((allow . ,allowed-methods)))
                     "Nope")))

1.3.5 Applying the three separately

The three underlying procedures, add-head-routes, add-options-routes and add-method-not-allowed-routes, can be applied directly, but only in that order. Each has to see what the one before it generated, so that the methods those routes serve are counted among the ones their paths allow: add-options-routes lists HEAD, and add-method-not-allowed-routes lists both HEAD and OPTIONS.

The routes add-method-not-allowed-routes generates match every method, so applying it first would leave the other two nothing to generate. Doing so raises an error, rather than generating nothing and saying nothing. So does applying add-options-routes twice. Putting add-head-routes after add-options-routes is not caught: the HEAD routes are still generated, but the OPTIONS responses leave HEAD out of their Allow header.

1.3.6 Generated routes for part of a table

The calls above cover a whole route table, ending in its catch-all. They can also cover part of one. A route table that does not end in a catch-all takes the generated routes at its end, rather than before one. You do not have to declare which of the two you have. A route matching every method and every path answers every request that reaches it, so the generated routes go before such a route where there is one, and last where there is not. Only a catch-all in final position counts as one; a catch-all anywhere else in the list is an error.

Use this for a part of the table that is wrapped differently from the rest, so that its generated routes sit inside those wrappers too. A 405 for a path behind authentication, say, should be as invisible to a visitor without access as the page itself is:

(define admin-routes
  (wrap-routes
   (add-generated-http-compliance-routes
    (list (route 'GET '("admin" "accounts") list-accounts)
          (route 'POST '("admin" "accounts") create-account)))
   (make-auth-handler-wrapper db-pool)))

(define all-routes
  (wrap-routes
   (add-generated-http-compliance-routes
    (list (route 'GET '("posts") list-posts)
          admin-routes
          (route '* '* (lambda (r b) (not-found-response)))))
   logging-handler-wrapper))

The outer call walks over admin-routes like any other part of the table. It finds those paths answered already, so it generates nothing for them. Applying the call twice to the same table still raises, as before.

An inner table must serve its paths by itself: every route for a path it covers has to be inside it. Two things follow from that. A route for one of those paths placed later in the enclosing table is never reached, because the generated 405 routes match every method. That route’s method is also left out of the Allow header, which is worked out from the inner table alone.

For the same reason, keep all three sets of generated routes in an inner table. An inner table generated with #:head? #f or #:options? #f does not hand that work to the outer call: its own 405 routes match every method, so the outer call sees those paths as served for HEAD and OPTIONS already and generates nothing, and a HEAD or OPTIONS request to them is answered 405. Leaving out the 405 routes instead, with #:method-not-allowed? #f, does pass that work outward — and those routes are then outside the wrappers you wanted them inside.

See (safsaf http-compliance), for the procedures and their keyword arguments.


1.4 Handler Wrappers

A handler wrapper is a procedure that takes a handler and returns a new handler. It can transform the request on the way in and the response on the way out. Apply wrappers to a route table with wrap-routes.

(wrap-routes routes
  (make-exceptions-handler-wrapper #:dev? #t)
  logging-handler-wrapper)

When multiple wrappers are given, the first wraps outermost — it runs first on the request and last on the response. In the example above, exceptions catches errors from the logging wrapper and the inner handler.

Every wrapper that takes options also has a make- constructor which takes those options and returns the wrapper, so a configured wrapper goes in the wrap-routes list beside an unconfigured one. The wrapper itself remains exported for the case where the defaults will do, as logging-handler-wrapper is used above.

Per-group wrappers

Apply wrappers to part of the route table by wrapping a group separately:

(define api-routes
  (wrap-routes
   (add-generated-http-compliance-routes
    ;; So that the CORS preflight reaches the wrapper.
    (list (route-group '("api")
            (route 'GET '("items") api-list-items))))
   cors-handler-wrapper))

(define all-routes
  (wrap-routes
   (list api-routes
         (route 'GET '() index-page)
         (route '* '* (lambda (r b) (not-found-response))))
   logging-handler-wrapper))

Here the CORS headers are added only to /api/* routes, while logging applies to everything (see Security, for what those headers do).

Max body size

make-max-body-size-handler-wrapper checks the Content-Length header and rejects requests that exceed the limit with a 413 response. However, it does not limit chunked transfer-encoded requests that lack Content-Length. For untrusted networks, use a reverse proxy (e.g. Nginx’s client_max_body_size) to enforce size limits at the transport level.

wrap-routes is documented in (safsaf router). Each wrapper has a module of its own under (safsaf handler-wrappers …), listed in API, and the options of the ones met here are in (safsaf handler-wrappers max-body-size), (safsaf handler-wrappers exceptions) and (safsaf handler-wrappers logging).


1.5 Responses

Safsaf provides helpers that return (values response body) directly:

;; HTML — streams an SHTML tree
(html-response '(div (h1 "Hello") (p "world")))

;; JSON — takes an alist, vector or other Scheme value, or a
;; pre-rendered JSON string
(json-response '(("ok" . #t)))

;; Plain text
(text-response "pong")

;; Redirect (default 303 See Other)
(redirect-response "/login")
(redirect-response "/new-item" #:code 302)

;; Error responses
(not-found-response)
(text-response "Missing field" #:code 400)

html-response, json-response, text-response, and redirect-response accept #:code and #:headers for overrides. not-found-response accepts #:headers but has a fixed status code; for any other error status, pass #:code to text-response or html-response as above.

An HTML page is written as SHTML: a Scheme list whose first element is the element name, followed by an optional attribute list (@ (name "value") …) and then the contents. So (p (@ (class "lead")) "Hello") is <p class="lead">Hello</p>. Text is escaped when it is written out. The format comes from the (htmlprag) module of Guile-Lib, which documents it in full; Templating covers the parts Safsaf adds.

For content negotiation, use negotiate-content-type:

(define (show-item request body-port)
  (let* ((id (strip-path-extension
              (assoc-ref (current-route-params) 'id)))
         (item (fetch-item id)))
    (case (negotiate-content-type request
                                  '(text/html application/json))
      ((application/json)
       (json-response (item->alist item)))
      (else
       (html-response `(div (h1 ,(item-title item))))))))

The router matches raw path segments and knows nothing about these extensions, so a route has to be written with them in mind. A route ending in a capture matches /items/42.json and binds "42.json", which is why the example calls strip-path-extension: it removes everything from the last dot onwards. A dot at the start of the segment does not count, so a name like ".hidden" is returned unchanged. Apply it only to segments you know to be identifiers. On a captured file name it would strip an extension that belongs to the name.

A route ending in a literal does not match at all: /state.json never reaches a route patterned '("state"), and falls through to the catch-all. To offer both, give the route a pattern that carries the extension:

(route 'GET '("state") show-state)
(route 'GET '("state.json") show-state)

1.5.1 Caches and the Vary header

A URL that negotiates returns different bodies to different requests. A cache normally stores one response per URL, so it will return the HTML page to a request that asked for JSON, or the other way round. Say a script fetches the JSON of a page, and the reader then navigates to the page itself: the cache has the JSON stored under that URL, and serves it.

The Vary header prevents this. It names the request header fields the response depended on, and a cache then stores one response per combination of them rather than one per URL.

vary-handler-wrapper writes the header for you:

(wrap-routes routes
  vary-handler-wrapper
  (make-exceptions-handler-wrapper)
  ...)

negotiate-content-type records that it consulted the Accept header, and the wrapper writes the header on the way out. Code that reads a request header itself should record it with vary-on!:

(define (save-data? request)
  (vary-on! 'save-data)
  (equal? "on" (assq-ref (request-headers request) 'save-data)))

make-locale-handler-wrapper records what it consulted too, so applying vary-handler-wrapper outside it is what keeps a shared cache from serving one reader’s language to somebody else (see Internationalisation). cors-handler-wrapper records its dependency on Origin as well, but does not rely on this wrapper being there: it writes that field itself, and what is recorded merely joins whatever else the request consulted when this wrapper is applied (see Security).

A wrapper writes the header, rather than each handler doing it, and two things follow from that.

The first is that a response which recorded nothing gets no header, and this is worth the trouble. Accept strings differ between browsers, between versions of one browser, and between a navigation and a fetch. A URL carrying Vary: Accept is therefore stored once per distinct string, so adding the header everywhere costs a site most of its cache hits, and nothing reports the loss. For the same reason negotiate-content-type records nothing in two cases: when a path extension settled the choice, since /items/42.json and /items/42 are already separate cache entries, and when only one type is supported and the answer cannot vary.

The second is that the header reaches the responses a handler would have forgotten. Every response from a URL that negotiates needs it, including the 404s and the 304s from #:etag. One response cached without the header is served to every later request for that URL, whatever it asked for. Put the wrapper outside the exception handler wrapper, so that error responses pass through it as well.

Where no wrapper will see the response, vary-header builds the header directly:

(json-response body #:headers (list (vary-header 'accept)))

vary-handler-wrapper also takes #:always, a list of header names to add to every response, for a dependency that no per-request code observes.

The response constructors, negotiate-content-type, vary-on! and vary-header are in (safsaf response-helpers), and vary-handler-wrapper in (safsaf handler-wrappers vary).


1.6 Request Parsing

Form bodies

parse-form-body reads a URL-encoded POST body and returns an alist of string pairs:

(define (handle-login request body-port)
  (let* ((form (parse-form-body request body-port))
         (username (assoc-ref form "username"))
         (password (assoc-ref form "password")))
    (if (valid-credentials? username password)
        (redirect-response "/dashboard")
        (text-response "Invalid login" #:code 401))))

Query strings

parse-query-string extracts query parameters from the request URL:

(let ((qs (parse-query-string request)))
  (assoc-ref qs "page"))  ;; => "2" or #f

Multipart

For file uploads, use parse-multipart-body. It returns a list of <part> records, one per field of the form. multipart-text-fields picks out the ordinary text fields as an alist, leaving the uploaded files.

The <part> record itself comes from (webutils multipart), along with the procedures for reading one, so use that module as well:

(use-modules (safsaf utils)
             (webutils multipart))   ; parts-ref, part-body

(let* ((parts (parse-multipart-body request body-port))
       (form  (multipart-text-fields parts))   ; alist of text fields
       (file  (parts-ref parts "avatar")))     ; a <part>, or #f
  (when file
    (store-avatar! (assoc-ref form "username")
                   (part-body file))))

part-body returns a port to read the field’s content from, so a large upload need not be held in memory. For a field you know was submitted and holds text, parts-ref-string reads one straight to a string; unlike parts-ref it raises when the field is missing. part-content-disposition-params carries the filename the browser sent.

parts-ref returns #f for a field the form did not submit. Check for that before reading the part, as above.

Cookies

Read cookies with request-cookie-ref or request-cookies. Set them via response headers with set-cookie-header and delete-cookie-header:

(request-cookie-ref request "theme")  ;; => "dark" or #f

(text-response "ok"
  #:headers (list (set-cookie-header "theme" "dark"
                                     #:path "/"
                                     #:http-only #t)))

The parsing procedures are in (safsaf utils), and the cookie header constructors in (safsaf response-helpers).


1.7 Parameter Parsing

parse-params validates and transforms raw form or query data according to a declarative spec. Each spec entry names a parameter, a processor (a procedure that converts a string or returns an <invalid-param>), and options like #:required or #:default.

(let ((params (parse-params
                `((page    ,as-integer #:default 1)
                  (per-page ,as-integer #:default 20)
                  (q       ,as-string))
                (parse-query-string request))))
  (assq-ref params 'page))    ;; => 1 (integer, not string)

Built-in processors: as-string, as-integer, as-number, as-checkbox, as-one-of, as-matching, as-predicate.

Form params with CSRF

For POST forms, use parse-form-params instead. It checks the form’s CSRF token before parsing anything else, so a submission with a missing or wrong token comes back invalid like any other bad parameter. The field it looks for is named csrf-token; override that with #:csrf-field. See Security, for what the token is and how it gets into the form.

(let* ((form   (parse-form-body request body-port))
       (params (parse-form-params
                 `((title ,as-string #:required)
                   (body  ,as-string #:required))
                 form)))
  (if (any-invalid-params? params)
      ;; Re-render the form with errors
      (render-form (field-errors params 'title)
                   (field-errors params 'body))
      ;; Proceed
      (create-item! (assq-ref params 'title)
                    (assq-ref params 'body))))

any-invalid-params? returns #t if any value failed validation. field-errors returns a list of error message strings for a given field, suitable for rendering next to form inputs.

See (safsaf params), for the spec options, every built-in processor, and the rest of the procedures for inspecting a result.


1.8 Sessions

Session data is held in the cookie itself, not on the server. To stop a reader editing it, the cookie carries a signature made from the data and a secret key that only the server has, using HMAC from (webutils sessions). A cookie whose data has been changed no longer matches its signature and is ignored. The data is signed rather than encrypted, so the reader can read it: keep secrets out of it. See Security, for the secret key and the cookie attributes.

Set up a session config and apply the wrapper:

(define session-config
  (make-session-config "my-secret-key"
                       #:cookie-name "my-session"))

(define routes
  (wrap-routes my-routes
    (make-session-handler-wrapper session-config)))

Inside a handler, (current-session) returns whatever value session-set stored — an alist in the examples below — or #f if no valid session exists.

To set session data, include a session-set header in the response. To delete, use session-delete:

;; Set session
(redirect-response "/"
  #:headers (list (session-set session-config
                               '((user-id . 42)))))

;; Read session
(let ((user-id (and (current-session)
                    (assoc-ref (current-session) 'user-id))))
  ...)

;; Delete session
(redirect-response "/"
  #:headers (list (session-delete session-config)))

See (safsaf handler-wrappers sessions), for make-session-config’s cookie attributes and expiry, and for the rest of the session procedures.


1.9 Templating

write-shtml-as-html/streaming works like htmlprag’s write-shtml-as-html, but procedures may appear in the SHTML tree. A procedure of one argument is called as (proc port) and can write dynamic content directly; a thunk is called and its result rendered as SHTML.

streaming-html-response wraps this into a response: give it an SHTML tree (with optional procedure slots) and it returns (values response body) ready for a handler.

(define (base-layout title content-proc)
  `(*TOP*
    (*DECL* DOCTYPE html)
    (html
     (head (title ,title))
     (body
      (nav (a (@ (href "/")) "Home"))
      (main ,content-proc)
      (footer (p "Footer"))))))

The layout is plain SHTML with a procedure in the content-proc position. Use streaming-html-response to send it:

(define (index-page request body-port)
  (streaming-html-response
   (base-layout "Home"
     (lambda (port)
       (write-shtml-as-html/streaming
        `(div (h1 "Welcome")
              (p "Content goes here."))
        port)))))

You can also call write-shtml-as-html/streaming directly when you need to write SHTML with procedure slots to an arbitrary port.

1.9.1 Script and style elements

Text content is HTML-escaped, so <, > and & become entities. That is what you want everywhere except in script and style elements, which hold raw text. A browser does not decode entities inside them. An escaped && or > therefore reaches the JavaScript or CSS parser as &amp;&amp; or &gt;, and breaks it.

Wrap the content in (raw …) to write it through unescaped:

`(html
  (head
   (style (raw "nav > a{color:#066}")))
  (body
   (script (raw "if (a && b) go();"))))

raw does no escaping at all, so use it only with content you control. Putting user data inside a script or style element this way is an injection: a string containing </script> ends the element early, and everything after it is parsed as HTML.

There are two safe ways to pass data to a script. The simpler is to put the data in a data- attribute, where ordinary escaping applies and no raw is needed. The other is to render it as JSON, replacing every < with its JavaScript unicode escape. That leaves the JSON valid, and leaves no </script> in it. i18n-catalog-script from (safsaf i18n javascript) does this for its message catalog.

An external script needs no raw, having no text content:

(script (@ (src "/static/app.js")))

See (safsaf templating), for write-shtml-as-html/streaming and raw, and (safsaf response-helpers) for streaming-html-response.


1.10 Internationalisation

(safsaf i18n) translates messages. (safsaf handler-wrappers locale) decides which language to translate them into, and that is the more difficult of the two. Its consequences go well beyond the wording of a page. It decides three things:

  • whether a link to a page shows the same language to everyone who follows it;
  • whether a search engine can index more than one language of the site;
  • whether anything between the server and the reader can cache the pages at all.

1.10.1 Where the preference lives

A reader’s language preference can come from three places.

The URL/fr/posts/5 — makes the language part of the identity of the page. A link works the same for everyone who follows it, a crawler can see every language, and a cache keys on the URL as it does for everything else.

The Accept-Language header is what the browser volunteers. It is a useful opening guess and no more: it usually follows the operating system’s locale, which is often not the language its owner reads best, and it cannot express “I chose French on this site”.

An explicit choice — a cookie, or a field on the reader’s account — is the only one of the three that records a decision the reader actually made. It travels with the reader rather than with the link, so it cannot be the thing that identifies the page.

The difficulty is that the last two return different pages at one URL. A shared cache stores the response it saw and returns it to the next request for the same URL. Unless the response says otherwise, the first visitor’s French page is served to everyone.

Saying otherwise means the Vary header (see Responses), and here it is expensive. Accept-Language strings differ between browsers and between versions of one browser, so a page stored under Vary: Accept-Language is stored many times over. Vary: Cookie is worse: the cache key then includes every cookie the reader has, such as the session identifier, and almost no two readers share one. It stops shared caching in all but name.

1.10.2 The shape to aim for

Put the language in the URL, and use the other two only to decide where to send someone who arrives without one.

(define routes
  (route-group '()
    (route-group (locale-prefix-pattern '("en" "fr"))
      (route 'GET '()            list-posts #:name 'index)
      (route 'GET '("posts" id)  show-post  #:name 'show-post))
    (route '* '* (lambda (r b) (not-found-response)))))

locale-prefix-pattern matches exactly the locales given and binds the segment to locale. Do not use a bare capture such as '(locale) for this: a capture matches any segment at all, so /nonsense would match the group and then its index route, putting the front page at every one-segment URL there is.

Give the inner group no #:name, and the routes inside keep their own names for path-for. Together with the locale being inherited from the current request, that is what lets an application acquire a language prefix without touching its templates: (path-for routes 'show-post `((id . ,id))) returns /fr/posts/5 while rendering the French page and /en/posts/5 while rendering the English one.

The wrapper then selects on the URL and nothing else:

(make-locale-handler-wrapper #:supported '("en" "fr")
                             #:default "en"
                             #:detect '(route)
                             #:set-content-language? #t)

With #:detect '(route) no content response depends on a header or a cookie, so none of them records anything for Vary and all of them stay cacheable.

Apply the wrapper with wrap-routes, like any other. The route strategy reads the locale capture the router bound, so a wrapper applied to a handler any other way sees no capture and falls back to #:default for every request, without reporting anything wrong.

1.10.3 The entry redirect

That leaves the URLs with no language in them — someone typing the domain, an old bookmark, a link from elsewhere. One route handles them all:

(route 'GET '(. rest)
       (make-locale-redirect-handler #:supported '("en" "fr")
                                     #:default "en"
                                     #:handler-404 not-found))

It consults the cookie and then Accept-Language, and redirects to the same path under that language, keeping the query string. Put it in the route table after anything with paths of its own, such as static assets, health checks or a JavaScript runtime, and before the catch-all.

It answers with 302 rather than 301. Which language a reader prefers is not a property of the URL, and a permanent redirect would be cached as though it were.

It records both Accept-Language and Cookie for Vary — written, as everywhere, by vary-handler-wrapper, so keep that wrapper over this route too. Both are recorded whichever of the two decided this particular request, because the next reader’s answer can depend on either. That is a strict Vary on a response with an empty body, which costs a cache almost nothing. This is the reason for keeping the negotiation on this one URL: everything else stays cacheable.

A path that already begins with a supported locale reached the handler only because nothing matched it, so redirecting could only send it back to itself. Those go to #:handler-404 instead; pass the application’s own not-found handler so they are answered as they are everywhere else.

1.10.4 Switching language

locale-alternates returns the current page’s path in every supported locale, and path-in-locale returns it in one of them. Both read the locale context that make-locale-handler-wrapper binds, and raise when it is not in the stack, as does hreflang-links below. A switcher built from either leads to the page the reader is already on. That is what makes it useful: a language switch that sends the reader to the front page loses their place.

(map (lambda (entry)
       `(a (@ (href ,(cdr entry)) (hreflang ,(car entry)))
           ,(language-name (car entry))))
     (locale-alternates))

Since each language has a URL, these are plain links: shareable, no form, no CSRF token, and the reader can see where they lead.

To remember the choice as well, point them at a route that sets the cookie and forwards to the target. Note what the cookie is and is not for: it decides nothing about the page in front of the reader, and is consulted only by the entry redirect above. That is deliberate. Writing a cookie on a content response would defeat the caching the URL prefix just bought, since a shared cache will not store a response carrying Set-Cookie.

1.10.5 Telling the client

#:set-content-language? adds a Content-Language header naming the locale. It is skipped for responses that carry no content to describe, such as redirects and 304s. The option is off by default, because the wrapper also sees stylesheets and images, which have no language.

A search engine that finds the same page in two languages may treat one as a duplicate of the other and index only one of them. hreflang-links returns the link elements that tell it these are translations of one page:

`(head
  (title ,title)
  ,@(hreflang-links #:x-default #t))

#:x-default #t adds the entry naming the URL with no language in it — the one the entry redirect answers — as where to send a reader whose language the site does not have.

Set the document’s language too, which is what a screen reader, hyphenation and a browser’s offer to translate all go by:

`(html (@ (lang ,(or (current-locale) "en"))) ...)

1.10.6 If the locale cannot go in the URL

Sometimes the URLs are fixed and the language has to be chosen for each request. #:detect '(cookie accept-language) does that. The wrapper then records Accept-Language for Vary on every response whose language it chose. It records the same field for every request to that URL, because Vary describes the response a cache stored, not the request that produced it.

The cookie is not recorded unless you pass #:vary-on-cookie?. Django and Symfony make the same choice, and it has the same flaw in all three. Consider a reader with no cookie. Their language comes from Accept-Language, and the page is stored under Vary: Accept-Language. A later request that carries a locale cookie, but the same Accept-Language, matches that stored entry. The cache serves it the first reader’s language, and the cookie is never consulted.

Passing #:vary-on-cookie? closes that hole. It also ends shared caching for those pages, because the cache key then includes the whole cookie: session identifier, analytics, consent. Almost every reader has a different one. Neither choice is good, which is why the URL prefix above is the recommended arrangement.

See (safsaf i18n), for the translation procedures and the catalogue format, (safsaf handler-wrappers locale) for the wrapper, locale-prefix-pattern and the rest used above, and (safsaf i18n javascript) for translating in the browser.


1.11 Static Files

make-static-handler returns a handler that serves files from a directory. Pair it with a wildcard route:

(route-group '("static")
  (route 'GET '(. path)
         (make-static-handler "./public"
                              #:cache-control '((max-age . 3600)))))

This serves /static/css/style.css from ./public/css/style.css. The handler supports If-Modified-Since for 304 responses.

See (safsaf response-helpers), for make-static-handler’s options, and for static-url and build-static-manifest, which give a static file a URL that changes when its contents do.


1.12 Server-Sent Events

(safsaf response-helpers sse) provides Server-Sent Events: a long-lived HTTP response that streams labelled events to a browser’s EventSource. Compared to WebSockets it is one-way (server-to-client) and plain HTTP, which makes it easier to proxy and requires no new protocol handshake.

Use sse-response to build a streaming response. It takes the incoming request, a procedure that is called with an emit procedure, and a handful of keyword arguments:

(use-modules (safsaf response-helpers sse)
             (fibers))

(define (events-handler request body-port)
  (sse-response
   request
   (lambda (emit)
     (let loop ((n 1))
       (emit #:id (number->string n)
             #:event "tick"
             #:data (format #f "tick ~a" n))
       (sleep 1)
       (loop (1+ n))))))

The (fibers) import matters here: it provides the sleep that suspends only this fiber. Without it, sleep is Guile’s own, which blocks the scheduler thread and with it every other connection the server is handling.

emit accepts the same keyword arguments as make-sse-event: #:data, #:event, #:id, #:retry, #:comment. A call to emit and a keepalive comment cannot be written at the same time, so neither can appear in the middle of the other. The response sets content-type: text/event-stream, cache-control: no-cache and x-accel-buffering: no (for nginx) by default; add more with #:headers, which appends to the defaults rather than replacing them.

A client connecting via new EventSource('/events') picks up the stream and dispatches each tick event to any listener registered with es.addEventListener('tick', ...).

1.12.1 Keepalives

Browsers and proxies drop idle connections, often after 30–60 seconds. sse-response sends an SSE comment line after #:keepalive-interval seconds without anything else being written (default 15) to prevent that. Writing an event restarts the interval, so a stream that is already sending regularly sends no comments of its own. Pass #f or a non-positive number to disable.

Keepalives are also how a disconnect is discovered when the handler has nothing to send — see below.

1.12.2 Reconnection and replay

If the connection drops, EventSource reconnects automatically and sets the Last-Event-ID header to the last id: it saw. Read it with request-last-event-id:

(define (events-handler request body-port)
  (sse-response
   request
   (lambda (emit)
     (let ((since (request-last-event-id request)))
       (replay-events-since since emit)
       (stream-new-events emit)))))

Because browsers cannot set headers on the initial EventSource connection, the helper falls back to the last_event_id query-string parameter by default. Pass #:query-param to change the parameter name, or #:query-param #f to disable the fallback.

Use #:retry on sse-response to tell the browser how long to wait before reconnecting:

(sse-response request body-proc #:retry 2000)

1.12.3 Client disconnection

A write failure — typically because the client went away — raises an exception from emit. Check for it with sse-client-disconnected?:

(define (events-handler request body-port)
  (sse-response
   request
   (lambda (emit)
     (with-exception-handler
         (lambda (exn)
           (if (sse-client-disconnected? exn)
               'stop
               (raise-exception exn)))
       (lambda ()
         (let loop ()
           (emit #:data (next-event))
           (loop)))
       #:unwind? #t))))

That covers a procedure that always has something to send. A disconnect can only be discovered by writing, though. A procedure waiting for an event that never arrives — a feed of comments on a quiet page, say — never reaches an emit call, so it never finds out. It will stop the next time it does have something to send, which may be hours away, or never.

For those, current-sse-disconnect-condition holds a Fibers condition: a value that starts unsignalled and is signalled once, here when the client goes away. Fibers lets a procedure wait on several things at once, so a wait on this condition can be combined with the wait the procedure was already doing. See Operations in Guile Fibers, for the operations used below.

(define (events-handler request body-port)
  (sse-response
   request
   (lambda (emit)
     (let ((disconnected (current-sse-disconnect-condition)))
       (let loop ()
         (match (perform-operation
                 (choice-operation
                  (get-operation events)
                  (wrap-operation (wait-operation disconnected)
                                  (const 'disconnected))))
           ('disconnected
            (unsubscribe! events))
           (event
            (emit #:data event)
            (loop))))))))

The keepalive is what discovers the disconnection, so the condition is signalled up to two #:keepalive-interval periods after the client goes away. Two keepalives are needed because of how TCP closes a connection. The first says the client has stopped sending, which does not stop the server writing; only the reply to that write tells the server the connection is gone.

The response itself is closed either way, so a procedure that blocks for ever no longer keeps its connection and file descriptor open for the life of the process. Waiting on the condition is what lets the procedure stop and release resources of its own, such as the subscription above. Nothing outside the procedure can do that for it.

A runnable ticker example lives in examples/sse-ticker/sse-ticker.scm.

See (safsaf response-helpers sse), for sse-response’s keyword arguments and the rest of the module.


1.13 Security

Safsaf provides the pieces below, and applies none of them for you. A route table with no wrappers on it sends no security headers, checks no CSRF tokens and sets no cookie attributes. This section says what each piece does and what to turn on; Before you deploy at the end is the short version.

1.13.1 Security headers

security-headers-handler-wrapper from (safsaf handler-wrappers security-headers) adds a fixed set of headers to every response. Three are sent unless you turn them off:

X-Content-Type-Options: nosniff

Stops the browser guessing a response’s type from its content when the declared type says otherwise. Without it, a file a user uploaded can be guessed to be a script and run.

X-Frame-Options: DENY

Stops other sites displaying your pages inside a frame, which is how clickjacking works. Pass "SAMEORIGIN" if your own pages need to frame each other.

Referrer-Policy: strict-origin-when-cross-origin

Limits how much of the current URL the browser sends to another site in the Referer header. It sends the full URL within your own site, and only the origin when leaving it.

The others are sent only if you give them a value, because each has to be written for the particular site:

#:content-security-policy

Says where scripts, styles, images and the rest may be loaded from, and is the strongest defence against cross-site scripting. Start with #:content-security-policy-report-only, which uses the same syntax and reports violations without blocking anything, and switch it over once the reports are quiet.

#:strict-transport-security

Tells the browser to use HTTPS for this host for a period, for example "max-age=63072000; includeSubDomains". Turn it on only once HTTPS is working everywhere, including on every subdomain: a browser that has seen this header will refuse plain HTTP for the whole max-age, and there is no way to reach the browsers that already have it.

#:cross-origin-opener-policy

For example "same-origin", which stops a page that opened yours from keeping a handle on its window.

#:permissions-policy

Turns browser features off for your pages, for example "camera=(), microphone=()".

The wrapper appends its headers rather than replacing what is already there. A handler that sets X-Frame-Options itself produces a response carrying both values. Either leave the header to the wrapper, or turn that one off with #:frame-options #f.

1.13.2 Forms and CSRF

Cross-Site Request Forgery, CSRF, is an attack in which another site makes a reader’s browser submit a form to yours. The browser sends the reader’s cookies with it, so the request arrives authenticated. The defence is an unpredictable token that the attacking site cannot know.

csrf-handler-wrapper from (safsaf handler-wrappers csrf) puts one in a cookie, and binds it to current-csrf-token for the request. Put (csrf-token-field) inside every form to carry it back as a hidden field. parse-form-params (see Parameter Parsing) then marks a submission invalid when its field does not match the cookie. Another site can make the browser send the cookie, but cannot read it, so it cannot fill in the field.

(wrap-routes routes
  (make-csrf-handler-wrapper #:secure #t))

(define (new-post-form)
  `(form (@ (method "post") (action "/posts"))
         ,(csrf-token-field)
         (input (@ (name "title")))
         ...))

make-csrf-handler-wrapper takes the keyword arguments and returns the wrapper. Where the defaults will do, pass the wrapper itself: (wrap-routes routes csrf-handler-wrapper).

The cookie is HttpOnly and SameSite=strict by default. Pass #:secure #t wherever the site is served over HTTPS.

1.13.3 Cross-origin requests

A browser will let a script fetch a URL on another origin — another scheme, host or port — but not read the response, unless that response says the origin is allowed. Cross-Origin Resource Sharing, CORS, is how a server says so. cors-handler-wrapper from (safsaf handler-wrappers cors) writes those headers, and answers the preflight OPTIONS request that a browser sends before anything but the simplest cross-origin request.

(wrap-routes api-routes
  (make-cors-handler-wrapper
   #:origins '("https://app.example.com")
   #:methods '(GET POST)))

Name the origins. The default is '("*"), which lets a script on any site read the response, and is a reasonable default only for a public read-only API. A request carrying credentials — cookies and HTTP authentication — can only be read if the response also says #:allow-credentials? #t, which cannot be combined with "*"; the wrapper raises rather than accepting that pair.

The preflight reaches the wrapper through the route table like any other request, so the routes the wrapper covers have to accept OPTIONS as well. Otherwise the preflight matches no route in the group, is answered 404 or 405 elsewhere in the table without the CORS headers, and the browser reports the request as blocked — a failure visible only in the browser console, and only for the requests that are preflighted at all. Generate the OPTIONS routes inside the same wrap-routes and this is taken care of (see HTTP Compliance):

(define api-routes
  (wrap-routes
   (add-generated-http-compliance-routes
    (list (route-group '("api")
            (route 'GET '("items") api-list-items))))
   (make-cors-handler-wrapper
    #:origins '("https://app.example.com"))))

Writing (route '(GET OPTIONS) …) on each route instead does the same job, one route at a time.

Name one origin — a single site, or '("*") — and the CORS header fields can only take that one value, so they go on every response whoever asked for it, and no Vary is written. A request from anywhere else gets the same field, compares it with its own origin and is refused, which is what a response with no field on it achieved anyway. This is worth having: a shared cache then stores one response rather than one per calling site, and an intermediary that declines to cache anything carrying a Vary it does not understand still caches this.

Name two and the field is chosen per request, so the dependency has to be declared. Every response through the wrapper then says so in its Vary header — including the ones with no CORS headers on them, which are exactly the ones a shared cache must not hand to a request from an origin that may read them. Nothing has to be applied for that to happen, and where vary-handler-wrapper is applied outside (see Responses) it merges what else the request consulted into the same field rather than writing a second one.

So a table whose #:origins comes from configuration can carry a Vary in one deployment and not in another. That is the dependency being real in one and not the other, rather than an inconsistency.

The wrapper’s header fields also replace any of the same names the response already carries. A response with two Access-Control-Allow-Origin fields fails the browser’s check outright, so a handler that sets its own, or an inner group wrapped with CORS inside a table wrapped with it again, would otherwise block what both meant to allow.

Give each origin as a scheme, host and port and nothing else, in lower case, the way a browser serialises one. A trailing slash — which is what copying a URL out of an address bar gives you — or a capital letter could never match an Origin header field, and the wrapper raises rather than leaving CORS quietly off for that site.

The preflight also has to say which methods the path serves, and that is not something to write down: the wrapper takes it from the Allow header of the response the preflight reaches, so it is the route table’s own answer, worked out for that path rather than for the whole wrapped group. Two paths under one wrapper serving different methods each get their own list, which no single setting could give.

#:methods overrides that, and is worth reaching for only to report something other than what the table serves. Note which way the mistakes go. Naming a method no route serves costs little: the browser sends the request and the table answers 405, one carrying the CORS headers, so the script sees the refusal. Naming fewer methods than the table serves is the quiet one — the browser never sends the request at all, nothing reaches the server to be logged, and a route that works from curl and from same-origin pages is simply unreachable from another origin.

CORS is not access control, which is why leaving this to the table costs nothing. A cross-origin GET or form POST is sent and runs whatever the headers say; only the reading of the response is gated. Anything that is not a browser ignores these headers entirely. A method left out of the list is not a method anyone is stopped from calling — it is one your own front end can no longer call.

Apply it to the routes that need it rather than to the whole table (see Handler Wrappers).

1.13.4 Cookies and sessions

Session data lives in the cookie, signed with a secret key (see Sessions). It is signed, not encrypted, so the reader can read it: keep out anything they should not see. The secret key is what the whole scheme rests on, so give it a long random value, keep it out of the source tree, and change it knowing that every existing session becomes invalid.

make-session-config defaults to HttpOnly, which keeps the cookie away from JavaScript, and to SameSite=lax, which lets a reader arrive logged in by following a link but blocks cross-site form posts. It does not default to Secure: pass #:secure #t wherever the site is served over HTTPS, or the cookie will also be sent over plain HTTP.

set-cookie-header (see Request Parsing) sets none of these attributes unless asked, since it is for cookies of every kind. Pass #:http-only #t and #:secure #t on any cookie a script has no business reading.

1.13.5 Before you deploy

Neither Safsaf nor the web server checks any of this for you, and nothing here reports itself as missing at run time.

  • Apply security-headers-handler-wrapper, and write a #:content-security-policy for the site. The wrapper’s other headers have defaults; the policy has none. Until one is written, nothing limits where a script on your pages may be loaded from, and a script injected into a page runs like any other.
  • Apply csrf-handler-wrapper to every part of the route table that accepts a form, and use parse-form-params rather than parse-params to read one. Without the token check, a form submitted from another site arrives carrying the reader’s cookies, and the handler cannot tell it from a submission of your own.
  • Pass #:secure #t to make-session-config, csrf-handler-wrapper and set-cookie-header when serving over HTTPS. Without it the browser sends these cookies over plain HTTP as well, and anyone on the network path can read the session or the CSRF token from one such request.
  • Pass #:dev? #f — the default — to make-exceptions-handler-wrapper. With #:dev? #t an error page carries a backtrace of your code (see Handler Wrappers). That backtrace shows whoever made the request your source, your file paths, and the argument values of the call that failed.
  • Limit request bodies with make-max-body-size-handler-wrapper, and put a reverse proxy in front for the requests it cannot limit (see Handler Wrappers). With no limit, one request can make the process read a body until it runs out of memory.
  • Use raw in a template only with content you control (see Templating). It writes its argument into the page without escaping, so a string from a reader that contains markup becomes part of the page rather than text in it.
  • Apply vary-handler-wrapper outside anything that chooses what to send from a request header — negotiate-content-type and make-locale-handler-wrapper both record what they consulted, and without the wrapper nothing writes the Vary header they need (see Responses). A shared cache otherwise serves one reader’s language, or one client’s representation, to everybody.

Each wrapper’s own options are in its module: (safsaf handler-wrappers security-headers), (safsaf handler-wrappers csrf) and (safsaf handler-wrappers cors).


1.14 Running the Server

run-safsaf starts the HTTP server. Called on its own it sets up a Fibers scheduler, serves requests, and blocks until SIGINT or SIGTERM.

1.14.1 Shutdown

run-safsaf accepts an #:on-shutdown thunk. It runs after the listening socket has been closed, but before run-safsaf returns. Use it to flush logs, close database pools, or do any other cleanup. An exception raised by the thunk is caught and logged with log-msg.

(run-safsaf routes
            #:port 8080
            #:on-shutdown
            (lambda ()
              (close-db-pool!)
              (log-msg 'INFO "shutdown complete")))

A second SIGINT or SIGTERM during shutdown calls primitive-exit immediately. That is the way out if the thunk hangs.

Requests still in progress are not allowed to finish. They are cut off when the Fibers scheduler exits. Write long-running handlers and event streams so that they can be cut off safely, or wait for them yourself in the on-shutdown thunk.

1.14.2 Inside an existing scheduler

Called inside a run-fibers that is already running, run-safsaf does not block. It starts the server and returns two values: the underlying <web-server> record, and a thunk that shuts the server down. The thunk closes the listening socket and runs on-shutdown. Calling it twice is harmless, and it can be called from any fiber. The two-value let below is SRFI-71’s, from (srfi srfi-71).

(run-fibers
 (lambda ()
   (let ((server shutdown (run-safsaf routes
                                      #:port 8080
                                      #:on-shutdown cleanup)))
     ;; ... do other work ...
     (shutdown))))

1.14.3 Threads, buffering and the logger

Three options settle how the process as a whole behaves. Each has a default that suits most applications, and each is worth knowing about before changing it.

#:parallelism is the number of OS threads the Fibers scheduler runs fibers across. Fibers itself defaults to one per core; Safsaf defaults to 1. A single-threaded scheduler currently serves a typical request workload better, because scheduling across threads adds contention that costs more than the parallelism gains. Raise it if profiling shows the one thread is the limit, and measure the result rather than assuming more threads is faster. It applies only when run-safsaf starts the scheduler; inside a run-fibers of your own, that call decides.

(run-safsaf routes #:parallelism 4)

#:disable-output-port-buffering? is #t by default, which sets the buffering on the current output and error ports to 'none. Guile otherwise chooses the mode from the environment, line-buffered on a terminal and block-buffered when output is a pipe or a file. Port buffering is not fiber-safe: with several fibers writing to one buffered port, their output interleaves inside the buffer, and the failure looks like corrupted log lines or an error raised from inside the port rather than like a concurrency problem. Turning buffering off avoids that. It also means every write reaches the operating system on its own, so an application logging heavily to a file may want to buffer deliberately, and to write its logs from a single fiber if it does.

#:install-default-logger? is #t by default, and installs a logger writing to current-error-port when the application has not configured one. That is what makes log-msg and logging-handler-wrapper work without any setup. Call set-default-logger! before run-safsaf to install a different one; that logger is left alone. Pass #f to opt out altogether, which leaves log-msg calls silent rather than writing them to the error port.

1.14.4 Requests that name no path

Almost every request names a resource, and Safsaf dispatches it through the route table with no special cases. Two do not, and are answered by run-safsaf before the table, there being nothing for a route to match.

OPTIONS * is the first. RFC 9110 gives the asterisk-form to the server rather than to any resource, which makes it a ping: a client asks whether the server is there and answering. The default reply is a 200 with no body. Pass #:asterisk-options-handler to say more:

(define draining? (make-atomic-box #f))

(run-safsaf routes
  #:asterisk-options-handler
  (lambda (request body-port)
    (if (atomic-box-ref draining?)
        (values (build-response #:code 503) #f)
        (default-asterisk-options-handler request body-port)))
  #:on-shutdown (lambda () (atomic-box-set! draining? #t)))

A load balancer probing with OPTIONS * then stops sending traffic before the socket closes. Other things worth putting there are an Allow naming the methods the server uses anywhere, or a header advertising a protocol the application implements.

The handler runs outside wrap-routes, the request having reached no route, so no logging, exception or CORS wrapper sees it. In particular nothing catches an exception it raises, which is why the default does as little as it does.

The second is a target the router cannot decode — a path such as /%FF, whose percent-escape is not valid UTF-8. That is answered 400, and is not configurable: the request is malformed and there is nothing an application would want to say about it. An escape that is merely odd rather than undecodable, like /%ZZ, is matched literally and reaches the table as normal.

1.14.5 Options passed to the web server

A few options are Safsaf’s own: #:on-shutdown and #:request-observer above, and the three just described.

The rest run-safsaf passes to run-knots-web-server unchanged. They include #:host and #:port, #:socket to serve on a listening socket built elsewhere, #:connection-idle-timeout, #:listen-backlog, and the connection hooks (see Observing Requests). All of them are documented with run-knots-web-server.

#:port defaults to 8080. Any other option you do not give is left out of the call altogether, so the web server’s own default applies rather than one chosen by Safsaf. Only the options listed with run-safsaf are accepted; one it does not know raises rather than being forwarded.

See (safsaf), for run-safsaf’s full signature.


1.15 Observing Requests

A handler wrapper is the wrong place to measure requests. It sees only the requests that reach the handler it wraps, and only up to the point where that handler returns. A handler that returns a procedure as its response body has not finished its work at that point, and a handler that raises never returns to the wrapper at all.

run-safsaf takes a #:request-observer for this. It is called once for every request, after the response has been written:

(define* (observe request response
                  #:key route duration complete?
                  #:allow-other-keys)
  (log-msg 'INFO
           (request-method request) " "
           (uri-path (request-uri request)) " "
           (response-code response) " "
           duration "s"))

(run-safsaf routes #:request-observer observe)

Declare the observer with #:allow-other-keys, so that arguments added in later versions do not break it. Exceptions it raises are caught and logged by the web server, so a mistake in an observer costs metrics rather than requests. The example above makes one such mistake: for a handful of unusual requests request is #f, or has no URI to take a path from, and the log line is lost. The guard is under Requests that reach no route below.

1.15.1 Labelling by route

route is the route the request matched, as a <compiled-route> record. run-safsaf builds these from the route table at startup, and the observer is given the one that matched.

Label metrics with compiled-route-pattern, not with the request path. /users/1 and /users/2 are separate paths but one route, and a label with a new value for every path will eventually cost more to store than the thing it measures.

A pattern is not always a proper list: a rest route’s is a dotted list, and the catch-all’s is the bare symbol *. The catch-all is also the route that serves every 404, so a label procedure that only handles proper lists raises on the very requests worth counting — and, exceptions being swallowed, the metric silently disappears:

(define (segment-label segment)
  (if (string? segment)
      segment
      (string-append ":" (object->string segment))))

(define (route-label route)
  (if route
      (let loop ((pattern (compiled-route-pattern route))
                 (segments '()))
        (cond
         ((null? pattern)
          (string-append "/" (string-join (reverse segments) "/")))
         ((pair? pattern)
          (loop (cdr pattern)
                (cons (segment-label (car pattern)) segments)))
         (else            ; a dotted tail, or the catch-all's *
          (loop '() (cons (segment-label pattern) segments)))))
      "unmatched"))

compiled-route-name is the name the route was defined with, or #f for a route defined without one. The generated routes (see HTTP Compliance) carry names of their own, and every generated HEAD route is named %head, so names group more coarsely than patterns do.

1.15.2 Requests that reach no route

route is #f for the requests that never reach the route table. There are two kinds, and they need different handling.

A request the server could not parse — a malformed request line, a Content-Length that is not a number — is answered 400 by the server itself. request is #f for these, so the status code is all there is to report.

A request whose target is not a path the router can match reaches no route either, and is answered before the table. Here request is a request, but not one to take a path from:

An asterisk-form target

OPTIONS * is a legitimate HTTP/1.1 request whose request-uri is #f, so uri-path raises on it. RFC 9110 gives that form to the server rather than to a resource, so run-safsaf answers it itself; see #:asterisk-options-handler (see Running the Server).

An undecodable percent-escape

A path such as /%FF parses as a URI, but its segments cannot be decoded when the escape is not valid UTF-8, and it is answered 400. Escapes that are merely odd rather than undecodable, like /%ZZ, are matched literally and reach the catch-all as normal.

In both cases the expression that raises is the one an access log reaches for first, (uri-path (request-uri request)). Guard that call, or read the path only when route is not #f:

(define* (observe request response #:key route #:allow-other-keys)
  (log-msg 'INFO
           (response-code response) " "
           (if route
               (route-label route)
               "unrouted")))

An exception raised by the observer is caught and logged rather than failing the request. Getting this wrong therefore costs you log lines or metrics, and the requests themselves are still served, so nothing about the site looks wrong.

1.15.3 Timing

duration is the number of seconds between reading the request headers and finishing the response. handler-duration is the part of that up to the point the response body starts being written; it includes reading whatever of the request body the handler left unread. The difference between the two is the time spent writing to the client, which for an event stream is nearly all of duration.

complete? is #f if the response was never written in full, because the client went away or the body procedure raised. duration is then #f as well, and response-body-bytes-written may be. Count these separately from the rest. A plain request count makes an endpoint that clients keep abandoning look healthy.

1.15.4 Sizes

request-body-bytes-read and response-body-bytes-written are the decoded body sizes, without chunked framing overhead. A HEAD response, or a response with no body, reports 0 bytes written.

1.15.5 Measuring connections

The observer reports requests. Connections are reported by two hooks that run-safsaf passes to the web server unchanged:

(run-safsaf routes
            #:request-observer observe
            #:connection-accepted-hook
            (lambda* (sockaddr #:key #:allow-other-keys)
              (metric-increment connections-accepted))
            #:connection-closed-hook
            (lambda* (sockaddr #:key start-time end-time
                      #:allow-other-keys)
              (metric-observe connection-duration
                              (/ (- end-time start-time)
                                 internal-time-units-per-second))))

These come straight from the web server, so unlike the observer they report times as get-internal-real-time values rather than seconds; divide by internal-time-units-per-second as above.

The ratio worth watching is requests observed against connections accepted, which is how many requests each connection carries. If that number falls at the same time as latency rises, the problem is that clients are reconnecting too often, not that handlers have become slower.

See Running the Server, for the other options run-safsaf passes to the web server.

The observer’s full argument list is with run-safsaf in (safsaf), and <compiled-route> and its accessors are in (safsaf router).


2 API

The following is the list of modules provided by this library.


2.1 (safsaf)

2.1.1 Procedures

Procedure: default-asterisk-options-handler request body-port

Return the 200 with no body that answers OPTIONS *.

RFC 9110 has the asterisk-form apply to the server rather than to any resource, which makes it a ping: it reports that the server is there and answering, and nothing else. An application wanting to say more — the methods it serves anywhere, a protocol it implements, or a 503 while it drains before shutting down — passes its own procedure as #:asterisk-options-handler to run-safsaf.

Procedure: run-safsaf routes KEY: #:host #:port #:family #:addr #:socket #:ipv6-v6only? #:listen-backlog #:connection-buffer-size #:connection-idle-timeout #:connection-accepted-hook #:connection-closed-hook #:active-request-tracking? #:read-request-exception-handler #:write-response-exception-handler #:accept-exception-hook #:disable-output-port-buffering? #:install-default-logger? #:parallelism #:request-observer #:on-shutdown #:asterisk-options-handler

Start a Safsaf web server.

ROUTES is a list of routes and route-groups. The last route must be a catch-all, so that every request is handled. Every request naming a path is dispatched through the route table with no special cases; HEAD requests and requests matching a route’s path but not its method are answered by the routes (safsaf http-compliance) generates, which are added to ROUTES before it gets here. See HTTP Compliance.

A request naming no path is answered before the table, there being nothing for a route to match: OPTIONS *, whose asterisk-form target RFC 9110 gives to the server rather than to any resource, by #:asterisk-options-handler; the asterisk-form with any other method, or a path holding an escape that cannot be decoded, with 400. None of them reaches a route, so none reaches the handler wrappers applied to one — a handler passed here that raises has no exception handler wrapper to catch it.

Called outside a Fibers scheduler this starts one, serves requests, and blocks until SIGINT or SIGTERM. Called inside a scheduler that is already running it returns two values instead: the <web-server> record, and a thunk that shuts the server down. See Running the Server, for both, and for #:on-shutdown, which runs once the listening socket has been closed.

#:request-observer

A procedure called once for every request, after the response has been written, with the request and the response and the keyword arguments #:route, #:duration, #:handler-duration, #:complete?, #:request-body-bytes-read and #:response-body-bytes-written. Declare it with #:allow-other-keys, so that arguments added later do not break it. Several of the values can be #f, including the request itself; See Observing Requests, for what each one means, when it is #f, and what to label a metric with.

#:on-shutdown

A thunk run once the listening socket has been closed.

#:asterisk-options-handler

A handler (request body-port) -> (values response body) answering OPTIONS *. Defaults to default-asterisk-options-handler, which returns 200 with no body. Pass one of your own to report the methods the server serves anywhere, a protocol it implements, or a 503 while it drains before shutting down. It runs outside the handler wrappers, the request having reached no route.

#:parallelism

The number of OS threads the Fibers scheduler runs fibers across. Defaults to 1, rather than to Fibers’ own default of one per core, and applies only where run-safsaf starts the scheduler.

#:disable-output-port-buffering?

Whether to set the buffering on the current output and error ports to 'none. Defaults to #t.

#:install-default-logger?

Whether to install a logger writing to current-error-port where none has been configured. Defaults to #t.

#:connection-buffer-size

Passed to the web server as #:connection-buffer-size when given a value other than #f; #f means unset here, so the web server’s default applies.

See Running the Server, for what the three before the last are for and when to change them.

The remaining keyword arguments are passed to run-knots-web-server unchanged and are documented there:

#:host #:port #:family #:addr #:socket #:ipv6-v6only?
#:listen-backlog #:connection-idle-timeout
#:connection-accepted-hook #:connection-closed-hook
#:active-request-tracking? #:read-request-exception-handler
#:write-response-exception-handler #:accept-exception-hook

An argument not given is left out of that call rather than passed on with a default repeated here, so the web server’s own default applies, and goes on applying if it changes. #:host and #:port are the exception: run-safsaf defaults them itself, to #f and 8080, and always passes them on.

#:post-request-hook and #:call-handler-with-body-port? are not accepted: run-safsaf uses the first to implement #:request-observer, and depends on the second.


2.2 (safsaf handler-wrappers cors)

2.2.1 Procedures

Procedure: cors-handler-wrapper handler KEY: #:origins #:methods #:headers #:max-age #:allow-credentials? #:expose-headers

Handler wrapper that adds CORS (Cross-Origin Resource Sharing) headers to responses.

Browsers enforce the Same-Origin Policy: scripts on one origin (scheme + host + port) cannot read responses from a different origin. CORS relaxes this by letting the server declare which origins, methods, and headers are permitted.

For “simple” requests the browser sends the request and checks the response headers. For non-simple requests (e.g. PUT/DELETE, custom headers, or JSON Content-Type) the browser sends a preflight OPTIONS request first. This wrapper handles both cases.

#:origins

A list of allowed origin strings, or '("*") for any. Each is a scheme, host and port and nothing else, lowercased, as a browser serialises one: a trailing slash, a missing scheme or a capital letter could never match an Origin header field, and raises rather than leaving CORS quietly off for that site. "null" matches the origin a sandboxed document sends.

#:methods

A list of method symbols to report as the preflight’s Access-Control-Allow-Methods, or #f — the default — to take that from the route table. There is rarely a reason to give one: what a path serves is something the table already states, and a list written here is a copy of it that nothing keeps in sync. Naming fewer methods than the table serves makes those routes unreachable from a browser on another origin, with no request arriving and nothing logged to say so.

#:headers

A list of allowed request header name strings. A browser rejects the preflight when it asked for a header not in this list, so name every non-safelisted header the scripts send, or give '("*") where credentials are not in play. Unlike the methods, this cannot be worked out from the table: nothing declares which request header fields a handler reads.

#:max-age

The preflight cache duration in seconds. A preflight answered wrongly is remembered by the browser for this long.

#:allow-credentials?

Whether credentials — cookies and authentication — are allowed cross-origin. It cannot be #t together with a wildcard in #:origins, #:methods, #:headers or #:expose-headers, and raises rather than accepting the pair: with credentials a browser reads "*" as a literal name, matches nothing against it, and refuses or withholds everything the wildcard was meant to allow.

#:expose-headers

A list of response header name strings the browser may read from JavaScript, or '("*") for all of them where credentials are not in play.

The preflight is recognised by Access-Control-Request-Method, which a browser always sends with one, rather than by the method alone. A cross-origin OPTIONS without it is asking what the resource supports, a question the route table can answer and this wrapper cannot, so it goes to the handler and comes back with that answer and the CORS headers both.

A preflight goes to the handler too, and the wrapper answers from what comes back: the Allow header names the methods the table serves at that path, which is what the preflight is asking. Where the response is one a browser counts as ok it is returned with the CORS headers added, so a route of the application’s own answering OPTIONS decides its own response. Where it is not — a generated 405 route, or a wrapper inside this one refusing a request that carries no credentials, a preflight never carrying any — the Allow header is taken from it and reported on a 204 built here, a preflight being answered only by an ok status.

See HTTP Compliance, for the generated OPTIONS routes. They are what routes the preflight here in the first place, this wrapper answering it inside the handler it wraps rather than outside the table, and what states the methods for a path where nothing else does.

The preflight and the response to the request that follows it take different headers, each carrying only what applies to it.

Where #:origins names one origin to answer with — a single site, or '("*") — the header fields can only take that one value, so they go on every response whoever asked for it. A request from anywhere else gets the same field, compares it with its own origin and is refused, which is what the response with no field on it did anyway. Nothing then depends on the request, and no Vary field is written: a shared cache has one response to store rather than one per origin, and an intermediary that declines to cache what carries a Vary it does not know still caches this.

Naming a second origin brings the dependency back, the field then being chosen per request. Every response leaving the wrapper says so in its Vary header, the ones it passes through untouched included — those being the ones a cache must not hand to a request from an origin that may read them. The dependency is recorded with vary-on! as well, so that vary-handler-wrapper outside this one (see Responses) names it alongside whatever else the request consulted; that wrapper merges rather than appends, so the two together still write one field.

The CORS header fields replace any of the same names the response already carries rather than being added beside them. A response carrying two Access-Control-Allow-Origin fields fails the browser’s check outright, so a handler setting its own, or a second cors-handler-wrapper around this one, would otherwise block the requests both meant to allow.

Procedure: make-cors-handler-wrapper . args

Return a handler wrapper that adds CORS headers to responses. ARGS are the keyword arguments of cors-handler-wrapper; see there for what they mean, and for how the preflight reaches the wrapper.

(wrap-routes api-routes
  (make-cors-handler-wrapper
   #:origins '("https://app.example.com")
   #:methods '(GET POST)))

2.3 (safsaf handler-wrappers csrf)

2.3.1 Parameters

Parameter: current-csrf-token

Default value:

#f

2.3.2 Procedures

Procedure: csrf-handler-wrapper handler KEY: #:cookie-name #:secure #:http-only #:same-site

CSRF token handler wrapper.

Ensures a CSRF token cookie is present on every response (generates one if the request has none). The token is bound to current-csrf-token so handlers and templates can read it via (current-csrf-token).

The cookie’s Path is always /, and is not configurable.

#:cookie-name

The name of the token cookie. Defaults to "csrf-token".

#:secure

Whether to mark the cookie Secure. Defaults to #f; set it in production, where the site is served over HTTPS.

#:http-only

Whether to hide the cookie from JavaScript. Defaults to #t: the double-submit pattern reads the token from current-csrf-token rather than from document.cookie, so scripts have no need of it.

#:same-site

'strict (the default), 'lax, 'none, or #f to omit the attribute.

Token validation is not done here; it belongs with the form. Use parse-form-params from (safsaf params), which checks the submitted token against the cookie before parsing anything else.

Procedure: csrf-token-field

Return an SXML hidden input element for the CSRF token. Use in forms: (csrf-token-field)(input (@ (type "hidden") ...)).

Procedure: make-csrf-handler-wrapper . args

Return a handler wrapper that maintains a CSRF token cookie. ARGS are the keyword arguments of csrf-handler-wrapper; see there for what they mean and for how a submission is checked against the token.

(wrap-routes routes
  (make-csrf-handler-wrapper #:secure #t))

2.4 (safsaf handler-wrappers exceptions)

2.4.1 Procedures

Procedure: default-render-error render-html render-json

Return a render-error procedure that content-negotiates between RENDER-HTML and RENDER-JSON via negotiate-content-type — the URL path extension if the request has one, otherwise the Accept header.

Procedure: default-render-html request code message backtrace-string dev?

Default HTML error renderer. In dev mode, shows a rich backtrace page. In production, returns a minimal HTML page.

Procedure: default-render-json _request code message backtrace-string dev?

Default JSON error renderer. In dev mode, includes the backtrace. In production, returns only the error message.

Procedure: exceptions-handler-wrapper handler KEY: #:dev? #:logger #:render-html #:render-json #:render-error

Handler wrapper that catches exceptions from HANDLER and returns an error response.

The response format is content-negotiated from the request’s Accept header, choosing between HTML and JSON.

When #:logger is provided, exceptions are logged through it. Otherwise, the backtrace is written to the current error port. In dev mode (#:dev? is #t), the response includes the backtrace and exception details. In production mode, a generic error is returned.

Rendering can be customised at three levels. All three take a procedure of the same signature:

(request code message backtrace-string dev?)
 (values response body)
#:render-error

A full override, bypassing content negotiation entirely.

#:render-html

Called when content negotiation selects HTML.

#:render-json

Called when content negotiation selects JSON.

The default #:render-error content-negotiates between #:render-html and #:render-json. Providing #:render-html or #:render-json replaces just that format; providing #:render-error replaces the entire rendering.

Procedure: make-exceptions-handler-wrapper . args

Return a handler wrapper that catches exceptions and returns an error response. ARGS are the keyword arguments of exceptions-handler-wrapper; see there for what they mean and for the three levels at which rendering can be customised.

(wrap-routes routes
  (make-exceptions-handler-wrapper #:dev? #t))

2.5 (safsaf handler-wrappers locale)

2.5.1 Procedures

Return SHTML link elements pointing at every supported locale’s version of the current page, for the document head:

(link (@ (rel "alternate") (hreflang "fr") (href "/fr/posts/5")))

A search engine uses these to treat the versions as one page in several languages rather than as duplicates of each other, and to offer the right one. They only mean anything under the prefix layout path-in-locale assumes.

#:x-default, when given a path, adds the x-default entry naming the URL that negotiates rather than a particular language — the path make-locale-redirect-handler is mounted on. Pass #t to use the current path with its locale prefix removed.

Procedure: locale-alternates

Return an alist of (locale . path) for every supported locale, naming the current page in each of them. The source for a language switcher, and for hreflang-links.

Procedure: locale-prefix-pattern supported KEY: #:route-param

Return a route-group prefix pattern that matches exactly the SUPPORTED locales, binding the segment to #:route-param:

(route-group (locale-prefix-pattern '("en" "fr"))
  (route 'GET '() index #:name 'index)
  (route 'GET '("posts" id) show-post #:name 'show-post))

Use this rather than a bare capture such as '(locale). A capture matches any segment at all, so /nonsense would match the group and then its index route, serving the front page at every one-segment URL there is. A search engine then indexes each of them as a separate page with the same content. With this pattern such a path matches nothing, and falls through to make-locale-redirect-handler or to the application’s 404.

The routes inside keep their own names for path-for, provided the group is not given a #:name of its own, and the locale segment comes from the current request unless a call site passes one.

Procedure: make-locale-handler-wrapper KEY: #:supported #:default #:detect #:cookie-name #:route-param #:vary? #:vary-on-cookie? #:set-content-language?

Return a handler wrapper that resolves the request locale and binds it to current-locale for the duration of the handler.

#:supported

A list of the locale strings the application recognises. Defaults to '("en").

#:default

The locale used when no strategy yields a supported one. Defaults to "en".

#:detect

An ordered list of strategy symbols, the first to yield a supported locale winning. A value a strategy finds but #:supported does not list counts as a miss, so the next strategy is tried. Defaults to all three:

route

Reads (current-route-params) for #:route-param. Only works with the wrapper applied inside the router, through wrap-routes, since the dispatcher is what binds current-route-params.

cookie

Reads #:cookie-name from the request cookies.

accept-language

Takes the best match against #:supported from the Accept-Language request header.

Prefer route, with the locale as a segment of the URL: it is the only one of the three that leaves the page shareable, crawlable and cacheable, each language having a URL of its own.

#:cookie-name

The cookie the cookie strategy reads. Defaults to "locale".

#:route-param

The route parameter the route strategy reads. Defaults to 'locale.

#:vary?

Whether to record what the choice depended on with vary-on!, for vary-handler-wrapper to write. Defaults to #t. Nothing is recorded where the route strategy decided, the URL already telling the languages apart, nor where only one locale is supported and the answer cannot vary. Otherwise accept-language is recorded whenever that strategy is enabled, whichever strategy decided this particular request, because Vary describes the stored response and has to be the same for every request that URL answers.

#:vary-on-cookie?

Whether to add Cookie to that set. Defaults to #f. Leaving it off can serve a reader the wrong language from a shared cache; turning it on ends shared caching for those pages. Neither is good, and putting the locale in the URL avoids the choice. See Internationalisation, for the sequence of requests that goes wrong and what each setting costs.

#:set-content-language?

Whether to add a Content-Language header naming the locale, where the response does not already carry one. Defaults to #f, the wrapper seeing plenty of responses with no language at all, stylesheets and images among them.

Procedure: make-locale-redirect-handler KEY: #:supported #:default #:detect #:cookie-name #:code #:handler-404

Return a handler that redirects to the same path under a locale prefix, for an application whose pages live at /<locale>/....

This is the one URL such an application negotiates at: a visitor arrives at /posts/5 with no language in it, and the redirect sends them to /fr/posts/5 or /en/posts/5 according to their cookie and Accept-Language. The query string is kept.

Mount it as an ordinary route, after everything with a URL space of its own — static assets, health checks — and before the catch-all:

(route 'GET '(. rest) (make-locale-redirect-handler #:supported '("en" "fr")))
#:supported

A list of the locale strings the application recognises. Defaults to '("en").

#:default

The locale redirected to when no strategy yields a supported one. Defaults to "en".

#:detect

An ordered list of strategy symbols, as in make-locale-handler-wrapper but without route, there being no locale in the path to read. Defaults to '(cookie accept-language).

#:cookie-name

The cookie the cookie strategy reads. Defaults to "locale".

#:code

The redirect’s status code. Defaults to 302, and should stay temporary: which language a visitor prefers is not a property of the URL, so a permanent redirect would pin one visitor’s language to it.

#:handler-404

The handler for a request whose path already begins with a supported locale. Such a request reaches here only when nothing else matched it, so redirecting could only send it to itself. Defaults to a bare 404; pass the application’s own not-found handler to have these answered as they are everywhere else.

Both strategies are recorded with vary-on! however this particular request was decided, because the answer for the next visitor depends on both. A small redirect is the one response where paying the full cost of cache correctness costs nothing.

Procedure: path-in-locale locale OPT: path

Return the path of the current request with LOCALE as its locale prefix, keeping the query string. A leading supported-locale segment is replaced; a path without one has LOCALE inserted at the front.

For the language switcher, which has to lead to the page the reader is on rather than to the front page, and for hreflang-links. Assumes the locale is the first path segment, as make-locale-redirect-handler produces and a '(locale) route-group prefix matches.

PATH, if given, is relocalised instead of the current request’s — for a handler that has to move some other path into a language, such as a redirect target it was handed. It may carry a query string.

Only meaningful inside make-locale-handler-wrapper, which binds the request it works from.


2.6 (safsaf handler-wrappers logging)

2.6.1 Procedures

Procedure: logging-handler-wrapper handler KEY: #:logger #:level

Handler wrapper that logs each request and response.

Logs at #:level (default ’INFO) with method, path, status code, and duration in milliseconds. If #:logger is given, logs to that logger; otherwise uses the default logger set via set-default-logger!.

Procedure: make-logging-handler-wrapper . args

Return a handler wrapper that logs each request and response. ARGS are the keyword arguments of logging-handler-wrapper; see there for what they mean and for what each line records.

(wrap-routes routes
  (make-logging-handler-wrapper #:level 'DEBUG))

2.7 (safsaf handler-wrappers max-body-size)

2.7.1 Procedures

Procedure: make-max-body-size-handler-wrapper max-bytes KEY: #:handler-413

Return a handler wrapper that rejects requests whose Content-Length exceeds MAX-BYTES with a 413 Payload Too Large response.

#:handler-413 is a handler (request body-port) -> (values response body) called when the limit is exceeded; the default returns plain text.

Note: this checks the Content-Length header only. Chunked transfers without Content-Length are not limited by this wrapper.


2.8 (safsaf handler-wrappers security-headers)

2.8.1 Procedures

Procedure: make-security-headers-handler-wrapper . args

Return a handler wrapper that adds security headers to every response. ARGS are the keyword arguments of security-headers-handler-wrapper; see there for which headers are sent by default and how to disable one.

(wrap-routes routes
  (make-security-headers-handler-wrapper
   #:strict-transport-security "max-age=31536000"))
Procedure: security-headers-handler-wrapper handler KEY: #:content-type-options #:frame-options #:strict-transport-security #:referrer-policy #:cross-origin-opener-policy #:permissions-policy #:content-security-policy #:content-security-policy-report-only

Handler wrapper that adds security headers to every response.

All headers are optional and configurable. Pass #f to disable a header.

Sent by default:

X-Content-Type-Options: nosniff
X-Frame-Options: DENY
Referrer-Policy: strict-origin-when-cross-origin

Not sent unless a value is given:

#:strict-transport-security

For example "max-age=63072000; includeSubDomains".

#:cross-origin-opener-policy

For example "same-origin".

#:permissions-policy

For example "camera=(), microphone=()".

#:content-security-policy

For example "default-src 'self'; script-src 'self'".

#:content-security-policy-report-only

The same syntax, for testing a policy without enforcing it.


2.9 (safsaf handler-wrappers sessions)

2.9.1 Macros

Macro: session-config? x

Return #t if OBJ is a <session-config>, as returned by make-session-config.

2.9.2 Parameters

Parameter: current-session

Default value:

#f

2.9.3 Procedures

Procedure: make-session-config secret-key KEY: #:cookie-name #:expire-delta #:algorithm #:secure #:http-only #:same-site #:path

Create a session config, for session-handler-wrapper and for session-set and session-delete.

SECRET-KEY

The HMAC signing key, a string.

#:cookie-name

The name of the session cookie. Defaults to "session".

#:expire-delta

How long a session lasts, as (days hours minutes). Defaults to 30 days.

#:algorithm

The HMAC algorithm. Defaults to 'sha512.

#:secure

Whether to mark the cookie Secure, which keeps the browser from sending it over cleartext. Defaults to #f; set it in production, where the site is served over HTTPS.

#:http-only

Whether to hide the cookie from JavaScript, so that a cross-site scripting flaw on the origin cannot read the session out of document.cookie. Defaults to #t.

#:same-site

'lax (the default), 'strict, 'none, or #f to omit the attribute. Lax allows the cross-site GET that arrives from a link in an email, while blocking a cross-site form post.

#:path

The cookie’s Path. Defaults to "/", and applies to both session-set and session-delete: a browser matches a deletion against the cookie by name and Path, so the two have to agree or the session cookie outlives the deletion.

Procedure: make-session-handler-wrapper . args

Return a handler wrapper that binds session data. ARGS are the arguments of session-handler-wrapper after the handler — a session configuration from make-session-config; see there for what it holds.

(wrap-routes routes
  (make-session-handler-wrapper (make-session-config secret-key)))
Procedure: session-delete config

Return a Set-Cookie header that expires the session cookie. Include in a response headers list:

(redirect-response "/" #:headers (list (session-delete cfg)))
Procedure: session-handler-wrapper handler config

Session handler wrapper using signed cookies via (webutils sessions).

Reads the session cookie from the request, verifies the HMAC signature, and binds current-session for the duration of the handler. If no valid session cookie is present, current-session is #f.

Handlers read session data by calling (current-session), which returns the session data or #f.

To set or delete the session, handlers include the appropriate header in their response using session-set and session-delete:

(redirect-response "/" #:headers (list (session-set config data)))
(redirect-response "/" #:headers (list (session-delete config)))
Procedure: session-set config data

Return a Set-Cookie header that stores signed DATA in the session cookie. DATA can be any Scheme value that can be written and read back. Include in a response headers list:

(redirect-response "/"
                   #:headers (list (session-set cfg '((user-id . 42)))))

2.10 (safsaf handler-wrappers trailing-slash)

2.10.1 Procedures

Procedure: make-trailing-slash-handler-wrapper . args

Return a handler wrapper that normalizes trailing slashes. ARGS are the keyword arguments of trailing-slash-handler-wrapper; see there for what they mean.

(wrap-routes routes (make-trailing-slash-handler-wrapper #:mode 'append))
Procedure: trailing-slash-handler-wrapper handler KEY: #:mode #:code

Handler wrapper that normalizes trailing slashes in request paths.

#:mode is either ’strip (default) or ’append:

'strip

Redirect /foo/ to /foo.

'append

Redirect /foo to /foo/.

The root path / is always left alone.

#:code is the HTTP status code for the redirect (default 301).


2.11 (safsaf handler-wrappers vary)

2.11.1 Procedures

Procedure: make-vary-handler-wrapper . args

Return a handler wrapper that writes the Vary header. ARGS are the keyword arguments of vary-handler-wrapper; see there for what they mean and for where the wrapper belongs in the stack.

(wrap-routes routes
  (make-vary-handler-wrapper #:always '(accept-encoding)))
Procedure: vary-handler-wrapper handler KEY: #:always

Handler wrapper that adds a Vary header naming the request headers the response depended on.

Code that reads a request header to decide what to send records it with vary-on!; negotiate-content-type already does. This wrapper collects what was recorded and writes the header. Responses that recorded nothing get no header, so a URL serving one representation keeps a cache key of the URL alone.

#:always is a list of header names added to every response regardless, for a dependency no per-request code observes.

Wrap outside anything that answers a request itself. It adds the header to whatever response passes through it, which is how the 304s and error responses from a negotiated URL come to carry it — those are the ones a handler writing the header by hand forgets, and one missing header poisons the URL for every representation of it. An exception handler wrapper therefore belongs inside this one, so that the 500 it builds is still seen here.


2.12 (safsaf http-compliance)

2.12.1 Procedures

Procedure: add-generated-http-compliance-routes routes KEY: #:head? #:options? #:options-handler #:method-not-allowed? #:method-not-allowed-handler

Return ROUTES with the generated routes that handle HEAD requests, answer OPTIONS, and answer 405 Method Not Allowed, in the order the three require.

ROUTES is a route list, the whole table ending in its catch-all route or part of a larger table. This is the usual way to use this module; it is add-head-routes, then add-options-routes, then add-method-not-allowed-routes, which have to be applied in that order. See those procedures for what each generates, and for what ROUTES has to satisfy to be only part of a table.

The generated routes are ordinary routes, so call this inside wrap-routes and they are wrapped along with the rest of the tree:

(run-safsaf
 (wrap-routes (add-generated-http-compliance-routes routes)
              logging-handler-wrapper))

That is also what routes a CORS preflight to the wrapper answering it, cors-handler-wrapper answering the preflight inside the handler it wraps rather than outside the table.

Set #:head?, #:options? or #:method-not-allowed? to #f to leave out that set of generated routes. Where ROUTES is part of a larger table, keep all three. The 405 routes generated here match every method, so the call covering the whole table treats these paths as fully served: with #:head? #f it generates no HEAD routes for them, and a HEAD request to them is answered 405. Only #:method-not-allowed? #f passes its work outward, and the routes the outer call generates are outside any wrappers applied to ROUTES.

#:options-handler and #:method-not-allowed-handler are passed on as the #:handler of add-options-routes and add-method-not-allowed-routes.

Procedure: add-head-routes routes

Return ROUTES with a HEAD route generated for every path that has a route for GET but none for HEAD.

ROUTES is a route list. Where it ends in the catch-all route the generated routes are inserted before that catch-all, and where it does not, being part of a larger table, they go at its end. Each runs the handler of the first GET route for its path, exactly as a GET request would. The web server drops the body from HEAD responses, so the response carries the header fields the GET response would have carried, without the body.

The generated routes are ordinary routes, so they are wrapped by wrap-routes along with the rest of the tree. Call this first of all, before wrap-routes, add-options-routes and add-method-not-allowed-routes, or leave the order to add-generated-http-compliance-routes.

Applying this after add-method-not-allowed-routes would generate nothing, as the routes that answer 405 match every method, HEAD included, so it raises an error instead.

Procedure: add-method-not-allowed-routes routes KEY: #:handler

Return ROUTES with generated routes added that respond 405 Method Not Allowed to requests matching the path of a route but not its method.

ROUTES is a route list, the whole table ending in its catch-all route or part of a larger table. For each distinct path in the tree, a route matching any method is generated, where it only ever matches requests that no real route matched: before the catch-all where there is one, and at the end where there is not. Paths already served by a route matching any method are left alone, no request being able to reach a generated route for them. Paths differing only in their capture names count as one, and the generated route captures under the names of the first of them.

Where ROUTES is part of a larger table it has to serve its paths on its own: every route for a path it covers has to be in it. The generated routes match every method, so a route for one of those paths later in the enclosing table would be shadowed, and the Allow header is worked out from ROUTES alone and would leave that route’s method out.

The generated routes are ordinary routes, so they are wrapped by wrap-routes along with the rest of the tree:

(run-safsaf
 (wrap-routes (add-method-not-allowed-routes
               (add-options-routes (add-head-routes routes)))
              logging-handler-wrapper))

Call this before wrap-routes, as above, otherwise the generated handlers run outside the handler wrappers. Call it last, after add-head-routes and add-options-routes, so that the paths served by their generated routes list HEAD and OPTIONS among the methods they allow rather than answering 405, or leave the order to add-generated-http-compliance-routes.

#:handler is a procedure (request body-port allowed-methods) -> (values response body) producing the 405 response, where ALLOWED-METHODS is the list of methods the request path does allow. The default returns plain text with an Allow header.

Procedure: add-options-routes routes KEY: #:handler

Return ROUTES with an OPTIONS route generated for every path that has no route matching OPTIONS, answering with the methods that path allows.

ROUTES is a route list, the whole table ending in its catch-all route or part of a larger table. For each distinct path in the tree a route is generated, placed before the catch-all where there is one and at the end where there is not. Paths already served by a route matching OPTIONS are left alone, whether that route names OPTIONS among its methods or matches every method. Paths differing only in their capture names count as one, and the generated route captures under the names of the first of them.

Without these routes an OPTIONS request to a known path is answered 405 by the routes add-method-not-allowed-routes generates, or 404 by the catch-all where there are none. Both are worse than an answer, and neither reaches a handler wrapper covering only part of the table, which is what a CORS preflight has to do: cors-handler-wrapper answers the preflight inside the handler it wraps, so the preflight has to be routed there. Generating these routes inside the same wrap-routes is what routes it.

Where ROUTES is part of a larger table it has to serve its paths on its own, as add-method-not-allowed-routes requires: the Allow header is worked out from ROUTES alone, and a route for one of its paths later in the enclosing table would be left out of it.

The generated routes are ordinary routes, so they are wrapped by wrap-routes along with the rest of the tree. Call this before wrap-routes, otherwise the generated handlers run outside the handler wrappers. Call it after add-head-routes, so that the paths served by the generated HEAD routes list HEAD among the methods they allow, and before add-method-not-allowed-routes, whose routes match every method and would leave no path to generate for — or leave the order to add-generated-http-compliance-routes.

#:handler is a procedure (request body-port allowed-methods) -> (values response body) producing the response, where ALLOWED-METHODS is the list of methods the request path allows. OPTIONS is among them, the generated route being what allows it, so that the list matches the one the 405 routes report for the same path. The default returns 204 No Content with an Allow header and no body.

Procedure: default-method-not-allowed-handler request body-port allowed-methods

Return a 405 Method Not Allowed response with an Allow header listing ALLOWED-METHODS.

Procedure: default-options-handler request body-port allowed-methods

Return a 204 No Content response with an Allow header listing ALLOWED-METHODS.


2.13 (safsaf i18n)

2.13.1 Parameters

Parameter: current-locale

Default value:

#f

2.13.2 Procedures

Procedure: best-accept-language header supported

Given an Accept-Language HEADER and a list of SUPPORTED locale strings, return the best match (as listed in SUPPORTED) or #f if none match. Matching is case-insensitive and falls back from a regional variant to its base language, e.g. a request for en-US will match a supported "en".

A * in the header picks the first supported locale.

HEADER may be a raw string or Guile’s pre-parsed form (an alist of (permille . symbol) pairs), so this works with the raw header value or with (assoc-ref (request-headers request) 'accept-language).

Procedure: catalog-ref msgid

Return the raw catalog entry for MSGID under (current-locale), or #f when there is no translation. The entry is a string for a singular translation and a vector of forms for a plural one.

Use this to tell an untranslated message from a translated one, which t and tn deliberately hide by falling back to the msgid. Anything exporting translations rather than rendering them needs the distinction: a catalog that carries the fallback as though it were a translation makes the consumer apply its own rules to it.

Procedure: clear-catalogs!

Drop all loaded catalogs. Primarily useful in tests.

Procedure: install-translation! locale msgid value

Install VALUE (a string or vector of strings) as the translation of MSGID for LOCALE. VALUE is a string for singular entries and a vector for plural entries. Tests can use this to avoid going through the PO parser.

Procedure: load-catalog-from-port! locale port

Read PO entries from PORT and install them as translations for LOCALE. Existing entries for LOCALE are preserved (new entries override).

Procedure: load-catalogs! dir KEY: #:extension

Scan DIR for files matching #:extension (default ".po") and install each as the catalog for the locale taken from the filename stem.

locale/fr.po    ⇒ catalog for locale "fr"
locale/en-GB.po ⇒ catalog for locale "en-GB"
Procedure: locale-plural-index n

Return the plural form index that (current-locale)’s plural rule selects for the count N. Zero-based, as in the vectors returned by plural-forms. See register-plural-rule!.

Procedure: normalize-accept-language header

Normalise an Accept-Language HEADER to a list of (locale . quality) pairs sorted by descending quality, with locale lowercased. HEADER can be:

  • #f or an empty string (returns '())
  • a string (parsed via parse-accept-language)
  • an alist of (permille . language-symbol) pairs as returned by Guile’s (web http) header parser, where the permille is an integer from 0 to 1000.
Procedure: parse-accept-language header

Parse an Accept-Language HEADER into a list of (locale . quality) pairs sorted by descending quality. LOCALE is lowercased. Entries with q=0 are omitted. Returns ’() for #f or an empty header.

Procedure: plural-forms msgid msgid-plural

Return a vector of the plural forms for MSGID under (current-locale), in plural form index order. Falls back to #(MSGID MSGID-PLURAL) when no plural translation is installed, matching what tn would use.

Unlike tn this selects no form and interpolates nothing, so the count need not be known yet. Together with locale-plural-index it is enough to hand a plural message to something that will pick the form itself — a client-side script, say, where the count only exists in the browser.

Procedure: register-plural-rule! locale rule

Register a plural rule RULE for LOCALE. RULE is a procedure taking an integer N and returning the plural form index (zero-based).

A rule for "en" would be (lambda (n) (if (= n 1) 0 1)).

Procedure: t msgid . args

Translate MSGID under (current-locale), falling back to MSGID itself when no translation is installed. ARGS, if any, are interpolated via (ice-9 format) — use ~a, ~s, etc.

(t "Hello, ~a!" name)
Procedure: tn msgid msgid-plural n . args

Plural-aware translation. N selects the plural form via the current locale’s plural rule. MSGID-PLURAL is the untranslated plural form used when no translation is installed and N is not 1.

N is passed as the first format argument; ARGS are passed after. Use ~a to insert the count:

(tn "~a item" "~a items" count)

2.14 (safsaf i18n javascript)

2.14.1 Procedures

Procedure: i18n-catalog-script specs

Return SXML for a script element holding the message catalog for SPECS under (current-locale), for the runtime to read.

SPECS is a list where each element is either a msgid string or a (msgid msgid-plural) list. Pass only what the page’s own script uses.

The plural rule table is included only when SPECS contains a plural message, since it is by far the largest part of the catalog.

Procedure: i18n-runtime-file

Return the absolute path of the JavaScript runtime.

The file is installed alongside the modules, so it is found on %load-path both installed and uninstalled.

Serve it from a route of its own to have it cached between pages, rather than inlined into each by i18n-runtime-script:

(route 'GET '("i18n.js")
       (make-file-handler (i18n-runtime-file)
                          #:cache-control '((max-age . 3600))))

make-file-handler adds an ETag and handles conditional requests, so the revalidation is cheap. Do not serve it as immutable: the URL carries no content hash, so an upgraded Safsaf has to be able to replace what the client already has.

Procedure: i18n-runtime-javascript

Return the JavaScript runtime’s source as a string. It is read once and kept.

Use this to inline the runtime yourself, or to copy it into an asset directory at build time. For serving it directly, see i18n-runtime-file.

Procedure: i18n-runtime-script

Return SXML for a script element holding the JavaScript runtime, which reads the catalog emitted by i18n-catalog-script and defines window.i18n with t and tn.

Procedure: i18n-scripts specs

Return a list of SXML nodes: the catalog for SPECS followed by the runtime that reads it. Splice the list into the end of the document body with unquote-splicing.

Emitting both together keeps the runtime and the catalog format it expects from drifting apart, so prefer this to calling i18n-catalog-script and i18n-runtime-script separately.


2.15 (safsaf params)

2.15.1 Macros

Macro: invalid-param-message x

Return the message saying why INVALID-PARAM was rejected, a string meant to be shown to whoever submitted the form. field-errors collects these for one parameter.

Macro: invalid-param-value x

Return the value INVALID-PARAM was made from — the raw string from the request, or #f where the parameter was absent from the request altogether. Use it to put what was submitted back in the form field. A record made by guard-against-mutually-exclusive-params carries the already converted value instead, that being what the parameter held when the conflict was found.

Macro: invalid-param? x

Return #t if OBJ is an <invalid-param>.

Macro: make-invalid-param x

Return an <invalid-param> recording that VALUE was not acceptable, with MESSAGE saying why. A processor returns one of these in place of a converted value; see parse-params.

2.15.2 Procedures

Procedure: any-invalid-params? parsed-params

Return #t if any values in PARSED-PARAMS are invalid.

Procedure: as-checkbox s

Return #t if S is "on", the value a browser submits for a ticked checkbox with no value attribute, and #f otherwise. A parse-params processor.

Never returns an <invalid-param>: any other value simply reads as unticked. An unticked box is not submitted at all, so give the parameter a #:default of #f to tell it from a missing one.

Procedure: as-integer s

Accept S as an exact integer, or return an <invalid-param>. A parse-params processor.

A value with a fractional or exponent part is rejected rather than rounded; use as-number to accept those.

Procedure: as-matching regex KEY: #:message

Return a parse-params processor that accepts values matching REGEX, a string or a compiled regexp. #:message overrides the error message, which defaults to "Invalid format".

Procedure: as-number s

Accept S as any number Scheme can read, or return an <invalid-param>. A parse-params processor.

Procedure: as-one-of choices KEY: #:message

Return a parse-params processor that accepts only values in CHOICES, a list of strings. #:message overrides the error message, which otherwise lists the choices.

Procedure: as-predicate pred KEY: #:message

Return a parse-params processor that accepts values for which PRED returns true. #:message overrides the error message, which defaults to "Invalid value".

Procedure: as-string s

Accept S unchanged. The parse-params processor for a parameter that needs no validation beyond being present.

Procedure: field-errors parsed-params name

Return a list of error message strings for NAME, or ’(). Convenient for rendering form fields with per-field errors.

Procedure: guard-against-mutually-exclusive-params parsed-params groups

Check PARSED-PARAMS for mutually exclusive parameter groups. GROUPS is a list of lists of symbols, e.g. ’((limit_results all_results)). If parameters from the same group co-occur, every one of them is replaced with an <invalid-param> record naming the others.

Procedure: invalid-param-ref parsed-params name

Return the <invalid-param> record for NAME, or #f if valid or absent. A #:multi-value parameter binds a list, which this returns #f for even when the list holds invalid values; use field-errors or any-invalid-params? for those.

Procedure: params->query-string parsed-params

Serialize PARSED-PARAMS back to a URI query string. Skips invalid params. Handles multi-value (list) entries. Useful for building pagination links that preserve current filters.

Procedure: parse-form-params param-specs raw-params KEY: #:csrf-field

Like parse-params but prepends a CSRF token check. Uses current-csrf-token from (safsaf handler-wrappers csrf).

#:csrf-field names the form field carrying the token, and defaults to csrf-token, which is the name csrf-token-field gives it. A submission whose field is missing or does not match comes back with an <invalid-param> under that name, like any other bad parameter.

Procedure: parse-params param-specs raw-params

Parse and transform parameters from RAW-PARAMS according to PARAM-SPECS.

RAW-PARAMS is an alist of (string . string) pairs, as returned by parse-query-string or parse-form-body.

PARAM-SPECS is a list of specifications. Each spec is a list whose first element is the parameter name (a symbol), whose second is a processor, and whose rest are keyword options.

A processor is a procedure of one argument, the raw string from the request. It returns the value to bind, converted to whatever type the application wants, or an <invalid-param> record saying why the string was not acceptable. as-string, as-integer, as-number and as-checkbox are processors; as-one-of, as-matching and as-predicate return one. Any procedure of that shape will do, so an application can write its own.

The keyword options:

(name processor)                       ; optional
(name processor #:required)            ; must be present
(name processor #:default value)       ; fallback
(name processor #:multi-value)         ; collect all occurrences
(name processor #:multi-value #:default value)
(name processor #:no-default-when (fields) #:default value)

A spec in any other shape — an unknown keyword, or these keywords in another order — raises an error.

Returns an alist of (symbol . value) pairs. A value that fails validation appears as an <invalid-param> record in place of the value, rather than being left out or raising. A missing optional parameter with no default is omitted. A parameter submitted with an empty string as its value is treated as missing throughout: omitted, replaced by its #:default, dropped from a #:multi-value list, and invalid when #:required.


2.16 (safsaf response-helpers)

2.16.1 Macros

Macro: static-manifest-entries x

Return MANIFEST’s hash table, mapping each logical path to its truncated content hash. static-manifest-ref looks one up without exposing the table.

Macro: static-manifest-root x

Return the directory MANIFEST’s logical paths are resolved against.

Macro: static-manifest-url-prefix x

Return the path prefix MANIFEST’s static route is mounted under, which static-url builds its URLs from.

Macro: static-manifest? x

Return #t if OBJ is a <static-manifest>.

2.16.2 Parameters

Parameter: current-static-manifest

Default value:

#f
Parameter: current-vary

Default value:

#f

2.16.3 Procedures

Procedure: add-vary-header response fields

Return RESPONSE with FIELDS merged into its Vary header, or RESPONSE itself when FIELDS is empty.

Merges rather than appends: a second Vary header would be legal, since Vary is a comma-separated list field, but one header is easier to read and to test against. A response already varying on * is returned unchanged, * being the broader claim.

Procedure: build-response/inherit response KEY: #:headers

Build a new response based on RESPONSE, preserving its version, status code, and reason phrase. #:headers defaults to the existing headers; override it to modify them.

Use this in handler wrappers that need to adjust headers on an inner handler’s response without losing the other response fields. The port field of RESPONSE is not carried over; it is #f on any response a handler builds.

Procedure: build-static-manifest root KEY: #:url-prefix #:hash-algorithm #:hash-length #:filter

Walk ROOT and return a <static-manifest> mapping each file’s path, relative to ROOT, to a truncated content hash.

ROOT

The directory holding the assets to serve.

#:url-prefix

The path prefix the static route is mounted under, which static-url builds its URLs from. Defaults to "/static".

#:hash-algorithm

'md5 (the default, via guile-lib’s (md5)), 'sha1 or 'sha256 (both via guile-gcrypt). MD5 is enough here: nothing depends on the hash being hard to forge, only on its changing when the content changes.

#:hash-length

How many characters of the hex digest to keep. Defaults to 12, which is 48 bits — plenty to tell two versions of a file apart, and short enough to keep URLs readable. Pass the full digest length (32 for md5, 40 for sha1, 64 for sha256) to truncate nothing.

#:filter

A predicate called with each regular file’s path relative to ROOT. A file it returns #f for is left out of the manifest. Use it where one tree mixes assets built ahead of time with content written at runtime, such as uploads, which have no business being fingerprinted.

Procedure: delete-cookie-header name

Return a Set-Cookie header pair that expires cookie NAME. Wraps (webutils cookie) delete-cookie.

Procedure: dump-manifest-to-file manifest path

Write MANIFEST’s entries to PATH as a Scheme s-expression: an alist of (logical-path . hash-string) pairs sorted by path.

Only the entries are written — ROOT and URL-PREFIX are deployment config, not content, so they are reconstructed by load-manifest-from-file from caller arguments. This lets a build pipeline ship one canonical manifest while different deploys point it at different roots or mount it under different URLs.

Entries are sorted so the output is reproducible — useful when the manifest itself ends up under version control or content-addressed storage.

Procedure: html-response shtml KEY: #:code #:headers #:charset #:cache-control #:etag #:request

Return an HTML response, rendering SHTML to a string up front.

SHTML

The tree to render, via write-shtml-as-html/streaming from (safsaf templating), so the extensions that module accepts — procedures in the tree, raw, doctype, *ENTITY* — work here too.

#:code

The response status code. Defaults to 200.

#:headers

Headers appended to the ones built here.

#:charset

The charset of the Content-Type. Defaults to "utf-8".

#:cache-control

A Cache-Control value in Guile’s header format — a list whose elements are a symbol or a (symbol . value) pair, such as '(no-cache) or '((max-age . 60)). For HTML that refers to fingerprinted assets, '(no-cache) is the usual choice: the page revalidates on every load while the assets it names stay cached indefinitely.

#:etag

One of:

#f

The default: no ETag header.

#t

Compute a strong ETag from the rendered body, with md5.

a string

Used as a strong ETag value.

a (string . boolean) pair

Used directly, as (tag . strong?).

#:request

The request being answered. Given alongside an ETag, it turns on conditional handling: where the request’s If-None-Match matches, the body is skipped and a 304 returned. An ETag derived from the data, such as a row version, skips the rendering as well on a revalidation hit; #:etag #t costs a render every time but needs nothing from the application. A 304 carries the ETag and nothing else: #:headers and #:cache-control are not applied to it.

The response is sent with Content-Length, so a render error becomes a clean 500 rather than a truncated 200, and an intermediary can size the body. For HTML produced incrementally — a very large page, or a tree whose procedures should write straight to the socket — use streaming-html-response, which sends it chunked.

Procedure: json-response data KEY: #:code #:headers #:unicode

Return a JSON response. DATA is either a pre-rendered JSON string (sent as-is) or a Scheme value, which is serialized via scm->json-string. #:unicode controls JSON-builder string escaping and defaults to #t, matching the streaming helpers — non-ASCII characters are emitted as \uXXXX escape sequences. Pass #:unicode #f to write characters inline as UTF-8.

Sent with Content-Length. For JSON that’s built incrementally — or large enough that materialising it as a string is wasteful — use streaming-json-response instead. To get the buffered framing while still using scm-alist->streaming-json/list->streaming-json-array, render into a string first via call-with-output-string and pass the result here.

Procedure: list->streaming-json-array proc lst port KEY: #:unicode

Write LST as a JSON array to PORT, applying PROC to each element to produce a JSON-serializable value. Each element is written individually via scm->json so the entire array need not be held in memory. #:unicode is passed on to scm->json, as in scm-alist->streaming-json.

Procedure: load-manifest-from-file path KEY: #:root #:url-prefix

Read a manifest previously written by dump-manifest-to-file and return a <static-manifest>.

PATH

The file to read.

#:root

The directory the manifest’s logical paths resolve against, which is where the files themselves live for this deployment. Defaults to ".".

#:url-prefix

The path prefix the static route is mounted under, which static-url builds its URLs from. Defaults to "/static".

A manifest read from a file is worth having where the assets come from a build step — a bundler, an asset pipeline, a package build. Hashing happens once, wherever that build runs, and the server starts without walking the tree.

Procedure: make-file-handler file-path KEY: #:cache-control #:content-type

Return a handler that serves the single file at FILE-PATH.

Useful for fixed-URL routes:

(route 'GET '("favicon.ico")
       (make-file-handler "./public/favicon.ico"))
FILE-PATH

The file to serve. Unlike make-static-handler, no part of it comes from the route, so the route needs no wildcard capture.

#:cache-control

A Cache-Control value in Guile’s header format, an alist such as '((max-age . 3600)).

#:content-type

Overrides the MIME type guessed from the extension. Pass a symbol, such as 'image/png, or a full header value, such as '(text/html (charset . "utf-8")).

A 200 carries Content-Length from stat(2), and an ETag and Last-Modified for conditional requests; If-None-Match and If-Modified-Since are both honoured, the first taking precedence per RFC 7232 §6. A missing file, or one that is not a regular file, is a 404.

Procedure: make-fingerprinted-static-handler manifest KEY: #:cache-control

Return a handler that serves files from MANIFEST under fingerprinted, content-addressed URLs.

Use with a route that captures a version segment and a wildcard path:

(route 'GET '(version . path)
       (make-fingerprinted-static-handler manifest))

The handler checks that the captured version matches the file’s current hash in MANIFEST. On match: 200 with #:cache-control (default '(public (max-age . 31536000) immutable)). On mismatch or missing file: 404.

Templates build URLs with static-url, which uses the manifest’s current hash for each asset. Old URLs for files whose content has changed simply 404 — clients pick up the new URL on the next HTML load, which should be served with Cache-Control: no-cache (or similar) so it revalidates.

Like make-static-handler, the handler emits Content-Length, Last-Modified, and ETag, and honours conditional requests with 304s. ETag handling is redundant under immutable caching but harmless.

Procedure: make-static-handler root-dir KEY: #:cache-control

Return a handler that serves static files from ROOT-DIR.

The handler expects route params to contain a wildcard capture named ’path (the file path segments). Use with a wildcard route:

(route 'GET '(. path) (make-static-handler "/path/to/public"))

Successful 200 responses carry Content-Length from stat(2), so the body is sent without chunked framing, plus an ETag and Last-Modified for conditional requests. Supports If-None-Match and If-Modified-Since for 304 responses (If-None-Match takes precedence per RFC 7232 §6).

#:cache-control, if given, is a Cache-Control value in Guile’s header format — a list whose elements are a symbol or a (symbol . value) pair, e.g. ’((max-age . 3600)) or ’(no-cache).

Works with /gnu/store and /nix/store paths: the ETag is derived from the canonical store path (immutable, content-addressed), so it stays stable across rebuilds that produce identical output. Files with a very low mtime (as produced by the store’s timestamp normalization) use the time this handler was created as Last-Modified instead, so that conditional requests behave sensibly.

For serving a single file at a fixed URL, use make-file-handler.

Procedure: make-static-manifest-handler-wrapper manifest

Return a handler wrapper that parameterizes current-static-manifest to MANIFEST for the duration of each request.

Apply via wrap-routes so templates downstream can call static-url without an explicit #:manifest argument:

(wrap-routes routes ... (make-static-manifest-handler-wrapper manifest))

Equivalent to wrapping run-safsaf in parameterize, but co-located with the rest of the wrapper stack and scoped to whichever subtree it’s applied to.

Procedure: make-vary-collector

Return a fresh collector for current-vary.

Used by vary-handler-wrapper, which parameterizes current-vary to one of these for the duration of a request. Outside such a parameterization current-vary is #f and vary-on! does nothing.

Procedure: negotiate-content-type request OPT: supported KEY: #:extensions

Return the MIME type symbol to answer REQUEST with.

REQUEST

The request to negotiate for.

SUPPORTED

The types the caller can produce, best first. Optional, defaulting to '(text/html application/json).

#:extensions

An alist mapping file extension strings to MIME type symbols, for the path-based half of the negotiation. Defaults to .json, .html and .txt.

The URL path extension is checked first, and wins if the type it implies is in SUPPORTED. Otherwise the Accept header decides, and the first of its types that appears in SUPPORTED is returned. When nothing matches, the first element of SUPPORTED is.

Where the Accept header decides — or could have decided, the fallback included — the dependency is recorded with vary-on!, so vary-handler-wrapper writes Vary: Accept on the response. Nothing is recorded when the extension settled the choice or when SUPPORTED has one element, since the answer then cannot vary.

Note that the router matches raw path segments, so it does not know about these extensions. A route ending in a literal only matches a request carrying an extension if the extension is part of the pattern — write '("state.json") alongside '("state"), or capture the segment. A route ending in a capture does match, but binds the extension along with the value; recover the identifier with strip-path-extension.

Procedure: not-found-response OPT: body KEY: #:headers #:charset

Return a 404 Not Found response. BODY is the text to send, and defaults to "Not Found"; #:headers and #:charset are passed to text-response.

Procedure: redirect-response path KEY: #:code #:headers

Return a redirect response to PATH (a string). Sent with Content-Length 0 (empty body). #:code sets the status and defaults to 303 See Other; #:headers appends to the Location header built here.

Procedure: scm-alist->streaming-json alist port KEY: #:unicode

Write ALIST as a JSON object to PORT, streaming each value as it is produced. If a value in the alist is a procedure, it is called with PORT so it can write its own JSON representation directly. Otherwise the value is serialized via scm->json, with #:unicode passed on; it defaults to #t, escaping non-ASCII characters as \uXXXX sequences.

Procedure: set-cookie-header name value KEY: #:path #:domain #:max-age #:secure #:http-only #:expires

Return a Set-Cookie header pair suitable for inclusion in a response headers alist. Wraps (webutils cookie) set-cookie.

Example:

(values (build-response
         #:headers (list (set-cookie-header "session" token
                                            #:path "/"
                                            #:http-only #t
                                            #:secure #t)))
        "ok")
Procedure: static-manifest-ref manifest logical-path

Return the truncated hash for LOGICAL-PATH in MANIFEST, or #f if LOGICAL-PATH is not present.

Procedure: static-url logical-path KEY: #:manifest

Return the fingerprinted URL for LOGICAL-PATH under #:manifest.

LOGICAL-PATH is the file’s path relative to the manifest’s root, e.g. "app.css" or "img/logo.png".

Raises an error if #:manifest is #f or LOGICAL-PATH is not in it — silently returning an un-fingerprinted URL would mean serving uncached assets without warning.

The result is built from the manifest’s url-prefix and the file’s truncated content hash, e.g. "/static/a1b2c3d4e5f6/app.css". For reverse-routing via path-for instead, look the hash up with static-manifest-ref and pass it to path-for directly.

Procedure: streaming-html-response shtml KEY: #:code #:headers #:charset #:cache-control #:etag #:request

Return an HTML response that streams SHTML to the client.

SHTML

The tree to render, via write-shtml-as-html/streaming from (safsaf templating). Procedures in the tree are called as the body is written rather than beforehand.

#:code

The response status code. Defaults to 200.

#:headers

Headers appended to the ones built here.

#:charset

The charset of the Content-Type. Defaults to "utf-8".

#:cache-control

As in html-response.

#:etag

A string, a (string . boolean) pair, or #f. Computing one from the body is not offered here: the response would have to be buffered to hash it, which is what this procedure exists to avoid. Derive it from the data instead, such as a row version.

#:request

The request being answered. Given alongside an ETag, a matching If-None-Match is answered 304 without rendering. As in html-response, the 304 carries the ETag and nothing else.

The response is sent chunked, which commits the status before rendering starts, so an error part-way through the document produces a truncated 200 rather than a clean 500. For a whole page that fits in memory, html-response gets a Content-Length and a chance to fail properly.

Procedure: streaming-json-response thunk KEY: #:code #:headers

Return a JSON response whose body is written incrementally by THUNK. THUNK is a procedure of one argument (the output port). Use scm-alist->streaming-json and list->streaming-json-array inside THUNK to write JSON without materializing the entire response in memory.

Sent with Transfer-Encoding: chunked — prefer json-response for small payloads, since chunked framing commits the response status before the body is built (a thunk that throws midway produces a truncated 200).

Procedure: strip-path-extension segment

Remove a trailing file extension from SEGMENT, a single path segment.

The router matches raw path segments, so a route parameter captured from a request like /build/1905ae71-…-bd31dfa5fca0.json still carries the .json that selected the content type. Strip it before using the value as an identifier:

(let ((id (strip-path-extension
           (assoc-ref (current-route-params) 'id))))
  ...)

Everything from the last dot onwards is removed. A segment with no dot is returned unchanged, as is one whose only dot is leading — ".hidden" is a name, not an extension.

Apply this only to a segment known to be an identifier: on a captured file name it would strip an extension that is part of the name.

Procedure: text-response str KEY: #:code #:headers #:charset

Return a plain text response. STR is the text string to send. Sent with Content-Length. #:code sets the status and defaults to 200, which with #:headers makes this the constructor for any response whose body is a plain string. #:charset defaults to "utf-8".

Procedure: vary-fields

Return the request header names recorded by vary-on! for this request, in the order they were first recorded, or the empty list when nothing was recorded or no vary handler wrapper is in the stack.

Procedure: vary-header . fields

Return a Vary header pair listing FIELDS, for responses built where no vary handler wrapper will see them:

(json-response body #:headers (list (vary-header 'accept)))

Prefer vary-on! and the handler wrapper: they cover the error and 304 responses from the same URL, which have to carry the header too, and which are the ones a by-hand approach misses.

Procedure: vary-on! . fields

Record that the response depends on the named request header FIELDS, so that vary-handler-wrapper lists them in its Vary header.

FIELDS are symbols naming request headers, as (web http) spells them: accept, accept-language, cookie. Repeated fields are recorded once.

Call this from code that reads a request header to decide what to send. negotiate-content-type already does, whenever the Accept header could have changed its answer; hand-rolled negotiation needs it:

(define (wants-json? request)
  (vary-on! 'accept)
  (member '(application/json . ()) (request-accept request)))

Does nothing when no vary handler wrapper is in the stack, so a response built outside one carries no Vary header — see vary-header for setting it directly.


2.17 (safsaf response-helpers sse)

2.17.1 Macros

Macro: sse-event-comment x

Return the comment text of EVENT, written as lines prefixed with ‘:’, or #f if it has none.

Macro: sse-event-data x

Return the payload of EVENT, written as its data: lines, or #f if it has none.

Macro: sse-event-event x

Return the event name of EVENT, written as its event: line, or #f if it has none.

Macro: sse-event-id x

Return the Last-Event-ID of EVENT, written as its id: line, or #f if it has none.

Macro: sse-event-retry x

Return the reconnection delay of EVENT in milliseconds, written as its retry: line, or #f if it has none.

Macro: sse-event? x

Return #t if OBJ is an <sse-event>.

2.17.2 Parameters

Parameter: current-sse-disconnect-condition

Default value:

#f

2.17.3 Procedures

Procedure: make-sse-event KEY: #:data #:event #:id #:retry #:comment

Construct a Server-Sent Event value.

Keyword arguments are all optional:

#:data

A string that becomes the event payload. Multi-line strings are split and each line is emitted as a separate data: line per the SSE specification, so newlines inside it are fine.

#:event

An event-name string. Consumers can filter by name via EventSource.addEventListener(name, ...). Must not contain newlines.

#:id

A Last-Event-ID string. The browser echoes this back in the Last-Event-ID header when it reconnects, enabling replay. Must not contain newlines.

#:retry

A non-negative integer (milliseconds) telling the browser how long to wait before reconnecting after a drop.

#:comment

A string sent as an SSE comment (each line prefixed with ‘:’). Useful as a keepalive to stop proxies closing idle streams. Multi-line strings are split one comment line per input line.

At least one of #:data, #:comment, #:retry, #:event or #:id must be given for the event to cause anything observable on the client.

Procedure: request-last-event-id request KEY: #:query-param

Return the client’s Last-Event-ID as a string, or #f if none.

Checks the standard Last-Event-ID header first. If that’s absent and #:query-param is a string, falls back to that query-string parameter — browsers’ EventSource can’t set headers on the initial connection, so a query-string carrier is a common way to request replay on first connect. Pass #:query-param #f to disable the fallback.

The value is returned as a string; the SSE spec treats IDs opaquely. Applications that number events with integers should convert the result themselves.

Procedure: sse-client-disconnected? exn

Return #t if EXN indicates the SSE client has disconnected. Such an exception is raised by the emit procedure sse-response passes its body procedure, when a write to the client fails.

Procedure: sse-response request body-proc KEY: #:headers #:keepalive-interval #:retry

Return a streaming Server-Sent Events response.

REQUEST

The incoming <request>. It is needed so that the underlying socket port can be flushed after every event: the web server wraps the socket in a chunked output port that buffers writes, so flushing the body port alone leaves the event unsent.

BODY-PROC

A procedure of one argument, emit, which accepts the same keyword arguments as make-sse-event#:data, #:event, #:id, #:retry and #:comment. Each call to emit is serialised with any keepalive comments, written to the client and flushed before it returns. If the client has disconnected, emit raises an exception satisfying sse-client-disconnected?.

#:headers

Headers appended to the defaults, which are text/event-stream, cache-control: no-cache and x-accel-buffering: no.

#:keepalive-interval

The seconds of inactivity after which a comment line is written to hold the connection open, HTTP proxies commonly dropping an idle connection after 30 to 60 seconds. Defaults to 15. Writing an event restarts the interval, so a stream already sending regularly sends no comments. Pass #f or a non-positive number to send none at all.

#:retry

A reconnection delay in milliseconds. When given, an initial retry: directive tells the browser how long to wait before reconnecting.

A disconnect is only discovered by writing, so emit reports one just when there is something to send. A BODY-PROC that blocks waiting for an event that may never arrive learns of one from current-sse-disconnect-condition instead, which is parameterised around it for that purpose; see there for the pattern. The response is torn down either way, so a BODY-PROC that blocks for ever does not hold the connection open.

Procedure: write-sse-event event port

Write EVENT to PORT in SSE wire format and finish with a blank line so the browser dispatches it. Does not flush PORT.


2.18 (safsaf router)

2.18.1 Macros

Macro: compiled-route-handler x

Return the handler procedure of COMPILED-ROUTE.

Macro: compiled-route-method x

Return the method COMPILED-ROUTE was compiled from, in the form the route was written with: a symbol, a list of symbols, or '*.

Macro: compiled-route-name x

Return the name of COMPILED-ROUTE, or #f if unnamed. The routes (safsaf http-compliance) generates carry names of their own, so this does not always name a route the application wrote.

Macro: compiled-route-pattern x

Return the full pattern of COMPILED-ROUTE, with the prefixes of any enclosing route-groups already appended.

This is what labels a metric with the route that served a request; the request path is unbounded and unsuitable.

Macro: compiled-route? x

Return #t if OBJ is a <compiled-route>.

Macro: route-group-children x

Return the list of child routes and groups of ROUTE-GROUP.

Macro: route-group-name x

Return the name of ROUTE-GROUP, or #f if unnamed.

Macro: route-group-prefix x

Return the prefix pattern of ROUTE-GROUP.

Macro: route-group? x

Return #t if OBJ is a <route-group>.

Macro: route-handler x

Return the handler procedure of ROUTE.

Macro: route-method x

Return the HTTP method of ROUTE.

Macro: route-name x

Return the name of ROUTE, or #f if unnamed.

Macro: route-pattern x

Return the URL pattern of ROUTE.

Macro: route? x

Return #t if OBJ is a <route>.

2.18.2 Parameters

Parameter: current-reverse-routes

Default value:

#f
Parameter: current-route-params

Default value:

()

2.18.3 Procedures

Procedure: compile-allowed-methods compiled-routes pattern

Return a procedure of one argument, a list of path segments, giving the HTTP methods COMPILED-ROUTES allows for that path.

Only a path that PATTERN matches may be passed to it. For those paths it returns what find-allowed-methods would return over the whole of COMPILED-ROUTES, having settled most of the work when it was called rather than on every request. This is what the generated routes in (safsaf http-compliance) build their Allow header from.

Procedure: compile-routes routes

Compile a route tree (route, route-group, or list) into two values: 1. An ordered list of <compiled-route> records ready for matching. 2. A <reverse-routes> record for use with path-for.

The last route must be a catch-all — ’* as the whole pattern, matching every path — so that every request is handled. A pattern with fixed segments before a rest parameter, such as '("static" . rest), does not count as one.

Procedure: find-allowed-methods compiled-routes path-segments

Scan COMPILED-ROUTES for routes whose path matches PATH-SEGMENTS, collecting their HTTP methods. Returns a deduplicated list of method symbols, or ’() if no route’s path matches.

Routes matching any method contribute nothing, since there is no method they would answer 405 to, so the catch-all drops out on its own and does not need excluding.

Procedure: find-matching-route compiled-routes method path-segments

Find the first matching route for METHOD and PATH-SEGMENTS. Returns (values compiled-route bindings) on match, or (values #f #f) on no match.

Use this rather than match-route when the matched route itself is needed and not just its handler, for instance to label the request with the route that served it. Handlers do not identify a route: routes can share one, and the HEAD routes (safsaf http-compliance) generates deliberately reuse the handler of the GET route for their path.

Procedure: flatten-routes routes

Flatten ROUTES, which may be a route, route-group, or list of either, into a flat list of routes in match order. Group prefixes are appended to the patterns of their children, so each returned route matches the same paths as it does within the tree, without reference to its enclosing groups.

The returned routes are fresh <route> records; mutating them does not affect ROUTES.

Procedure: make-route-group prefix KEY: #:name

Create an empty route group with PREFIX. Children can be added later with route-group-add-children!.

Procedure: match-route compiled-routes method path-segments

Find the first matching route for METHOD and PATH-SEGMENTS. Returns (values handler bindings) on match, or (values #f #f) on no match.

Procedure: method-spec-matches? method-spec method

Return #t if METHOD-SPEC, in any of the forms route accepts, matches METHOD.

Procedure: path-for group name OPT: params KEY: #:query #:fragment #:relative?

Generate a URL path for a named route within GROUP.

GROUP is a route-group value. NAME is either a symbol naming a route within GROUP, or a list of symbols for nested lookup where the last element is the route name and preceding elements are child group names.

(path-for routes 'users)
(path-for routes 'user '((id . "42")))
(path-for routes '(api items) '((id . "7")))

PARAMS is an alist mapping capture symbols to string values, or to a list of strings for rest parameters.

A capture that PARAMS does not supply is taken from (current-route-params), so a segment the current request already bound need not be repeated at every call site. This is what makes a capture in a group prefix workable — under a '(locale) prefix, (path-for routes '(lang post) '((id . "5"))) keeps the locale the request came in with, and passing locale explicitly moves to another one. Only a capture missing from PARAMS is inherited, and it is still an error if the request did not bind it either.

Optional keyword arguments:

#:query

An alist of query parameters, ((key . value) ...). A key is a symbol or a string; a value must be a string.

#:fragment

A fragment string, without the leading #.

#:relative?

When #t, the leading / is left off.

The compiled route table is read from a parameter that the server binds for each request, so this works inside a handler and anything a handler calls; called outside a request it raises.

Procedure: route method pattern handler KEY: #:name

Create a route.

METHOD

An HTTP method symbol, a list of them, or '* for any method.

PATTERN

A list of segments: a string matches literally, a symbol captures the segment under that name, and a two-element list (proc name) captures it only when proc accepts it. A dotted tail captures whatever segments are left.

HANDLER

A procedure (request body-port) returning two values, a response and a body.

#:name

A symbol naming the route for reverse routing with path-for. Optional; a route without one cannot be reached by name.

Procedure: route-group prefix KEY: #:name . children

Create a route group, which serves its children under a shared path prefix.

PREFIX

A pattern list, in the same syntax as a route pattern, prepended to the pattern of every route beneath it.

CHILDREN

The routes and route-groups of the group, given as rest arguments and matched in the order written.

#:name

A symbol naming the group, so that path-for can also reach the routes inside it as '(group-name route-name). Optional. Named or not, the children’s own names stay visible in the enclosing scope as well; where two share a name, the first defined wins.

Procedure: route-group-add-children! group new-children

Append NEW-CHILDREN to GROUP’s child list.

Procedure: wrap-routes routes . wrappers

Apply WRAPPERS to every handler in ROUTES, which may be a route, route-group, or list of either. When multiple wrappers are given, the first wrapper in the list wraps outermost (runs first on the request, last on the response).

The handlers are replaced in place and ROUTES itself is returned, so that the route and route-group objects keep the identity path-for looks them up by. Use flatten-routes to get routes that can be modified without touching the tree.


2.19 (safsaf templating)

2.19.1 Procedures

Procedure: write-shtml-as-html/streaming node port

Write SHTML NODE to PORT.

Procedures in the tree are dispatched by arity:

  • A thunk (zero-argument procedure) is called and its result is rendered recursively as SHTML.
  • Anything else is called as (proc port) and may write HTML directly to PORT.

In addition to standard SHTML, three extensions are recognised:

  • (raw HTML) — write HTML to PORT unescaped. Use only with trusted input. script and style elements hold raw text, which the browser does not unescape, so their content has to be written this way: (script (raw "if (a && b) go();")).
  • (doctype NAME) — write <!DOCTYPE NAME>. NAME may be a symbol or a string. The htmlprag-style (*DECL* DOCTYPE html) form is also accepted.
  • (*ENTITY* NAME) — write &NAME;. NAME may be a symbol, a string, or an integer (for numeric entities).

Attribute values: a string is escaped; #t produces a bare boolean attribute; #f omits the attribute; any other value is displayed and escaped.

An HTML void element — br, img, meta and the rest — is written without a closing tag when it has no content; given content, it gets one, which is not valid HTML for those elements.


2.20 (safsaf uri)

2.20.1 Procedures

Procedure: split-and-decode-uri-path/safsaf path

Split PATH on / and percent-decode each segment, dropping the empty segments, as (web uri) split-and-decode-uri-path does:

(split-and-decode-uri-path/safsaf "/foo/bar%20baz/")
 ("foo" "bar baz")

A faster alternative to split-and-decode-uri-path, walking PATH once and consing only the segments it returns.

A segment holding a byte over 127 and no percent-encoding is returned unchanged, where split-and-decode-uri-path raises. A URI parsed from the wire cannot contain one, and answering a request is better than throwing at a hand-built one.

Procedure: uri-decode/safsaf str KEY: #:decode-plus-to-space?

Percent-decode STR as (web uri) uri-decode does, turning + into a space unless #:decode-plus-to-space? is #f.

A faster alternative to uri-decode: STR is returned as it is when there is nothing to decode, and handed to uri-decode when there is. One difference follows: a STR holding a character over 127 and nothing to decode is returned unchanged, where uri-decode raises.

Note that uri-decode’s #:encoding is not offered here: it exists to return raw bytes, and a caller wanting those wants uri-decode itself.

Procedure: uri-encode/safsaf str

Percent-encode STR, as (web uri) uri-encode with its default character set does, escaping everything outside the RFC 3986 unreserved set of the ASCII alphanumerics and - . _ ~.

A faster alternative to uri-encode: STR is returned as it is when nothing needs escaping, and handed to uri-encode when something does.

Callers wanting uri-encode’s #:unescaped-chars or #:encoding should call uri-encode itself; this covers the default, which is what generating a URL from route segments and query parameters needs.


2.21 (safsaf utils)

2.21.1 Procedures

Procedure: multipart-text-fields parts

Extract text fields from multipart PARTS as an alist of (name . value). File upload parts (those with a filename parameter) are excluded.

Procedure: parse-form-body request body-port

Read and parse a URL-encoded form body from REQUEST. Returns an alist of string key-value pairs.

Procedure: parse-multipart-body request body-port

Read and parse a multipart/form-data body from REQUEST. Returns a list of <part> records from (webutils multipart), or ’() when the request has no body. Raises when the request’s content type is not multipart/form-data. Use parts-ref, parts-ref-string, part-body, etc. to access parts.

Procedure: parse-query-string request

Parse the query string from REQUEST. Returns an alist of string key-value pairs, or ’() if no query string.

Procedure: request-cookie-ref request name OPT: default

Return the value of cookie NAME from REQUEST, or DEFAULT (itself defaulting to #f) if not found.

Procedure: request-cookies request

Return the cookies from REQUEST as an alist of (name . value) pairs. The value is a string, or #t for a cookie sent without an =. Returns ’() if no Cookie header is present. Importing (webutils cookie) registers the Cookie header parser with (web http).

Procedure: same-site->extensions same-site

Convert a SameSite value to a Set-Cookie extensions alist suitable for the #:extensions keyword argument of (webutils cookie) set-cookie.

SAME-SITE accepts ’strict, ’lax, ’none, or #f (omit the attribute); any other value raises an error.


Appendix A Version History

Unreleased
  • run-safsaf answers OPTIONS * rather than raising on it. The asterisk-form target names the server rather than a resource, so Guile parses it with a request-uri of #f and uri-path raised, which the exception handler wrapper turned into a 500 — a server fault reported for a request that was well formed. It is now answered 200 with no body, RFC 9110 having the form be a ping.
    • #:asterisk-options-handler takes a handler of the usual (request body-port) signature to answer it instead, for reporting the methods the server serves anywhere, a protocol it implements, or a 503 while draining before shutdown. default-asterisk-options-handler is exported for a handler that wants to fall back to it.
    • It runs before the route table and so outside wrap-routes: no logging, exception or CORS wrapper sees the request, and nothing catches an exception the handler raises.
    • A request whose path holds an escape that cannot be decoded, such as /%FF, is answered 400 rather than 500 for the same reason. That one is not configurable — the target is malformed and there is one right answer. An escape that is merely odd rather than undecodable, like /%ZZ, reaches the route table as before.
    • Both still reach no route, so a request observer still sees them with #:route #f; only the status code they are reported with has changed.
  • cors-handler-wrapper sends its header fields to every request, and writes no Vary, where #:origins names one origin to answer with — a single site, or '("*"). The fields can only take that one value there, so only their presence would have depended on the request, and a request from anywhere else gets the same field, compares it with its own origin and is refused, which is what a response with no field on it achieved anyway.
    • What this buys is caching. A shared cache stores one response rather than one per calling site, and an intermediary that declines to cache anything carrying a Vary it does not understand caches this — which for a public read-only API, the case '("*") is for, is the difference between a cached response and an origin fetch every time.
    • Naming a second origin brings the dependency back: the field is then chosen per request, so every response carries Vary: Origin as before, the ones with no CORS fields on them included. A table whose #:origins comes from configuration can therefore carry the Vary in one deployment and not in another.
    • Responses to same-origin requests now carry the CORS fields under a single-origin configuration, where before they carried none. Nothing can read them that could not before — a cross-origin request sends Origin and got the fields already — but it is visible, and an argument for wrapping the routes that need CORS rather than the whole table.
  • cors-handler-wrapper writes the Vary header field itself rather than only recording the dependency for vary-handler-wrapper to write. Whether a response carries the CORS headers depends on the request’s Origin wherever more than one origin is named, and an application that had not applied the other wrapper got no Vary at all — a shared cache then hands the copy made for one origin, or the copy made for a request that carried no Origin and so has no CORS headers on it, to a request from another, and the browser blocks a request the server meant to allow.
    • The dependency is still recorded with vary-on!, and vary-handler-wrapper merges rather than appends, so where it is applied the two still produce one field naming everything the request consulted.
    • Responses the wrapper passes through untouched carry the field too; those are the ones a cache must not hand to a cross-origin request.
  • cors-handler-wrapper replaces header fields of its own names on the response rather than adding them beside what is there. A response carrying two Access-Control-Allow-Origin fields fails the browser’s CORS check outright, so a handler that set its own, or a route group wrapped with CORS inside a table wrapped with it again, blocked the very requests both were meant to allow.
  • cors-handler-wrapper raises when an entry in #:origins is not something an Origin header field could hold. A browser sends the origin as a scheme, host and port and nothing else, lowercased, so an entry with a path — the trailing slash a URL copied from an address bar carries — or with no scheme, or with a capital letter, could never match, and CORS was quietly off for that site with nothing said about it. "*" and "null" are accepted as before.
  • cors-handler-wrapper works out the preflight’s Access-Control-Allow-Methods from the route table rather than from a list given to it, and #:methods now defaults to #f meaning exactly that. A preflight goes to the handler like any other request, and the Allow header of the response it reaches — which the routes (safsaf http-compliance) generates build whether they answer OPTIONS or 405 — names the methods that path serves.
    • What a path serves is something the route table already states, and #:methods was a copy of it that nothing kept in sync. Adding a route to a group and forgetting the list made that route unreachable from a browser on another origin, with no request arriving and nothing logged to say so; the previous default, '(GET POST PUT DELETE PATCH), was wrong for most tables in the other direction.
    • The list is now worked out per path rather than per wrapper, so two paths under one wrap-routes that serve different methods each get their own — which no single setting could give.
    • #:methods still overrides, for reporting something other than what the table serves.
    • A preflight the handler answers with an ok status keeps that response, with the CORS headers added, so a route of the application’s own answering OPTIONS decides what it sends. One answered otherwise — a generated 405 route, or a wrapper inside this one refusing a request that carries no credentials, as a preflight never does — has its Allow header reported on a 204 built by the wrapper, a preflight being answered only by an ok status.
    • Handler wrappers applied inside this one now run on preflights, where before the preflight was answered without reaching them.
    • Where nothing states what the path allows, the method the preflight asked about is reported back, leaving the table to answer the request itself.
  • cors-handler-wrapper raises when #:allow-credentials? is #t together with a wildcard in #:methods, #:headers or #:expose-headers, as it already did for #:origins. With credentials a browser reads "*" as the literal name of a method or header field, matches nothing against it, and refuses or withholds everything the wildcard was meant to allow — a configuration that looks the most permissive there is and behaves as the least.
  • cors-handler-wrapper recognises a preflight by the Access-Control-Request-Method header a browser always sends with one, rather than by the method alone. A cross-origin OPTIONS without it is asking what the resource supports, and used to be answered with the preflight’s 204 and no Allow header at all. It now goes to the handler, so a route table with generated OPTIONS routes answers it properly, with the CORS headers added on the way out.
    • A request that is a preflight is answered exactly as before.
  • cors-handler-wrapper sends each of the two responses only the headers that apply to it. Access-Control-Allow-Methods, Access-Control-Allow-Headers and Access-Control-Max-Age answer the preflight and no longer ride on every cross-origin response; Access-Control-Expose-Headers describes a response’s own fields and no longer rides on the preflight. Browsers ignored the misplaced ones, so this changes what is sent rather than what happens.
  • (safsaf http-compliance) gains add-options-routes, which generates an OPTIONS route for every path that has none, answering 204 with an Allow header naming the methods that path allows. Before this an OPTIONS request to a known path was answered 405 by the generated 405 routes, or 404 by the catch-all, neither of which is an answer to the question OPTIONS asks.
    • add-generated-http-compliance-routes applies it, between add-head-routes and add-method-not-allowed-routes, and gains #:options? and #:options-handler alongside the keyword arguments for the other two. Applications calling it get the OPTIONS routes without changing anything.
    • This is also what routes a CORS preflight to the wrapper that answers it. cors-handler-wrapper answers the preflight inside the handler it wraps, so the preflight has to match a route in the wrapped group — and a group written as (route 'GET ...) routes no OPTIONS at all, leaving the preflight to be answered 404 or 405 elsewhere in the table without the CORS headers, and the cross-origin request blocked. Generating the OPTIONS routes inside the same wrap-routes fixes that without spelling (route '(GET OPTIONS) ...) on each route, which is what the manual asked for before.
    • The Allow header the 405 routes send now names OPTIONS, the OPTIONS routes being generated before them.
    • Paths already served for OPTIONS are left alone, by an explicit OPTIONS route, a multi-method route such as '(GET OPTIONS), or a route matching every method.
    • add-options-routes has to be applied after add-head-routes and before add-method-not-allowed-routes, as the latter’s routes match every method and would leave it nothing to generate. Applying it after them, or twice, raises rather than silently generating nothing.
    • OPTIONS *, the asterisk-form request target, is unaffected: it has no path for the router to match and is still answered 500.
  • The five handler wrappers that took keyword arguments but had no constructor gain one: make-cors-handler-wrapper, make-csrf-handler-wrapper, make-logging-handler-wrapper, make-security-headers-handler-wrapper and make-vary-handler-wrapper. Configuring one of these at a wrap-routes call site previously meant writing a lambda around it, because wrap-routes wants a procedure of one argument and the wrapper takes the handler first:
    (wrap-routes routes
      (lambda (handler)
        (csrf-handler-wrapper handler #:secure #t)))
    

    which is now

    (wrap-routes routes
      (make-csrf-handler-wrapper #:secure #t))
    
    • Every handler wrapper that takes options now has a make- constructor, so a configured wrapper reads the same way whichever one it is. #:secure #t on the CSRF cookie is the case that prompted this: every site served over HTTPS needs it.
    • The wrappers themselves stay exported and unchanged. Passing one bare to wrap-routes still applies it with its defaults, and applying one directly to a handler still works.
  • (safsaf handler-wrappers locale) gains the pieces needed to put the locale in the URL, which is the only arrangement that leaves a multilingual page shareable, indexable in each language, and cacheable. Selecting the language from a cookie or from Accept-Language returns different pages at one URL, and every cache between the server and the reader has to be told so.
    • locale-prefix-pattern builds the route-group prefix: (route-group (locale-prefix-pattern '("en" "fr")) ...). It matches those locales and nothing else. A bare '(locale) capture matches any segment, which would put the group’s index route at every one-segment URL there is.
    • make-locale-redirect-handler answers the URLs with no language in them, redirecting to the same path under the reader’s language. Mount it as an ordinary route after anything with a URL space of its own and before the catch-all. It answers 302, keeps the query string, and passes a path that already names a locale to #:handler-404 rather than redirecting it at itself.
    • path-in-locale and locale-alternates give the current page’s path in another language, for a switcher that keeps the reader where they are rather than returning them to the front page. hreflang-links renders the corresponding <link rel="alternate"> elements, with #:x-default for the negotiating URL.
    • make-locale-handler-wrapper gains #:set-content-language?, which labels responses with the locale, skipping those with no representation to describe such as redirects and 304s.
    • The manual has a new Internationalisation section covering the whole arrangement, and the blog-site example has been converted to it.
  • make-locale-handler-wrapper now records what the language choice depended on, so that vary-handler-wrapper declares it. Before this, an application selecting the language from a cookie or Accept-Language served pages that a shared cache would hand to the next reader whatever language they wanted, with nothing in the response to stop it.
    • Nothing is recorded when the route strategy decided, since the URL already tells the languages apart, nor when only one locale is supported. Otherwise accept-language is recorded whenever that strategy is enabled — whichever strategy decided this particular request, because Vary describes the stored response and must be the same for every request the URL answers.
    • #:vary-on-cookie? (default #f) adds Cookie. Left off, the cookie case is under-declared exactly as it is in Django and Symfony: a reader with no cookie is served by Accept-Language and their page stored under Vary: Accept-Language, so a later request carrying a locale cookie but the same Accept-Language matches that entry and gets the other language. Turned on, it keys the entry on the whole cookie jar and costs essentially all shared caching. Putting the locale in the URL avoids the trade rather than taking a side in it.
    • #:vary? #f turns the recording off.
  • path-for now fills a capture the caller does not supply from (current-route-params), and only raises when the current request did not bind it either. This is what makes a capture in a group prefix usable: under a locale prefix, (path-for routes 'show-post `((id . ,id))) keeps the language the request came in with, so adding the prefix to an application does not mean editing every link it renders. Passing the capture explicitly still wins, which is how a link points at another language.
  • sse-response no longer holds a connection open when the client goes away while the handler has nothing to send. A disconnect is only noticed on writing, so a body procedure blocked waiting for an event that never arrived never reached an emit to hear about it, and the fiber, the socket and its file descriptor stayed put for the life of the process.
    • The new current-sse-disconnect-condition parameter holds that condition while a body procedure runs. One that may never emit again can compose it into whatever it blocks on with choice-operation, and so unwind and release its own resources, such as the subscription it was reading from, rather than waiting for its next emit.
    • Keepalive comments now fire after #:keepalive-interval seconds idle rather than on a fixed schedule: writing an event restarts the interval, so a stream already sending regularly sends none.
  • New (safsaf handler-wrappers vary), which adds a Vary header naming the request headers a response depended on. A negotiated URL returns different bodies to different requests, so without it a shared cache keyed on the URL alone can serve one representation to a request that asked for another.
    • negotiate-content-type records that it read the Accept header, and the wrapper writes the header once the response exists. Code negotiating by hand records with the new vary-on!.
    • Recorded only when the Accept header actually decided: a path extension leaves the representations on distinct URLs, and a single supported type cannot vary. A header added where it is not needed costs cache entries for nothing.
    • Being a wrapper, it also covers the 304s and error responses from a negotiated URL, which need it just as much and are the ones a handler setting the header by hand misses. One response cached without it poisons the URL for every representation of it.
    • vary-header builds the header directly, for responses no wrapper will see.
  • New (safsaf i18n javascript), for the translations a script has to resolve for itself. i18n-scripts emits a message catalog as JSON in the page, followed by a runtime providing t and tn that behave as the Scheme ones do, ~a directives included.
    • The catalog covers the messages one page declares rather than the whole application, so there is no separate JavaScript translation domain to keep in sync and no extra request. The msgids are the ordinary ones and come from the same .po files.
    • This is for messages whose arguments only exist in the browser — a live character count, the size of a file just chosen, a plural whose count the server never sees. A string the server can resolve should still be rendered into a data- attribute and read back from the DOM.
    • Dates, numbers and units are deliberately not covered. The browser’s Intl API knows every locale’s rules for them already.
    • i18n-runtime-file returns the path of the installed runtime, to serve it from a route of its own with make-file-handler and have it cached between pages rather than inlined into each. i18n-runtime-javascript returns its source, for inlining it by hand or copying it into an asset directory at build time.
  • New catalog-ref, plural-forms and locale-plural-index in (safsaf i18n), for exporting translations rather than formatting them for immediate output. plural-forms returns a message’s plural forms without selecting one, so the count need not be known yet, and locale-plural-index gives the form index the current locale’s rule selects for a count.
    • catalog-ref tells an untranslated message from a translated one, which t and tn deliberately hide by falling back to the msgid. Anything exporting a catalog needs the distinction: tn uses n = 1 for an untranslated message rather than the locale’s plural rule, so a consumer applying that rule to the fallback would disagree with it.
  • The procedures in (safsaf http-compliance) accept a route list covering part of a table, not just a whole one. A list that does not end in a catch-all has the generated routes added at its end instead of before one, and a list that has already been through them can be composed into a larger one, which generates for the paths outside it and leaves its own alone.
    • This is for the part of a table wrapped differently from the rest. The generated routes have to be inside those wrappers too: a 405 for a path behind authentication should be as invisible to a visitor without it as the page is.
    • Which of the two a route list is follows from the list rather than being declared, so there is no new argument: the generated routes go before a catch-all where there is one, and last where there is not.
    • Such a list has to serve its paths on its own, every route for a path it covers being in it. Its generated 405 routes match every method, so a route for one of those paths later in the enclosing table would be shadowed, and the Allow header is worked out from that list alone.
    • A catch-all route somewhere other than the end is now an error rather than being left to compile-routes, which could only report that the last route it was given was not one.
  • add-method-not-allowed-routes no longer generates routes for paths a route matching any method already covers. That route comes before the generated ones, so they could never match; only unreachable routes are removed.
Version 0.3
  • run-safsaf passes twelve more options on to the knots web server unchanged, and documents them by pointing at it: #:family, #:addr, #:socket, #:ipv6-v6only?, #:listen-backlog, #:connection-idle-timeout, #:connection-accepted-hook, #:connection-closed-hook, #:active-request-tracking?, #:read-request-exception-handler, #:write-response-exception-handler and #:accept-exception-hook.
    • #:connection-accepted-hook and #:connection-closed-hook are the pair for measuring connections rather than requests: how many are opened, how long they last, how many requests each carries.
    • #:socket serves on a listening socket built elsewhere, which is how to serve on one inherited from an init system.
    • An option not given is left out of the call rather than passed on with a default repeated in run-safsaf, so the web server’s own default applies and goes on applying if it changes. This is why they have no meaningful default of their own: #f is a value the web server acts on for several of them.
    • #:post-request-hook and #:call-handler-with-body-port? are not accepted, being what #:request-observer is built on and what the handler signature depends on.
  • run-safsaf gained #:request-observer, called once for every request after the response has been written. Unlike a handler wrapper it sees every request the server handled, including those no handler ran for, and it sees the response as delivered rather than as returned.
    (define* (observe request response
                      #:key route duration complete?
                      #:allow-other-keys)
      ...)
    
    (run-safsaf routes #:request-observer observe)
    
    • route is the <compiled-route> matched. Label metrics with its compiled-route-pattern: the request path is unbounded, and the generated routes replace compiled-route-name with their own.
    • route is #f for a request that reached no route, either because the server could not parse it and answered 400 itself (request is #f too), or because its target is not a path the router can match and it raised, answered 500 — an asterisk-form target (OPTIONS *), whose request-uri is #f, or a path holding an undecodable percent-escape (/%FF). An observer that logs the path should guard it for these.
    • duration covers writing the response body, which a handler wrapper cannot see; handler-duration is the part before it.
    • complete? is #f when the response was never written in full, because the client went away or the body procedure raised.
    • request-body-bytes-read and response-body-bytes-written are the decoded body sizes.
  • find-matching-route returns the <compiled-route> a request matched, where match-route returns only its handler. Handlers do not identify a route — routes can share one, and the generated HEAD routes reuse the handler of the GET route for their path. <compiled-route> now keeps the pattern and name it was compiled from, readable with compiled-route-pattern and compiled-route-name.
  • New (safsaf http-compliance) module. Each of its procedures takes a route list and returns it with generated routes inserted before the catch-all. These are ordinary routes, so handler wrappers apply to them: call inside wrap-routes.
    (wrap-routes
     (add-generated-http-compliance-routes routes)
     logging-handler-wrapper)
    
    • add-generated-http-compliance-routes generates both sets of routes below, in the order they have to be applied. #:head? and #:method-not-allowed? leave out either set; #:method-not-allowed-handler controls the 405 response.
    • add-method-not-allowed-routes generates the routes that answer 405 Method Not Allowed. #:handler customises the response; it takes (request body-port allowed-methods), like any handler plus the methods the path does allow.
    • add-head-routes generates a HEAD route for every path with a GET route and no HEAD route, running the GET handler. Applying it after add-method-not-allowed-routes — where it would generate nothing, since those routes match every method — raises an error.
    • default-method-not-allowed-handler moved here from (safsaf) and gained the body-port argument.
  • The dispatcher no longer special-cases HEAD requests or 405 responses; it matches a route and calls its handler. Both are now the generated routes’ job, and both pass through the handler wrappers.
    • run-safsaf’s #:method-not-allowed? and #:method-not-allowed-handler keyword arguments have been removed, along with the automatic HEAD handling that needed no opt-in. Applications wanting either now call the corresponding procedure from (safsaf http-compliance).
    • HEAD responses now carry the Content-Length the GET response would have carried, rather than 0.
    • A HEAD request to a path with no GET route responds 405 without a body, as HEAD responses should.
  • method-spec-matches? tests a method against a route’s method in any of the forms route accepts.
  • flatten-routes flattens a route tree into a list of routes with group prefixes appended to their patterns.
Version 0.2
  • Server-Sent Events, in a new (safsaf response-helpers sse) module.
    • sse-response returns a streaming text/event-stream response that runs a body thunk against an emit procedure.
    • Disconnect detection via the &sse-client-disconnected exception.
    • request-last-event-id reads Last-Event-ID for resumption.
    • <sse-event> records reject embedded newlines on construction.
  • Internationalisation, in a new (safsaf i18n) module.
    • t (singular) and tn (plural) with (ice-9 format) interpolation.
    • In-Scheme catalog storage (fiber-safe, unlike libintl).
    • .po parser: multi-line continuations, escape sequences, comments.
    • Plural rules with defaults for common languages; overridable via register-plural-rule!.
    • parse-accept-language / best-accept-language with q-value sorting and region→language fallback.
  • New (safsaf handler-wrappers locale).
    • make-locale-handler-wrapper with configurable #:supported, #:default, #:detect order (route, cookie, Accept-Language), #:cookie-name, and #:route-param.
  • Fingerprinted static assets.
    • build-static-manifest walks a directory, hashing files with (md5) or (gcrypt hash); #:filter excludes subtrees.
    • static-url resolves logical names to content-addressed URLs.
    • make-fingerprinted-static-handler serves with Cache-Control: public, max-age=31536000, immutable, and 404s on hash mismatch.
    • dump-manifest-to-file / load-manifest-from-file for pre-built manifests.
    • make-static-manifest-handler-wrapper binds the manifest per request.
  • HTML response caching.
    • #:cache-control, #:etag, #:request on html-response.
    • Explicit etag values short-circuit to 304 before rendering.
    • #:etag #t auto-computes a strong tag from the rendered body.
    • streaming-html-response accepts the same keywords (explicit etag only).
  • make-file-handler: serve a single file at a fixed URL.
    • Content-Length, ETag, and Last-Modified headers.
    • Conditional requests: If-None-Match and If-Modified-Since (If-None-Match wins per RFC 7232).
    • Optional #:cache-control and #:content-type overrides.
  • make-static-handler: 200 responses now carry Content-Length.
  • json-response accepts either a JSON string or a Scheme value (encoded via scm->json-string).
  • text-response gained #:charset.
  • html-response: #:doctype and #:entities accept symbols as well as strings.
  • run-safsaf keyword arguments.
    • #:parallelism is forwarded to run-fibers (default 1, because single-threaded scheduling currently scales better for typical request workloads).
    • #:install-default-logger? installs an error-port logger when none is set.
    • #:disable-output-port-buffering? (default #t) — port buffering is not fiber-safe.
    • #:on-shutdown thunk runs after the listening socket has been closed, before run-safsaf returns; exceptions are caught and logged.
  • Graceful shutdown hooks.
    • Standalone mode now handles SIGTERM in addition to SIGINT; a second signal calls primitive-exit so the process can be stopped even if on-shutdown hangs.
    • Embedded mode (called inside an existing run-fibers) now returns two values: the <web-server> record and an idempotent shutdown thunk.
    • In-flight requests are not drained — they are cut off when the Fibers scheduler exits.
  • Templating: general SXML / HTML rendering improvements; some redundant response helpers removed.
  • Session handler wrapper: additional configuration options.
  • CSRF handler wrapper: enhancements (see the manual for details).
Version 0.1
  • Initial release.
  • Built on the code of the Guix Data Serivce, plus other web services like the Guix Build Coordinator and Nar Herder.
  • Written using Claude Opus 4.6 using Claude Code.

Appendix B Copying Information

Copyright © 2026 Christopher Baines <mail@cbaines.net>

This library is free software; you can redistribute it and/or modify it under the terms of the GNU Lesser General Public License as published by the Free Software Foundation; either version 3 of the License, or (at your option) any later version.


Concept Index

Jump to:   C   S  

C
CSRFSecurity

S
SHTMLResponses


Data Type Index


Procedure Index

Jump to:   A   B   C   D   E   F   G   H   I   J   L   M   N   P   R   S   T   U   V   W  

A
add-generated-http-compliance-routessafsaf_http-compliance
add-head-routessafsaf_http-compliance
add-method-not-allowed-routessafsaf_http-compliance
add-options-routessafsaf_http-compliance
add-vary-headersafsaf_response-helpers
any-invalid-params?safsaf_params
as-checkboxsafsaf_params
as-integersafsaf_params
as-matchingsafsaf_params
as-numbersafsaf_params
as-one-ofsafsaf_params
as-predicatesafsaf_params
as-stringsafsaf_params

B
best-accept-languagesafsaf_i18n
build-response/inheritsafsaf_response-helpers
build-static-manifestsafsaf_response-helpers

C
catalog-refsafsaf_i18n
clear-catalogs!safsaf_i18n
compile-allowed-methodssafsaf_router
compile-routessafsaf_router
compiled-route-handlersafsaf_router
compiled-route-methodsafsaf_router
compiled-route-namesafsaf_router
compiled-route-patternsafsaf_router
compiled-route?safsaf_router
cors-handler-wrappersafsaf_handler-wrappers_cors
csrf-handler-wrappersafsaf_handler-wrappers_csrf
csrf-token-fieldsafsaf_handler-wrappers_csrf

D
default-asterisk-options-handlersafsaf
default-method-not-allowed-handlersafsaf_http-compliance
default-options-handlersafsaf_http-compliance
default-render-errorsafsaf_handler-wrappers_exceptions
default-render-htmlsafsaf_handler-wrappers_exceptions
default-render-jsonsafsaf_handler-wrappers_exceptions
delete-cookie-headersafsaf_response-helpers
dump-manifest-to-filesafsaf_response-helpers

E
exceptions-handler-wrappersafsaf_handler-wrappers_exceptions

F
field-errorssafsaf_params
find-allowed-methodssafsaf_router
find-matching-routesafsaf_router
flatten-routessafsaf_router

G
guard-against-mutually-exclusive-paramssafsaf_params

H
hreflang-linkssafsaf_handler-wrappers_locale
html-responsesafsaf_response-helpers

I
i18n-catalog-scriptsafsaf_i18n_javascript
i18n-runtime-filesafsaf_i18n_javascript
i18n-runtime-javascriptsafsaf_i18n_javascript
i18n-runtime-scriptsafsaf_i18n_javascript
i18n-scriptssafsaf_i18n_javascript
install-translation!safsaf_i18n
invalid-param-messagesafsaf_params
invalid-param-refsafsaf_params
invalid-param-valuesafsaf_params
invalid-param?safsaf_params

J
json-responsesafsaf_response-helpers

L
list->streaming-json-arraysafsaf_response-helpers
load-catalog-from-port!safsaf_i18n
load-catalogs!safsaf_i18n
load-manifest-from-filesafsaf_response-helpers
locale-alternatessafsaf_handler-wrappers_locale
locale-plural-indexsafsaf_i18n
locale-prefix-patternsafsaf_handler-wrappers_locale
logging-handler-wrappersafsaf_handler-wrappers_logging

M
make-cors-handler-wrappersafsaf_handler-wrappers_cors
make-csrf-handler-wrappersafsaf_handler-wrappers_csrf
make-exceptions-handler-wrappersafsaf_handler-wrappers_exceptions
make-file-handlersafsaf_response-helpers
make-fingerprinted-static-handlersafsaf_response-helpers
make-invalid-paramsafsaf_params
make-locale-handler-wrappersafsaf_handler-wrappers_locale
make-locale-redirect-handlersafsaf_handler-wrappers_locale
make-logging-handler-wrappersafsaf_handler-wrappers_logging
make-max-body-size-handler-wrappersafsaf_handler-wrappers_max-body-size
make-route-groupsafsaf_router
make-security-headers-handler-wrappersafsaf_handler-wrappers_security-headers
make-session-configsafsaf_handler-wrappers_sessions
make-session-handler-wrappersafsaf_handler-wrappers_sessions
make-sse-eventsafsaf_response-helpers_sse
make-static-handlersafsaf_response-helpers
make-static-manifest-handler-wrappersafsaf_response-helpers
make-trailing-slash-handler-wrappersafsaf_handler-wrappers_trailing-slash
make-vary-collectorsafsaf_response-helpers
make-vary-handler-wrappersafsaf_handler-wrappers_vary
match-routesafsaf_router
method-spec-matches?safsaf_router
multipart-text-fieldssafsaf_utils

N
negotiate-content-typesafsaf_response-helpers
normalize-accept-languagesafsaf_i18n
not-found-responsesafsaf_response-helpers

P
params->query-stringsafsaf_params
parse-accept-languagesafsaf_i18n
parse-form-bodysafsaf_utils
parse-form-paramssafsaf_params
parse-multipart-bodysafsaf_utils
parse-paramssafsaf_params
parse-query-stringsafsaf_utils
path-forsafsaf_router
path-in-localesafsaf_handler-wrappers_locale
plural-formssafsaf_i18n

R
redirect-responsesafsaf_response-helpers
register-plural-rule!safsaf_i18n
request-cookie-refsafsaf_utils
request-cookiessafsaf_utils
request-last-event-idsafsaf_response-helpers_sse
routesafsaf_router
route-groupsafsaf_router
route-group-add-children!safsaf_router
route-group-childrensafsaf_router
route-group-namesafsaf_router
route-group-prefixsafsaf_router
route-group?safsaf_router
route-handlersafsaf_router
route-methodsafsaf_router
route-namesafsaf_router
route-patternsafsaf_router
route?safsaf_router
run-safsafsafsaf

S
same-site->extensionssafsaf_utils
scm-alist->streaming-jsonsafsaf_response-helpers
security-headers-handler-wrappersafsaf_handler-wrappers_security-headers
session-config?safsaf_handler-wrappers_sessions
session-deletesafsaf_handler-wrappers_sessions
session-handler-wrappersafsaf_handler-wrappers_sessions
session-setsafsaf_handler-wrappers_sessions
set-cookie-headersafsaf_response-helpers
split-and-decode-uri-path/safsafsafsaf_uri
sse-client-disconnected?safsaf_response-helpers_sse
sse-event-commentsafsaf_response-helpers_sse
sse-event-datasafsaf_response-helpers_sse
sse-event-eventsafsaf_response-helpers_sse
sse-event-idsafsaf_response-helpers_sse
sse-event-retrysafsaf_response-helpers_sse
sse-event?safsaf_response-helpers_sse
sse-responsesafsaf_response-helpers_sse
static-manifest-entriessafsaf_response-helpers
static-manifest-refsafsaf_response-helpers
static-manifest-rootsafsaf_response-helpers
static-manifest-url-prefixsafsaf_response-helpers
static-manifest?safsaf_response-helpers
static-urlsafsaf_response-helpers
streaming-html-responsesafsaf_response-helpers
streaming-json-responsesafsaf_response-helpers
strip-path-extensionsafsaf_response-helpers

T
tsafsaf_i18n
text-responsesafsaf_response-helpers
tnsafsaf_i18n
trailing-slash-handler-wrappersafsaf_handler-wrappers_trailing-slash

U
uri-decode/safsafsafsaf_uri
uri-encode/safsafsafsaf_uri

V
vary-fieldssafsaf_response-helpers
vary-handler-wrappersafsaf_handler-wrappers_vary
vary-headersafsaf_response-helpers
vary-on!safsaf_response-helpers

W
wrap-routessafsaf_router
write-shtml-as-html/streamingsafsaf_templating
write-sse-eventsafsaf_response-helpers_sse


Variable Index