Pudu programming language
Menu
Package

@chrismichaelps / pudu-lang-docgen

Documentation publishing for Pudu: articles, API references, navigation, search, and static websites

0.1.0Apache-2.01

InstallClose

Serve.pudu

Pudu178 lines6.6 KB

GitHub ↗
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 }1718/// Largest request head read before the request is refused.19const HEAD_LIMIT: Int = 655362021/// Milliseconds between checks for an interrupt while serving.22const SIGNAL_MILLIS: Int = 502324/// Media type of the server's own plain-text replies.25const PLAIN: Str = "text/plain; charset=utf-8"2627/// Reason phrases of the statuses the server answers with.28const REASONS: Array[(Int, Str)] = [(200, "OK"), (400, "Bad Request"), (404, "Not Found"), (405, "Method Not Allowed"), (500, "Internal Server Error")]2930/// The site-relative file a request path names: folders name their `index.html`, and paths31/// that are not portable after percent-decoding name nothing.32export 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}3839/// A request path with `%XX` escapes decoded as UTF-8, or none when an escape is malformed.40export 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}6263/// Serves a folder until interrupted, answering how many requests were served.64export 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}8990/// The reply to a request line: files and folder indexes for `GET` and `HEAD`, the site's91/// not-found page for missing files, and plain refusals for malformed or other requests. A92/// `HEAD` reply keeps its body so the head reports its length; only the body is not sent.93export 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}99100/// The reply for a request path: the file, a folder's index, or the site's not-found page.101export 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}121122/// The response head of a reply.123export 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}130131/// One request read from a connection and answered; the connection closes afterwards.132fn 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}144145/// The physical path of a regular file inside the root.146fn 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}155156/// A file's bytes with the media type its extension names.157fn 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}167168/// UTF-8 text of hexadecimal byte pairs.169fn 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