Safsaf is a web framework for Guile Scheme, built on Guile Fibers using the Guile Knots web server.
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.
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.
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-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.
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.
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).
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.
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.
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.
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")))
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.
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.
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.
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).
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).
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)
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).
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))))
parse-query-string extracts query parameters from the request
URL:
(let ((qs (parse-query-string request))) (assoc-ref qs "page")) ;; => "2" or #f
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.
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).
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.
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.
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.
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.
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 && or
>, 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.
(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:
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.
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.
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.
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.
#: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"))) ...)
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.
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.
(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', ...).
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.
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)
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.
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.
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: nosniffStops 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: DENYStops 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-originLimits 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-policySays 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-securityTells 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-policyFor example "same-origin", which stops a page that opened yours
from keeping a handle on its window.
#:permissions-policyTurns 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.
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.
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).
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.
Neither Safsaf nor the web server checks any of this for you, and nothing here reports itself as missing at run time.
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.
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.
#: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.
#: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.
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.
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.
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).
run-safsaf starts the HTTP server. Called on its own it sets up
a Fibers scheduler, serves requests, and blocks until SIGINT or
SIGTERM.
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.
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))))
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.
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.
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.
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.
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.
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:
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).
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.
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.
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.
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).
The following is the list of modules provided by this library.
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.
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-observerA 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-shutdownA thunk run once the listening socket has been closed.
#:asterisk-options-handlerA 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.
#:parallelismThe 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-sizePassed 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.
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.
#:originsA 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.
#:methodsA 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.
#:headersA 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-ageThe 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-headersA 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.
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)))
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-nameThe name of the token cookie. Defaults to "csrf-token".
#:secureWhether to mark the cookie Secure. Defaults to #f; set it in production, where the site is served over HTTPS.
#:http-onlyWhether 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.
Return an SXML hidden input element for the CSRF token. Use in forms:
(csrf-token-field) ⇒ (input (@ (type "hidden")
...)).
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))
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.
Default HTML error renderer. In dev mode, shows a rich backtrace page. In production, returns a minimal HTML page.
Default JSON error renderer. In dev mode, includes the backtrace. In production, returns only the error message.
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-errorA full override, bypassing content negotiation entirely.
#:render-htmlCalled when content negotiation selects HTML.
#:render-jsonCalled 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.
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))
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.
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.
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.
Return a handler wrapper that resolves the request locale and binds it
to current-locale for the duration of the handler.
#:supportedA list of the locale strings the application recognises. Defaults to
'("en").
#:defaultThe locale used when no strategy yields a supported one. Defaults to
"en".
#:detectAn 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:
routeReads (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.
cookieReads #:cookie-name from the request cookies.
accept-languageTakes 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-nameThe cookie the cookie strategy reads. Defaults to
"locale".
#:route-paramThe 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.
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")))
#:supportedA list of the locale strings the application recognises. Defaults to
'("en").
#:defaultThe locale redirected to when no strategy yields a supported one.
Defaults to "en".
#:detectAn 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-nameThe cookie the cookie strategy reads. Defaults to
"locale".
#:codeThe 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-404The 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.
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.
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!.
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))
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.
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"))
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-securityFor example "max-age=63072000; includeSubDomains".
#:cross-origin-opener-policyFor example "same-origin".
#:permissions-policyFor example "camera=(), microphone=()".
#:content-security-policyFor example "default-src 'self'; script-src 'self'".
#:content-security-policy-report-onlyThe same syntax, for testing a policy without enforcing it.
Return #t if OBJ is a <session-config>, as returned by
make-session-config.
Create a session config, for session-handler-wrapper and for
session-set and session-delete.
SECRET-KEYThe HMAC signing key, a string.
#:cookie-nameThe name of the session cookie. Defaults to "session".
#:expire-deltaHow long a session lasts, as (days hours minutes). Defaults to
30 days.
#:algorithmThe HMAC algorithm. Defaults to 'sha512.
#:secureWhether 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-onlyWhether 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.
#:pathThe 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.
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)))
Return a Set-Cookie header that expires the session cookie. Include in a response headers list:
(redirect-response "/" #:headers (list (session-delete cfg)))
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)))
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)))))
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))
Handler wrapper that normalizes trailing slashes in request paths.
#:mode is either ’strip (default) or ’append:
'stripRedirect /foo/ to /foo.
'appendRedirect /foo to /foo/.
The root path / is always left alone.
#:code is the HTTP status code for the redirect (default 301).
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)))
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.
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.
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.
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.
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.
Return a 405 Method Not Allowed response with an Allow header listing ALLOWED-METHODS.
Return a 204 No Content response with an Allow header listing ALLOWED-METHODS.
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).
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.
Drop all loaded catalogs. Primarily useful in tests.
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.
Read PO entries from PORT and install them as translations for LOCALE. Existing entries for LOCALE are preserved (new entries override).
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"
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!.
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 '())
parse-accept-language)
(permille . language-symbol) pairs as returned by
Guile’s (web http) header parser, where the permille is an
integer from 0 to 1000.
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.
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.
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)).
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)
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)
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.
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.
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.
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.
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.
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.
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.
Return #t if OBJ is an <invalid-param>.
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.
Return #t if any values in PARSED-PARAMS are invalid.
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.
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.
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".
Accept S as any number Scheme can read, or return an
<invalid-param>. A parse-params processor.
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.
Return a parse-params processor that accepts values for which
PRED returns true. #:message overrides the error message, which
defaults to "Invalid value".
Accept S unchanged. The parse-params processor for a parameter
that needs no validation beyond being present.
Return a list of error message strings for NAME, or ’(). Convenient for rendering form fields with per-field errors.
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.
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.
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.
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.
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.
Return MANIFEST’s hash table, mapping each logical path to its truncated
content hash. static-manifest-ref looks one up without exposing
the table.
Return the directory MANIFEST’s logical paths are resolved against.
Return the path prefix MANIFEST’s static route is mounted under, which
static-url builds its URLs from.
Return #t if OBJ is a <static-manifest>.
Default value:
#f
Default value:
#f
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.
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.
Walk ROOT and return a <static-manifest> mapping each file’s
path, relative to ROOT, to a truncated content hash.
ROOTThe directory holding the assets to serve.
#:url-prefixThe 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-lengthHow 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.
#:filterA 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.
Return a Set-Cookie header pair that expires cookie NAME. Wraps (webutils cookie) delete-cookie.
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.
Return an HTML response, rendering SHTML to a string up front.
SHTMLThe 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.
#:codeThe response status code. Defaults to 200.
#:headersHeaders appended to the ones built here.
#:charsetThe charset of the Content-Type. Defaults to "utf-8".
#:cache-controlA 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.
#:etagOne of:
#fThe default: no ETag header.
#tCompute a strong ETag from the rendered body, with md5.
a stringUsed as a strong ETag value.
a (string . boolean) pairUsed directly, as (tag . strong?).
#:requestThe 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.
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.
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.
Read a manifest previously written by dump-manifest-to-file and
return a <static-manifest>.
PATHThe file to read.
#:rootThe directory the manifest’s logical paths resolve against, which is
where the files themselves live for this deployment. Defaults to
".".
#:url-prefixThe 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.
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-PATHThe file to serve. Unlike make-static-handler, no part of it
comes from the route, so the route needs no wildcard capture.
#:cache-controlA Cache-Control value in Guile’s header format, an alist such as
'((max-age . 3600)).
#:content-typeOverrides 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.
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.
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.
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.
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.
Return the MIME type symbol to answer REQUEST with.
REQUESTThe request to negotiate for.
SUPPORTEDThe types the caller can produce, best first. Optional, defaulting to
'(text/html application/json).
#:extensionsAn 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.
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.
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.
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.
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")
Return the truncated hash for LOGICAL-PATH in MANIFEST, or #f if LOGICAL-PATH is not present.
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.
Return an HTML response that streams SHTML to the client.
SHTMLThe 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.
#:codeThe response status code. Defaults to 200.
#:headersHeaders appended to the ones built here.
#:charsetThe charset of the Content-Type. Defaults to "utf-8".
#:cache-controlAs in html-response.
#:etagA 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.
#:requestThe 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.
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).
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.
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".
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.
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.
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.
Return the comment text of EVENT, written as lines prefixed with
‘:’, or #f if it has none.
Return the payload of EVENT, written as its data: lines, or
#f if it has none.
Return the event name of EVENT, written as its event: line, or
#f if it has none.
Return the Last-Event-ID of EVENT, written as its id: line, or
#f if it has none.
Return the reconnection delay of EVENT in milliseconds, written as its
retry: line, or #f if it has none.
Return #t if OBJ is an <sse-event>.
Construct a Server-Sent Event value.
Keyword arguments are all optional:
#:dataA 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.
#:eventAn event-name string. Consumers can filter by name via
EventSource.addEventListener(name, ...). Must not contain
newlines.
#:idA Last-Event-ID string. The browser echoes this back in the
Last-Event-ID header when it reconnects, enabling replay. Must
not contain newlines.
#:retryA non-negative integer (milliseconds) telling the browser how long to wait before reconnecting after a drop.
#:commentA 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.
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.
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.
Return a streaming Server-Sent Events response.
REQUESTThe 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-PROCA 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?.
#:headersHeaders appended to the defaults, which are text/event-stream,
cache-control: no-cache and x-accel-buffering: no.
#:keepalive-intervalThe 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.
#:retryA 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.
Write EVENT to PORT in SSE wire format and finish with a blank line so the browser dispatches it. Does not flush PORT.
Return the handler procedure of COMPILED-ROUTE.
Return the method COMPILED-ROUTE was compiled from, in the form the
route was written with: a symbol, a list of symbols, or '*.
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.
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.
Return #t if OBJ is a <compiled-route>.
Return the list of child routes and groups of ROUTE-GROUP.
Return the name of ROUTE-GROUP, or #f if unnamed.
Return the prefix pattern of ROUTE-GROUP.
Return #t if OBJ is a <route-group>.
Return the handler procedure of ROUTE.
Return the HTTP method of ROUTE.
Return the name of ROUTE, or #f if unnamed.
Return the URL pattern of ROUTE.
Return #t if OBJ is a <route>.
Default value:
#f
Default value:
()
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.
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.
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.
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.
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.
Create an empty route group with PREFIX. Children can be added later with route-group-add-children!.
Find the first matching route for METHOD and PATH-SEGMENTS. Returns (values handler bindings) on match, or (values #f #f) on no match.
Return #t if METHOD-SPEC, in any of the forms route accepts, matches METHOD.
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:
#:queryAn alist of query parameters, ((key . value) ...). A key is a
symbol or a string; a value must be a string.
#:fragmentA 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.
Create a route.
METHODAn HTTP method symbol, a list of them, or '* for any method.
PATTERNA 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.
HANDLERA procedure (request body-port) returning two values, a response
and a body.
#:nameA symbol naming the route for reverse routing with path-for.
Optional; a route without one cannot be reached by name.
Create a route group, which serves its children under a shared path prefix.
PREFIXA pattern list, in the same syntax as a route pattern, prepended to the pattern of every route beneath it.
CHILDRENThe routes and route-groups of the group, given as rest arguments and matched in the order written.
#:nameA 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.
Append NEW-CHILDREN to GROUP’s child list.
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.
Write SHTML NODE to PORT.
Procedures in the tree are dispatched by arity:
(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.
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.
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.
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.
Extract text fields from multipart PARTS as an alist of (name . value). File upload parts (those with a filename parameter) are excluded.
Read and parse a URL-encoded form body from REQUEST. Returns an alist of string key-value pairs.
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.
Parse the query string from REQUEST. Returns an alist of string key-value pairs, or ’() if no query string.
Return the value of cookie NAME from REQUEST, or DEFAULT (itself defaulting to #f) if not found.
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).
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.
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.
wrap-routes: no
logging, exception or CORS wrapper sees the request, and nothing
catches an exception the handler raises.
/%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.
#: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.
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.
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.
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.
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.
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.
#: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.
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.
Allow header reported on a 204
built by the wrapper, a preflight being answered only by an ok
status.
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.
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.
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.
Allow header the 405 routes send now names OPTIONS, the
OPTIONS routes being generated before them.
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.
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))
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.
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.
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.
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.
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-interval seconds
idle rather than on a fixed schedule: writing an event
restarts the interval, so a stream already sending regularly
sends none.
(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!.
vary-header builds the header directly, for responses no wrapper
will see.
(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.
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.
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.
(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.
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.
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.
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.
(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.
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).
Content-Length the GET response
would have carried, rather than 0.
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.
(safsaf response-helpers sse) module.
sse-response returns a streaming text/event-stream response that
runs a body thunk against an emit procedure.
&sse-client-disconnected exception.
request-last-event-id reads Last-Event-ID for resumption.
<sse-event> records reject embedded newlines on construction.
(safsaf i18n) module.
t (singular) and tn (plural) with (ice-9 format) interpolation.
.po parser: multi-line continuations, escape sequences, comments.
register-plural-rule!.
parse-accept-language / best-accept-language with q-value
sorting and region→language fallback.
(safsaf handler-wrappers locale).
make-locale-handler-wrapper with configurable #:supported,
#:default, #:detect order (route, cookie, Accept-Language),
#:cookie-name, and #:route-param.
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.
#:cache-control, #:etag, #:request on html-response.
#: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.
If-None-Match and If-Modified-Since
(If-None-Match wins per RFC 7232).
#: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.
primitive-exit so the process can be
stopped even if on-shutdown hangs.
run-fibers) now
returns two values: the <web-server> record and an idempotent
shutdown thunk.
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.
| Jump to: | A B C D E F G H I J L M N P R S T U V W |
|---|
| Jump to: | A B C D E F G H I J L M N P R S T U V W |
|---|