// Serving static files from the routes under a prefix.
//
// The bytes live in a `Files` value rather than in the file system, so the layer
// is pure: a test serves a file it built in memory, a deployment loads a
// directory once at start-up, and neither path needs a second implementation.
///|
/// One servable file: its bytes and the media type it is announced with. The
/// `ETag` is derived from the content, so a conditional request works without the
/// loader having to know a modification time.
pub(all) struct FileEntry {
body : Bytes
media_type : String
}
///|
/// The files a service serves, keyed by path relative to the mount prefix.
pub(all) struct Files {
entries : Map[String, FileEntry]
}
///|
/// An empty set of files.
pub fn Files::new() -> Files {
{ entries: Map([]), }
}
///|
/// Add `body` under `path` (`/logo.svg`, say). The media type is guessed from the
/// extension unless one is given.
pub fn Files::add(
self : Files,
path : String,
body : Bytes,
media_type? : String,
) -> Unit {
let media_type = match media_type {
Some(media_type) => media_type
None => media_type_of(path)
}
self.entries[normalise(path)] = { body, media_type, }
}
///|
/// The file registered under `path`, or `None`.
pub fn Files::get(self : Files, path : String) -> FileEntry? {
self.entries.get(normalise(path))
}
///|
/// The paths in this set, sorted, so a listing or a test is stable.
pub fn Files::paths(self : Files) -> Array[String] {
let paths = self.entries.keys().collect()
paths.sort()
paths
}
///|
/// A path as the map keys it: one leading slash, no trailing slash.
fn normalise(path : String) -> String {
let mut start = 0
while start < path.length() && path[start] == '/' {
start = start + 1
}
let mut end = path.length()
while end > start && path[end - 1] == '/' {
end = end - 1
}
"/" + path[start:end].to_owned()
}
///|
/// The media type for a path, from its extension. The table covers the types a
/// static bundle actually carries; anything else is served as
/// `application/octet-stream`, which every client downloads rather than guesses.
pub fn media_type_of(path : String) -> String {
let lower = path.to_lower()
for pair in media_types {
if lower.has_suffix("." + pair.0) {
return pair.1
}
}
"application/octet-stream"
}
///|
/// The extension-to-media-type table, longest extension first so `.jsonld` wins
/// over `.json`.
let media_types : Array[(String, String)] = [
("jsonld", "application/ld+json"),
("woff2", "font/woff2"),
("woff", "font/woff"),
("html", "text/html; charset=utf-8"),
("css", "text/css; charset=utf-8"),
("json", "application/json"),
("mjs", "text/javascript; charset=utf-8"),
("js", "text/javascript; charset=utf-8"),
("svg", "image/svg+xml"),
("png", "image/png"),
("jpg", "image/jpeg"),
("jpeg", "image/jpeg"),
("webp", "image/webp"),
("avif", "image/avif"),
("ico", "image/x-icon"),
("txt", "text/plain; charset=utf-8"),
("xml", "application/xml"),
("pdf", "application/pdf"),
("wasm", "application/wasm"),
("map", "application/json"),
("ttf", "font/ttf"),
("otf", "font/otf"),
("mp4", "video/mp4"),
("webm", "video/webm"),
]
///|
/// The `ETag` of a file: a quoted, strong validator derived from the length and a
/// CRC-32 of the bytes, so two builds that produce the same content serve the same
/// tag.
pub fn FileEntry::etag(self : FileEntry) -> String {
"\"" +
self.body.length().to_string() +
"-" +
@moonzip.crc32(self.body[:]).to_string() +
"\""
}
///|
/// Whether a request's `If-None-Match` names this file's tag.
fn matches_etag(scope : @moonasgi.Scope, tag : String) -> Bool {
match scope_header(scope, "if-none-match") {
Some(value) => value == tag || value == "*"
None => false
}
}
///|
/// The file-serving layer: a `GET` under `prefix` whose remaining path names a
/// registered file is answered with it, and every other request reaches the
/// application. A conditional request whose `If-None-Match` names the file's tag
/// is answered `304` without the body.
///
/// This is a middleware rather than a route so it composes with the assembled
/// chain exactly as the reference implementation's does: the file response still
/// carries the CORS, trace and access-log layers.
pub fn file_server(files : Files, prefix : String) -> Middleware {
let mount = normalise(prefix)
inner => {
(scope, receive, send) => {
let served = match scope {
Http(hs) =>
if hs.http_method == "GET" || hs.http_method == "HEAD" {
serve_file(files, mount, scope, hs.path, hs.http_method, send)
} else {
false
}
_ => false
}
if served {
// the response has already gone out
()
} else {
inner(scope, receive, send)
}
}
}
}
///|
/// Answer one request from `files`; `true` when it was served. `HEAD` gets the
/// headers and no body, as a HEAD response must.
async fn serve_file(
files : Files,
mount : String,
scope : @moonasgi.Scope,
path : String,
verb : String,
send : @moonasgi.Send,
) -> Bool {
// The mount has to end where its name ends: `/static` must not answer a request
// for `/staticx/a.css` out of a file registered as `/x/a.css`.
let relative = if mount == "/" {
path
} else if path == mount {
"/"
} else if path.has_prefix(mount + "/") {
path.exact_view(start=mount.length()).to_owned()
} else {
return false
}
let entry = match files.get(relative) {
Some(entry) => entry
None => return false
}
let tag = entry.etag()
let headers = [("content-type", entry.media_type), ("etag", tag)]
if matches_etag(scope, tag) {
send(
@moonasgi.Event::HttpResponseStart(status=304, headers~, trailers=false),
)
send(@moonasgi.Event::HttpResponseBody(body=b"", more_body=false))
return true
}
send(
@moonasgi.Event::HttpResponseStart(
status=200,
headers=[("content-length", entry.body.length().to_string()), ..headers],
trailers=false,
),
)
let body = if verb == "HEAD" { b"" } else { entry.body }
send(@moonasgi.Event::HttpResponseBody(body~, more_body=false))
true
}