Safsaf

Next:   [Contents][Index]

Overview

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

Table of Contents


Next: , Previous: , Up: Overview   [Contents][Index]

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.


Next: , Up: Guidance   [Contents][Index]

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 routes
  (list
   (route 'GET '() index-page
          #:name 'index)
   (route 'GET '("hello" name) hello-page)
   (route '* '* (lambda (request body-port)
                  (not-found-response)))))

(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 "!"))))

(run-safsaf routes #:port 8080)

The last route should be a catch-all ('* method, '* pattern) so that every request is handled. run-safsaf sets up a Fibers scheduler, starts the HTTP server, and blocks until Ctrl-C.


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.


Next: , Previous: , Up: Guidance   [Contents][Index]

1.3 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 tree 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.

Per-group wrappers

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

(define api-routes
  (wrap-routes
   (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 CORS headers are added only to /api/* routes, while logging applies to everything.

Security headers

security-headers-handler-wrapper appends its headers to the response rather than replacing existing ones. If a handler sets X-Frame-Options itself, both values will appear in the response. To avoid duplication, either omit the header from the wrapper (pass #:frame-options #f) or do not set it in the handler.

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.


1.4 Responses

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

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

;; JSON — takes a JSON string
(json-response (scm->json-string '(("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)
(bad-request-response "Missing field")
(forbidden-response)

html-response, json-response, text-response, and redirect-response accept #:code and #:headers for overrides. The error helpers (not-found-response, etc.) accept #:headers but have a fixed status code.

For content negotiation, use negotiate-content-type:

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

Next: , Previous: , Up: Guidance   [Contents][Index]

1.5 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:

(let* ((parts (parse-multipart-body request body-port))
       (form (multipart-text-fields parts))
       (file (parts-ref parts "avatar")))
  ;; form is an alist of text fields
  ;; file is a <part> record — read its body with (part-body file)
  ...)

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)))

Next: , Previous: , Up: Guidance   [Contents][Index]

1.6 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 automatically checks the CSRF token (from csrf-handler-wrapper) before parsing:

(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.


Next: , Previous: , Up: Guidance   [Contents][Index]

1.7 Sessions

Sessions use HMAC-signed cookies via (webutils sessions). Set up a session config and apply the wrapper:

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

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

Inside a handler, (current-session) returns the session data (an alist) 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-mgr
                               '((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-mgr)))

Next: , Previous: , Up: Guidance   [Contents][Index]

1.8 Templating

write-shtml-as-html/streaming works like htmlprag’s write-shtml-as-html, but any procedure in the SHTML tree is called as (proc port) and can write dynamic content directly.

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
        `(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.


Next: , Previous: , Up: Guidance   [Contents][Index]

1.9 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.


Previous: , Up: Guidance   [Contents][Index]

1.10 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 body thunk that is called with an emit procedure, and a handful of keyword arguments:

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

(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))))))

emit accepts the same keyword arguments as make-sse-event: #:data, #:event, #:id, #:retry, #:comment. Calls serialise with the internal keepalive fiber, so the wire output stays well-framed even if keepalive fires mid-loop. The response sets content-type: text/event-stream, cache-control: no-cache and x-accel-buffering: no (for nginx) by default; append to or override via #:headers.

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.10.1 Keepalives

Browsers and proxies drop idle connections, often after 30–60 seconds. sse-response sends an SSE comment line every #:keepalive-interval seconds (default 15) to prevent that. Pass #f or a non-positive number to disable.

1.10.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. Override the parameter name or disable the fallback with #:query-param.

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

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

1.10.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))))

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


Next: , Previous: , Up: Overview   [Contents][Index]

2 API

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


2.1 (safsaf)

2.1.1 Procedures

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

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

Procedure: run-safsaf routes KEY: #:host #:port #:method-not-allowed? #:method-not-allowed-handler #:connection-buffer-size #:disable-output-port-buffering? #:parallelism

Start a Safsaf web server.

ROUTES is a list of routes and route-groups (as returned by component constructors). The last route must be a catch-all so that every request is handled.

HEAD requests are handled automatically: when no explicit HEAD route matches, the matching GET handler runs and its response body is discarded. Explicit HEAD routes always take precedence.

When METHOD-NOT-ALLOWED? is #t (the default), requests that match a route’s path but not its method receive a 405 response with an Allow header. METHOD-NOT-ALLOWED-HANDLER is a procedure (request allowed-methods) -> (values response body) that produces the 405 response; the default returns plain text.

When DISABLE-OUTPUT-PORT-BUFFERING? is #t (the default), buffering on the current output and error ports is set to ’none. Guile picks the buffering mode for these ports from the environment (line-buffered on a terminal, block-buffered otherwise), but port buffering is not thread- or fiber-safe, so concurrent writes from multiple fibers can cause mysterious errors.

When called outside a Fibers scheduler, sets up a scheduler, starts the HTTP server, and blocks until Ctrl-C. When called inside an existing scheduler (e.g. within run-fibers), just starts the HTTP server and returns immediately — the caller manages the lifecycle.

PARALLELISM is the number of OS threads the Fibers scheduler runs fibers across, and is forwarded to run-fibers’ #:parallelism. Fibers itself defaults to all available cores, but Safsaf defaults to 1 because in practice a single-threaded scheduler currently scales better for the core server — multi-threaded scheduling adds contention that outweighs the parallelism gains for typical request workloads. Set this higher if profiling shows the single thread is CPU-bound. Only applies when run-safsaf manages the scheduler; when called inside an existing run-fibers, this argument is ignored.


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 is a list of allowed origin strings, or ’("*") for any. METHODS is a list of allowed method symbols. HEADERS is a list of allowed request header name strings. MAX-AGE is the preflight cache duration in seconds. ALLOW-CREDENTIALS? controls whether credentials (cookies, auth) are allowed cross-origin. Note: cannot be #t when origins is ’("*"). EXPOSE-HEADERS is a list of response header name strings the browser may read from JavaScript.


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).

Cookie attributes: Path is always /. HTTP-ONLY defaults to #t — the double-submit pattern reads the token from (current-csrf-token), not from JavaScript, so there’s no need to expose the cookie to scripts. SAME-SITE accepts ’strict (default), ’lax, ’none, or #f to omit the attribute. SECURE defaults to #f; set #t in production where the site is served over HTTPS.

Token validation is NOT done here — it belongs in the form processing layer. Use parse-form-params from (safsaf params), which automatically checks the submitted token against the cookie token.

Procedure: csrf-token-field

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


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 based on the request’s 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:

#:render-error — full override. A procedure (request code message backtrace-string dev?) -> (values response body) that bypasses content negotiation entirely.

#:render-html — custom HTML rendering. A procedure with the same signature, called when content negotiation selects HTML.

#:render-json — custom JSON rendering. A procedure with the same signature, 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 KEY: #:dev? #:logger #:render-html #:render-json #:render-error

Return a handler wrapper that catches exceptions and returns an error response. See exceptions-handler-wrapper for details.


2.5 (safsaf handler-wrappers locale)

2.5.1 Procedures

Procedure: make-locale-handler-wrapper KEY: #:supported #:default #:detect #:cookie-name #:route-param

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

SUPPORTED is a list of locale strings the application recognises. DEFAULT is used when no strategy yields a supported locale. DETECT is an ordered list of strategy symbols; the first strategy to return a supported locale wins. Strategies:

route

Reads (current-route-params) for ROUTE-PARAM. The wrapper must be applied inside the router (i.e. via wrap-routes) for this to work, since current-route-params is bound by the dispatcher.

cookie

Reads COOKIE-NAME from the request cookies.

accept-language

Parses the Accept-Language request header and returns the best match against SUPPORTED.

Unsupported values from any strategy are treated as a miss, so the next strategy in DETECT is tried.


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!.


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: 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. Defaults: X-Content-Type-Options: nosniff X-Frame-Options: DENY Referrer-Policy: strict-origin-when-cross-origin

Not set by default (enable explicitly): Strict-Transport-Security (e.g. "max-age=63072000; includeSubDomains") Cross-Origin-Opener-Policy (e.g. "same-origin") Permissions-Policy (e.g. "camera=(), microphone=()") Content-Security-Policy (e.g. "default-src ’self’; script-src ’self’") Content-Security-Policy-Report-Only — same syntax, for testing policies without enforcing them


2.9 (safsaf handler-wrappers sessions)

2.9.1 Macros

Macro: session-config? x

Undocumented macro.

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 use with session-handler-wrapper.

SECRET-KEY is the HMAC signing key (a string). EXPIRE-DELTA is (days hours minutes), default 30 days. ALGORITHM is the HMAC algorithm, default sha512.

Cookie attributes: SECURE — default #f. Set #t in production where the site is served over HTTPS; this prevents the cookie from being sent over cleartext. HTTP-ONLY — default #t. Hides the cookie from JavaScript so an XSS on the origin can’t exfiltrate the session via document.cookie. SAME-SITE — ’lax (default), ’strict, ’none, or #f to omit. Lax is the right default: it preserves cross-site GET flows like "click email link, land logged in" while blocking cross-site form posts. PATH — default "/". Both the session cookie and the cookie that deletes it must agree on Path for the browser to match them, so this attribute applies to both session-set and session-delete.

Procedure: make-session-handler-wrapper config

Return a handler wrapper that binds session data from CONFIG. See session-handler-wrapper for details.

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 via: (current-session) → 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 KEY: #:mode #:code

Return a handler wrapper that normalizes trailing slashes.

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).

Use with wrap-routes: (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 i18n)

2.11.1 Parameters

Parameter: current-locale

Default value:

#f

2.11.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: 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: 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: 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.12 (safsaf params)

2.12.1 Macros

Macro: invalid-param-message x

Undocumented macro.

Macro: invalid-param-value x

Undocumented macro.

Macro: invalid-param? x

Undocumented macro.

Macro: make-invalid-param x

Undocumented macro.

2.12.2 Procedures

Procedure: any-invalid-params? parsed-params

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

Procedure: as-checkbox s

Undocumented procedure.

Procedure: as-integer s

Undocumented procedure.

Procedure: as-matching regex KEY: #:message

Return a processor that accepts values matching REGEX.

Procedure: as-number s

Undocumented procedure.

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

Return a processor that accepts only values in CHOICES (a list of strings).

Procedure: as-predicate pred KEY: #:message

Return a processor that accepts values for which PRED returns true.

Procedure: as-string x

Undocumented procedure.

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, the later ones are replaced with <invalid-param> records.

Procedure: invalid-param-ref parsed-params name

Return the <invalid-param> record for NAME, or #f if valid or absent.

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).

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), second is a processor procedure (string -> value | <invalid-param>), and the rest are 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) ; multi-value with fallback (name processor #:no-default-when (fields) #:default value) ; conditional default

Returns an alist of (symbol . value) pairs. Values that fail validation appear as <invalid-param> records inline. Missing optional params without defaults are omitted.

2.12.3 Record Types

Record type: <invalid-param>

This record type has the following fields:

  • value
  • message

2.13 (safsaf response-helpers)

2.13.1 Procedures

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 any response fields.

Procedure: delete-cookie-header name

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

Procedure: html-response shtml KEY: #:code #:headers #:charset

Return an HTML response by streaming SHTML to the client. SHTML is an SXML/SHTML tree as accepted by write-shtml-as-html. CHARSET defaults to "utf-8".

Procedure: json-response str KEY: #:code #:headers

Return a JSON response. STR is the JSON string to send.

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.

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 (the file path segments). Use with a wildcard route:

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

Supports If-Modified-Since for 304 responses. CACHE-CONTROL, if given, is a Cache-Control value in Guile’s header format — an alist, e.g. ’((max-age . 3600)) or ’((no-cache)).

Works with /gnu/store paths: files with a very low mtime (as produced by the store’s timestamp normalization) use the process start time as Last-Modified instead, so that conditional requests behave sensibly.

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

Return the most appropriate MIME type symbol for REQUEST from SUPPORTED.

Checks the URL path extension first (.json, .html, .txt) — if present and the implied type is in SUPPORTED, it wins. Otherwise, walks the Accept header and returns the first type that appears in SUPPORTED. Falls back to the first element of SUPPORTED if nothing matches.

EXTENSIONS is an alist mapping file extension strings to MIME type symbols, used for path-based negotiation. Defaults to %negotiation-extensions.

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

Return a 404 Not Found response.

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

Return a redirect response to PATH (a string).

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.

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: 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.

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

Return a plain text response. STR is the text string to send. CHARSET defaults to "utf-8".


2.14 (safsaf response-helpers sse)

2.14.1 Macros

Macro: sse-event-comment x

Undocumented macro.

Macro: sse-event-data x

Undocumented macro.

Macro: sse-event-event x

Undocumented macro.

Macro: sse-event-id x

Undocumented macro.

Macro: sse-event-retry x

Undocumented macro.

Macro: sse-event? x

Undocumented macro.

2.14.2 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 DATA 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 provided 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. Raised from inside an sse-response emit call when a write to the client fails.

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

Return a streaming Server-Sent Events response.

REQUEST is the incoming <request>; it’s needed so the helper can flush the underlying socket port after every event — the chunked output port wrapped around the socket by the web server buffers writes, so flushing only the body port is not sufficient.

BODY-THUNK is called with one argument, emit, which is a procedure accepting the same keyword arguments as make-sse-event (#:data, #:event, #:id, #:retry, #:comment). Calls to emit are serialised with any keepalive comments, written to the client, and flushed before returning. If the client has disconnected, emit raises an exception satisfying sse-client-disconnected?.

Response headers default to text/event-stream, cache-control: no-cache and x-accel-buffering: no. HEADERS is appended to the defaults.

KEEPALIVE-INTERVAL is the number of seconds between SSE comment lines sent to keep the connection alive (important because HTTP proxies commonly drop idle connections after 30–60 seconds). Pass #f or a non-positive number to disable.

RETRY, if set, emits an initial retry: directive telling the browser how long to wait before reconnecting.

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.15 (safsaf router)

2.15.1 Macros

Macro: compiled-route-handler x

Undocumented macro.

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.15.2 Parameters

Parameter: current-reverse-routes

Default value:

#f
Parameter: current-route-params

Default value:

()

2.15.3 Procedures

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 (’* pattern with a rest parameter) so that every request is handled.

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

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

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: 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.

Optional keyword arguments: #:query — alist of query parameters ((key . value) ...) #:fragment — fragment string (without the leading #) #:relative? — if #t, omit the leading /

Procedure: route method pattern handler KEY: #:name

Create a route. METHOD is a symbol, list of symbols, or ’* for any. PATTERN is a list of segments: strings (literal), symbols (capture), two-element lists (predicate capture: (proc name)), with optional dotted tail (wildcard capture). HANDLER is a procedure (request body-port) -> (values response body). NAME is an optional symbol used for reverse routing with path-for.

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

Create a route group. PREFIX is a pattern list (same syntax as route patterns). CHILDREN is an ordered list of routes and route-groups. NAME is an optional symbol for nested path-for lookups.

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. Returns a new structure with wrapped handlers. When multiple wrappers are given, the first wrapper in the list wraps outermost (runs first on the request, last on the response).


Next: , Previous: , Up: API   [Contents][Index]

2.16 (safsaf templating)

2.16.1 Procedures

Procedure: streaming-html-response shtml KEY: #:code #:headers #:charset

Return an HTML response that streams SHTML to the client.

SHTML is rendered via write-shtml-as-html/streaming, so any procedures in the tree are evaluated at output time.

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, two extensions are recognised:

  • (raw HTML) — write HTML to PORT unescaped. Use only with trusted input.
  • (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.


Previous: , Up: API   [Contents][Index]

2.17 (safsaf utils)

2.17.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). 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 if not found.

Procedure: request-cookies request

Return the cookies from REQUEST as an alist of (name . value) pairs. 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 kwarg of (webutils cookie) set-cookie.

SAME-SITE accepts ’strict, ’lax, ’none, or #f (omit the attribute).


Next: , Previous: , Up: Overview   [Contents][Index]

Appendix A Version History

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.

Next: , Previous: , Up: Overview   [Contents][Index]

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


Next: , Previous: , Up: Overview   [Contents][Index]

Data Type Index

Jump to:   <
Index Entry  Section

<
<invalid-param>: safsaf_params

Jump to:   <

Next: , Previous: , Up: Overview   [Contents][Index]

Procedure Index

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

A
any-invalid-params?: safsaf_params
as-checkbox: safsaf_params
as-integer: safsaf_params
as-matching: safsaf_params
as-number: safsaf_params
as-one-of: safsaf_params
as-predicate: safsaf_params
as-string: safsaf_params

B
best-accept-language: safsaf_i18n
build-response/inherit: safsaf_response-helpers

C
clear-catalogs!: safsaf_i18n
compile-routes: safsaf_router
compiled-route-handler: safsaf_router
cors-handler-wrapper: safsaf_handler-wrappers_cors
csrf-handler-wrapper: safsaf_handler-wrappers_csrf
csrf-token-field: safsaf_handler-wrappers_csrf

D
default-method-not-allowed-handler: safsaf
default-render-error: safsaf_handler-wrappers_exceptions
default-render-html: safsaf_handler-wrappers_exceptions
default-render-json: safsaf_handler-wrappers_exceptions
delete-cookie-header: safsaf_response-helpers

E
exceptions-handler-wrapper: safsaf_handler-wrappers_exceptions

F
field-errors: safsaf_params
find-allowed-methods: safsaf_router

G
guard-against-mutually-exclusive-params: safsaf_params

H
html-response: safsaf_response-helpers

I
install-translation!: safsaf_i18n
invalid-param-message: safsaf_params
invalid-param-ref: safsaf_params
invalid-param-value: safsaf_params
invalid-param?: safsaf_params

J
json-response: safsaf_response-helpers

L
list->streaming-json-array: safsaf_response-helpers
load-catalog-from-port!: safsaf_i18n
load-catalogs!: safsaf_i18n
logging-handler-wrapper: safsaf_handler-wrappers_logging

M
make-exceptions-handler-wrapper: safsaf_handler-wrappers_exceptions
make-invalid-param: safsaf_params
make-locale-handler-wrapper: safsaf_handler-wrappers_locale
make-max-body-size-handler-wrapper: safsaf_handler-wrappers_max-body-size
make-route-group: safsaf_router
make-session-config: safsaf_handler-wrappers_sessions
make-session-handler-wrapper: safsaf_handler-wrappers_sessions
make-sse-event: safsaf_response-helpers_sse
make-static-handler: safsaf_response-helpers
make-trailing-slash-handler-wrapper: safsaf_handler-wrappers_trailing-slash
match-route: safsaf_router
multipart-text-fields: safsaf_utils

N
negotiate-content-type: safsaf_response-helpers
normalize-accept-language: safsaf_i18n
not-found-response: safsaf_response-helpers

P
params->query-string: safsaf_params
parse-accept-language: safsaf_i18n
parse-form-body: safsaf_utils
parse-form-params: safsaf_params
parse-multipart-body: safsaf_utils
parse-params: safsaf_params
parse-query-string: safsaf_utils
path-for: safsaf_router

R
redirect-response: safsaf_response-helpers
register-plural-rule!: safsaf_i18n
request-cookie-ref: safsaf_utils
request-cookies: safsaf_utils
request-last-event-id: safsaf_response-helpers_sse
route: safsaf_router
route-group: safsaf_router
route-group-add-children!: safsaf_router
route-group-children: safsaf_router
route-group-name: safsaf_router
route-group-prefix: safsaf_router
route-group?: safsaf_router
route-handler: safsaf_router
route-method: safsaf_router
route-name: safsaf_router
route-pattern: safsaf_router
route?: safsaf_router
run-safsaf: safsaf

S
same-site->extensions: safsaf_utils
scm-alist->streaming-json: safsaf_response-helpers
security-headers-handler-wrapper: safsaf_handler-wrappers_security-headers
session-config?: safsaf_handler-wrappers_sessions
session-delete: safsaf_handler-wrappers_sessions
session-handler-wrapper: safsaf_handler-wrappers_sessions
session-set: safsaf_handler-wrappers_sessions
set-cookie-header: safsaf_response-helpers
sse-client-disconnected?: safsaf_response-helpers_sse
sse-event-comment: safsaf_response-helpers_sse
sse-event-data: safsaf_response-helpers_sse
sse-event-event: safsaf_response-helpers_sse
sse-event-id: safsaf_response-helpers_sse
sse-event-retry: safsaf_response-helpers_sse
sse-event?: safsaf_response-helpers_sse
sse-response: safsaf_response-helpers_sse
streaming-html-response: safsaf_templating
streaming-json-response: safsaf_response-helpers

T
t: safsaf_i18n
text-response: safsaf_response-helpers
tn: safsaf_i18n
trailing-slash-handler-wrapper: safsaf_handler-wrappers_trailing-slash

W
wrap-routes: safsaf_router
write-shtml-as-html/streaming: safsaf_templating
write-sse-event: safsaf_response-helpers_sse

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

Previous: , Up: Overview   [Contents][Index]

Variable Index

Jump to:   C  
Index Entry  Section

C
current-csrf-token: safsaf_handler-wrappers_csrf
current-locale: safsaf_i18n
current-reverse-routes: safsaf_router
current-route-params: safsaf_router
current-session: safsaf_handler-wrappers_sessions

Jump to:   C