
Serve.pudu
Pudu178 lines6.6 KB
1/** @Docgen.Serve.Seam — a local preview server for a published site folder */2module PuduLangDocgen.Serve34import Std.Bytes as Bytes5import Std.Char as Char6import Std.Concurrent as Concurrent7import Std.Fs as Fs8import Std.Io as Io9import Std.Mime as Mime10import Std.Net as Net11import Std.Path as Path12import Std.Signal as Signal13import PuduLangDocgen.Paths as Paths1415/** @Docgen.Serve.Reply — a response: its status, media type, and body */16export type Reply = { status: Int, media: Str, body: Bytes }171819const HEAD_LIMIT: Int = 65536202122const SIGNAL_MILLIS: Int = 50232425const PLAIN: Str = "text/plain; charset=utf-8"262728const REASONS: Array[(Int, Str)] = [(200, "OK"), (400, "Bad Request"), (404, "Not Found"), (405, "Method Not Allowed"), (500, "Internal Server Error")]29303132export fn target(path: Str) -> Option[Str] {33 let decoded = decode(path.split("?")[0].split("#")[0]) ?34 let trimmed = if decoded.startsWith("/") { decoded.drop(1) } else { decoded }35 let named = if trimmed.isEmpty() || trimmed.endsWith("/") { trimmed + "index.html" } else { trimmed }36 if Paths.relative(named) { Some(named) } else { None }37}383940export fn decode(path: Str) -> Option[Str] {41 let cs = path.chars()42 var hex: Array[Str] = []43 var pieces: Array[Str] = []44 var index = 045 while index < cs.length() {46 if cs[index] == '%' {47 if index + 2 >= cs.length() || !Char.isHexDigit(cs[index + 1]) || !Char.isHexDigit(cs[index + 2]) { return None }48 hex = hex.push(cs[index + 1].toText() + cs[index + 2].toText())49 index = index + 350 } else {51 if !hex.isEmpty() {52 pieces = pieces.push(textOf(hex.join("")) ?)53 hex = []54 }55 pieces = pieces.push(cs[index].toText())56 index = index + 157 }58 }59 if !hex.isEmpty() { pieces = pieces.push(textOf(hex.join("")) ?) }60 Some(pieces.join(""))61}626364export fn serve(root: Str, host: Str, port: Int) -> Result[Int, Str] {65 let listener = match Net.listen(host, port) {66 case Ok(found) => found67 case Err(problem) => { return Err(show(problem)) }68 }69 let actual = match Net.portOf(&listener) {70 case Ok(found) => found71 case Err(_) => port72 }73 let _said = Io.writeLine("Serving " + root + " at http://" + (if host == "0.0.0.0" { "localhost" } else { host }) + ":" + show(actual) + "/ (press Ctrl+C to stop)")74 if Signal.listen() {75 let held = listener76 let _watcher = Concurrent.start(fn() -> () {77 while !Signal.requested() { let _paused = Concurrent.sleep(SIGNAL_MILLIS) }78 let _closed = Net.closeListener(&held)79 })80 }81 match Net.serve(&listener, 0, fn(connection: Net.Connection) -> Bool {82 respond(root, &connection)83 true84 }) {85 case Ok(count) => Ok(count)86 case Err(problem) => if Signal.requested() { Ok(0) } else { Err(show(problem)) }87 }88}8990919293export fn reply(root: Str, line: Str) -> Reply {94 let parts = line.split(" ")95 if parts.length() != 3 || !parts[2].startsWith("HTTP/") { return Reply{status: 400, media: PLAIN, body: Bytes.fromText("Bad request")} }96 if parts[0] != "GET" && parts[0] != "HEAD" { return Reply{status: 405, media: PLAIN, body: Bytes.fromText("Only GET and HEAD are served")} }97 answer(root, parts[1])98}99100101export fn answer(root: Str, path: Str) -> Reply {102 match target(path) {103 case Some(wanted) => {104 match readable(root, wanted) {105 case Some(found) => { return file(found, wanted, 200) }106 case None => {107 match readable(root, wanted + "/index.html") {108 case Some(found) => { return file(found, "index.html", 200) }109 case None => {}110 }111 }112 }113 }114 case None => {}115 }116 match readable(root, "404.html") {117 case Some(found) => file(found, "404.html", 404)118 case None => Reply{status: 404, media: PLAIN, body: Bytes.fromText("Not found")}119 }120}121122123export fn head(answered: &Reply) -> Str {124 var reason = "OK"125 for (code, phrase) in REASONS {126 if code == answered.status { reason = phrase }127 }128 "HTTP/1.1 " + show(answered.status) + " " + reason + "\r\nContent-Type: " + answered.media + "\r\nContent-Length: " + show(answered.body.length()) + "\r\nCache-Control: no-cache\r\nConnection: close\r\n\r\n"129}130131132fn respond(root: Str, connection: &Net.Connection) -> () {133 let line = match Net.receiveUntil(connection, &Bytes.fromText("\r\n\r\n"), HEAD_LIMIT) {134 case Ok((received, _rest)) => match Bytes.toText(&received) {135 case Ok(text) => text.split("\r\n")[0]136 case Err(_) => ""137 }138 case Err(_) => ""139 }140 let answered = reply(root, line)141 let written = Bytes.fromText(head(&answered))142 let _sent = Net.send(connection, &(if line.startsWith("HEAD ") { written } else { written.concat(&answered.body) }))143}144145146fn readable(root: Str, relative: Str) -> Option[Str] {147 match Fs.resolveInside(root, relative) {148 case Ok(physical) => match Fs.metadata(physical) {149 case Ok(details) => if details.isDirectory || details.isSymbolicLink { None } else { Some(physical) }150 case Err(_) => None151 }152 case Err(_) => None153 }154}155156157fn file(physical: Str, name: Str, status: Int) -> Reply {158 let extension = match Path.extensionOf(name) {159 case Some(found) => found160 case None => ""161 }162 match Io.readAllBytes(physical) {163 case Ok(held) => Reply{status: status, media: Mime.fromExtension(extension), body: held}164 case Err(reason) => Reply{status: 500, media: PLAIN, body: Bytes.fromText(show(reason))}165 }166}167168169fn textOf(hex: Str) -> Option[Str] {170 match Bytes.decodeHex(hex) {171 case Ok(held) => match Bytes.toText(&held) {172 case Ok(found) => Some(found)173 case Err(_) => None174 }175 case Err(_) => None176 }177}178