///|
/// Metadata describing a static asset including its content type, ETag, modification time, and size.
pub(all) struct StaticAssetMeta {
asset_type : String?
etag : String?
mtime : Int64?
path : String?
size : Int64?
encoding : String?
fn new(
asset_type? : String,
etag? : String,
mtime? : Int64,
path? : String,
size? : Int64,
encoding? : String,
) -> StaticAssetMeta
}
///|
/// Creates a new `StaticAssetMeta` with optional fields for asset type, ETag, mtime, path, size, and encoding.
pub fn StaticAssetMeta::new(
asset_type? : String,
etag? : String,
mtime? : Int64,
path? : String,
size? : Int64,
encoding? : String,
) -> Self {
{ asset_type, etag, mtime, path, size, encoding }
}
///|
/// Trait for providing static file serving capabilities, including asset lookup, content retrieval, and MIME type resolution.
pub(open) trait ServeStaticProvider {
// This function should resolve asset meta
async get_meta(Self, id : String) -> StaticAssetMeta? noraise
// This function should resolve asset content
async get_contents(Self, id : String) -> &Responder noraise
// Custom MIME type resolver function
get_type(Self, ext : String) -> String? noraise
// Encodings map
get_encodings(Self) -> Map[String, String] noraise
// Index names
get_index_names(Self) -> Array[String] noraise
// Fallthrough
get_fallthrough(Self) -> Bool noraise
}
///|
fn normalize_mount_path(path : String) -> String {
if path == "" {
"/"
} else if path.length() > 1 && path[path.length() - 1:] == "/" {
path[:path.length() - 1].to_string()
} else {
path
}
}
///|
fn mounted_asset_id(mount_path : String, request_path : String) -> String {
if mount_path == "/" {
if request_path == "/" {
""
} else {
request_path[1:].to_string()
}
} else if request_path.length() == mount_path.length() {
""
} else {
request_path[mount_path.length() + 1:].to_string()
}
}
/// Parses a single Accept-Encoding token, returning the encoding name
/// or `None` if the q-value is 0 (meaning the client explicitly refuses it).
/// Examples:
/// "gzip" -> Some("gzip")
/// "gzip; q=0.8" -> Some("gzip")
/// "gzip;q=0" -> None
/// "br;q=0.0" -> None
///|
fn parse_accept_encoding_token(token : StringView) -> String? {
let (name, rest) = match token.find(";") {
Some(index) =>
(
token[:index].trim(chars=" ").to_string().to_lower(),
Some(token[index + 1:]),
)
None => (token.trim(chars=" ").to_string().to_lower(), None)
}
// Check q-value — if explicitly 0, refuse this encoding.
match rest {
Some(params) =>
for param in params.split(";") {
let trimmed = param.trim(chars=" ")
if trimmed.has_prefix("q=") || trimmed.has_prefix("Q=") {
let qval = trimmed[2:].trim(chars=" ").to_string()
// q=0, q=0.0, q=0.00, etc. means refused.
if qval == "0" || qval == "0.0" || qval == "0.00" || qval == "0.000" {
return None
}
}
}
None => ()
}
Some(name)
}
///|
fn normalize_etag_token(token : StringView) -> String {
let trimmed = token.trim(chars=" ")
if trimmed.length() >= 2 &&
(trimmed.has_prefix("W/") || trimmed.has_prefix("w/")) {
trimmed[2:].trim(chars=" ").to_string()
} else {
trimmed.to_string()
}
}
///|
fn if_none_match_matches(header_value : String, etag : String) -> Bool {
for token in header_value.split(",") {
let normalized = normalize_etag_token(token)
if normalized == "*" || normalized == etag {
return true
}
}
false
}
///|
struct StaticResolvedResponder {
inner : &Responder
asset_headers : Map[String, String]
}
///|
pub impl Responder for StaticResolvedResponder with options(self, res) -> Unit {
self.inner.options(res)
if res.status_code == OK {
self.asset_headers.each((key, value) => {
if !@mhttp.has_header_case_insensitive(res.headers, key) {
res.headers.set(key, value)
}
})
}
}
///|
pub impl Responder for StaticResolvedResponder with output(self, buf) -> Unit {
self.inner.output(buf)
}
///|
pub impl Responder for StaticResolvedResponder with output_bytes(self) -> Bytes? {
self.inner.output_bytes()
}
///|
fn static_asset_headers(
provider : &ServeStaticProvider,
id : String,
meta : StaticAssetMeta,
) -> Map[String, String] {
let headers : Map[String, String] = {}
match meta.etag {
Some(etag) => headers.set("ETag", etag)
None => ()
}
match meta.mtime {
Some(mtime) => headers.set("Last-Modified", @mhttp.format_http_date(mtime))
None => ()
}
match meta.asset_type {
Some(t) => headers.set("Content-Type", t)
None => {
let parts = id.split(".").collect()
if parts.length() > 1 {
match provider.get_type(parts[parts.length() - 1].to_string()) {
Some(t) => headers.set("Content-Type", t)
None => ()
}
}
}
}
match meta.encoding {
Some(enc) => headers.set("Content-Encoding", enc)
None => ()
}
match meta.size {
Some(size) => headers.set("Content-Length", size.to_string())
None => ()
}
headers
}
///|
fn static_not_modified_headers(
response_headers : Map[String, String],
meta : StaticAssetMeta,
) -> Map[String, String] {
let headers = copy_headers(response_headers)
match meta.etag {
Some(etag) => headers.set("ETag", etag)
None => ()
}
match meta.mtime {
Some(mtime) => headers.set("Last-Modified", @mhttp.format_http_date(mtime))
None => ()
}
headers
}
///|
fn if_modified_since_matches(header_value : String, mtime : Int64) -> Bool {
match @mhttp.parse_http_date(header_value) {
Some(if_modified_since) => if_modified_since >= mtime
None => false
}
}
///|
/// Mounts a static asset provider at the given path, serving files with ETag/Last-Modified caching and content negotiation.
pub fn Mocket::static_assets(
self : Mocket,
path : String,
provider : &ServeStaticProvider,
) -> Unit {
let mount_path = normalize_mount_path(self.base_path + path)
self.use_middleware(
async fn(event, next) noraise {
let request_path = event.req.path()
if !@mhttp.path_scope_matches(mount_path, request_path) {
return next()
}
// Method check
if event.req.http_method != Get && event.req.http_method != Head {
if provider.get_fallthrough() {
return next()
}
event.res.headers.set("Allow", "GET, HEAD")
return HttpResponse(MethodNotAllowed)
}
let original_id = mounted_asset_id(mount_path, request_path)
let if_none_match = event.req.get_header("if-none-match")
let if_modified_since = event.req.get_header("if-modified-since")
// Parse Accept-Encoding
// Headers are Map[StringView, StringView]
let accept_encoding = event.req
.get_header("accept-encoding")
.unwrap_or("")
let encodings = provider.get_encodings()
let matched_encodings = []
if !encodings.is_empty() {
// Token-safe Vary append: don't clobber existing Vary values
// (e.g. "Origin" from CORS middleware).
@mhttp.append_token_case_insensitive(
event.res.headers,
"Vary",
"Accept-Encoding",
)
}
if accept_encoding != "" {
// split requires `chars` label
for pair in accept_encoding.split(",") {
match parse_accept_encoding_token(pair) {
Some(encoding) =>
match encodings.get(encoding) {
Some(mapped) => matched_encodings.push(mapped)
None => ()
}
None => () // q=0 — client refuses this encoding
}
}
}
// Search paths
let mut id = original_id
let mut meta : StaticAssetMeta? = None
let index_names = provider.get_index_names()
if index_names.length() == 0 {
ignore(index_names.push("/index.html"))
}
// Search logic: suffix -> encoding
let mut found = false
let suffixes = [""]
suffixes.append(index_names)
let try_encodings = matched_encodings.copy()
try_encodings.push("") // Add empty encoding (identity)
for suffix in suffixes {
if found {
break
}
for encoding in try_encodings {
let try_id = id + suffix + encoding
match provider.get_meta(try_id) {
Some(m) => {
meta = Some(m)
id = try_id
found = true
break
}
None => ()
}
}
}
match meta {
None => {
if provider.get_fallthrough() {
return next()
}
return HttpResponse(NotFound).body("Not Found")
}
Some(meta) => {
let meta = match meta.etag {
Some(etag) =>
if if_none_match.map(header => if_none_match_matches(header, etag)) ==
Some(true) {
match provider.get_meta(id) {
Some(revalidated_meta) =>
match revalidated_meta.etag {
Some(revalidated_etag) if revalidated_etag == etag =>
return HttpResponse(
NotModified,
headers=static_not_modified_headers(
event.res.headers,
revalidated_meta,
),
)
_ => revalidated_meta
}
None => meta
}
} else {
meta
}
None => meta
}
let meta = match (if_none_match, if_modified_since, meta.mtime) {
(None, Some(header), Some(mtime)) if if_modified_since_matches(
header, mtime,
) =>
match provider.get_meta(id) {
Some(revalidated_meta) =>
match revalidated_meta.mtime {
Some(revalidated_mtime) if if_modified_since_matches(
header, revalidated_mtime,
) =>
return HttpResponse(
NotModified,
headers=static_not_modified_headers(
event.res.headers,
revalidated_meta,
),
)
_ => revalidated_meta
}
None => meta
}
_ => meta
}
let asset_headers = static_asset_headers(provider, id, meta)
if event.req.http_method == Head {
let head_headers = copy_headers(event.res.headers)
asset_headers.each((key, value) => {
if !@mhttp.has_header_case_insensitive(head_headers, key) {
head_headers.set(key, value)
}
})
return HttpResponse(OK, headers=head_headers)
}
let contents = provider.get_contents(id)
StaticResolvedResponder::{ inner: contents, asset_headers }
}
}
},
base_path=mount_path,
)
}