///|
let default_serve_host : String = "127.0.0.1"
///|
let default_serve_port : Int = 8000
///|
async fn serve_static_dir(
root : String,
host? : String = default_serve_host,
port? : Int = default_serve_port,
) -> Unit {
let addr = @socket.Addr::parse("\{host}:\{port}")
let server = @http.Server(addr, reuse_addr=true)
println("Serving \{root} at http://\{host}:\{server.addr.port()}/")
server.run_forever(allow_failure=true) <| ((request, _body, conn) => {
serve_static_request(root, request, conn)
})
}
///|
async fn serve_static_request(
root : String,
request : @http.Request,
conn : @http.ServerConnection,
) -> Unit {
match request.meth {
Get => serve_path(root, request.path, false, conn)
Head => serve_path(root, request.path, true, conn)
_ => send_text(conn, 405, "Method Not Allowed", "Method Not Allowed", true)
}
}
///|
async fn serve_path(
root : String,
request_path : String,
head_only : Bool,
conn : @http.ServerConnection,
) -> Unit {
match request_target_to_relative_path(request_path) {
None => send_text(conn, 403, "Forbidden", "Forbidden", head_only)
Some(relative_path) => {
let full_path = @path.Path(root).join(relative_path).to_string()
if !@afs.exists(full_path) {
serve_404(root, head_only, conn)
} else {
match @afs.kind(full_path) {
Directory => serve_directory_index(root, full_path, head_only, conn)
Regular => serve_file(full_path, head_only, conn)
_ => serve_404(root, head_only, conn)
}
}
}
}
}
///|
async fn serve_directory_index(
root : String,
dir_path : String,
head_only : Bool,
conn : @http.ServerConnection,
) -> Unit {
let index_path = @path.Path(dir_path).join("index.html").to_string()
if @afs.exists(index_path) && @afs.kind(index_path) is Regular {
serve_file(index_path, head_only, conn)
} else {
serve_404(root, head_only, conn)
}
}
///|
async fn serve_404(
root : String,
head_only : Bool,
conn : @http.ServerConnection,
) -> Unit {
let full_path = @path.Path(root).join("404.html").to_string()
if @afs.exists(full_path) && @fs.is_file(full_path) {
serve_file(full_path, head_only, conn)
} else {
send_text(conn, 404, "Not Found", "Not Found", head_only)
}
}
///|
async fn serve_file(
file_path : String,
head_only : Bool,
conn : @http.ServerConnection,
) -> Unit {
let file = @afs.open(file_path, mode=ReadOnly)
defer file.close()
conn.send_response(200, "OK", extra_headers={
"Content-Type": content_type(file_path),
"Content-Length": file.size().to_string(),
"Cache-Control": "no-cache",
})
if !head_only {
conn.write_reader(file)
}
conn.end_response()
}
///|
async fn send_text(
conn : @http.ServerConnection,
status : Int,
reason : String,
body : String,
head_only : Bool,
) -> Unit {
conn.send_response(status, reason, extra_headers={
"Content-Type": "text/plain; charset=utf-8",
"Content-Length": body.length().to_string(),
"Cache-Control": "no-cache",
})
if !head_only {
conn.write_string(body)
}
conn.end_response()
}
///|
fn request_target_to_relative_path(target : String) -> String? {
let path = strip_query_and_fragment(target)
if path.contains("\\") || path.contains("\u0000") {
None
} else {
let parts : Array[String] = []
for part in path.split("/") {
if part == "" || part == "." {
continue
} else if part == ".." {
return None
} else {
parts.push(percent_decode_unreserved(part.to_owned()))
}
}
if parts.length() == 0 {
Some("index.html")
} else {
Some(parts.join(@path.sep.to_string()))
}
}
}
///|
fn strip_query_and_fragment(target : String) -> String {
let query = target.find("?")
let fragment = target.find("#")
let end = match (query, fragment) {
(Some(q), Some(f)) => if q < f { q } else { f }
(Some(q), None) => q
(None, Some(f)) => f
(None, None) => target.length()
}
Show::to_string(target[0:end])
}
///|
fn percent_decode_unreserved(part : String) -> String {
let out = StringBuilder::new()
let mut index = 0
while index < part.length() {
if part[index] == '%' && index + 2 < part.length() {
let decoded = decode_percent_byte(part[index + 1], part[index + 2])
match decoded {
Some(ch) =>
if is_unreserved_url_unit(ch) {
out.write_string(Int::unsafe_to_char(ch.to_int()).to_string())
index = index + 3
continue
} else {
out.write_string(Show::to_string(part[index:index + 1]))
}
None => out.write_string(Show::to_string(part[index:index + 1]))
}
} else {
out.write_string(Show::to_string(part[index:index + 1]))
}
index = index + 1
}
out.to_string()
}
///|
fn decode_percent_byte(high : UInt16, low : UInt16) -> UInt16? {
match (hex_value(high), hex_value(low)) {
(Some(h), Some(l)) => Some((h * 16 + l).to_uint16())
_ => None
}
}
///|
fn hex_value(ch : UInt16) -> Int? {
if ch >= '0' && ch <= '9' {
Some(ch.to_int() - '0'.to_int())
} else if ch >= 'a' && ch <= 'f' {
Some(ch.to_int() - 'a'.to_int() + 10)
} else if ch >= 'A' && ch <= 'F' {
Some(ch.to_int() - 'A'.to_int() + 10)
} else {
None
}
}
///|
fn is_unreserved_url_unit(ch : UInt16) -> Bool {
(ch >= 'A' && ch <= 'Z') ||
(ch >= 'a' && ch <= 'z') ||
(ch >= '0' && ch <= '9') ||
ch == '-' ||
ch == '.' ||
ch == '_' ||
ch == '~'
}
///|
fn content_type(path : String) -> String {
match @path.Path(path).extname().to_owned().to_lower() {
".html" | ".htm" => "text/html; charset=utf-8"
".css" => "text/css; charset=utf-8"
".js" | ".mjs" => "text/javascript; charset=utf-8"
".json" => "application/json; charset=utf-8"
".txt" | ".md" => "text/plain; charset=utf-8"
".xml" => "application/xml; charset=utf-8"
".svg" => "image/svg+xml"
".png" => "image/png"
".jpg" | ".jpeg" => "image/jpeg"
".gif" => "image/gif"
".webp" => "image/webp"
".ico" => "image/x-icon"
".wasm" => "application/wasm"
".pdf" => "application/pdf"
".woff" => "font/woff"
".woff2" => "font/woff2"
_ => "application/octet-stream"
}
}