about summary refs log tree commit diff stats
path: root/src/server
diff options
context:
space:
mode:
authorbptato <nincsnevem662@gmail.com>2023-09-14 01:41:47 +0200
committerbptato <nincsnevem662@gmail.com>2023-09-14 02:01:21 +0200
commitc1b8338045716b25d664c0b8dd91eac0cb76480e (patch)
treea9c0a6763f180c2b6dd380aa880253ffc7685d85 /src/server
parentdb0798acccbedcef4b16737f6be0cf7388cc0528 (diff)
downloadchawan-c1b8338045716b25d664c0b8dd91eac0cb76480e.tar.gz
move around more modules
* ips -> io/
* loader related stuff -> loader/
* tempfile -> extern/
* buffer, forkserver -> server/
* lineedit, window -> display/
* cell -> types/
* opt -> types/
Diffstat (limited to 'src/server')
-rw-r--r--src/server/buffer.nim1481
-rw-r--r--src/server/forkserver.nim249
2 files changed, 1730 insertions, 0 deletions
diff --git a/src/server/buffer.nim b/src/server/buffer.nim
new file mode 100644
index 00000000..053483b5
--- /dev/null
+++ b/src/server/buffer.nim
@@ -0,0 +1,1481 @@
+import macros
+import nativesockets
+import net
+import options
+import os
+import posix
+import selectors
+import streams
+import tables
+import unicode
+
+import bindings/quickjs
+import config/config
+import css/cascade
+import css/cssparser
+import css/mediaquery
+import css/sheet
+import css/stylednode
+import css/values
+import display/window
+import html/chadombuilder
+import html/dom
+import html/env
+import html/event
+import img/png
+import io/posixstream
+import io/promise
+import io/serialize
+import io/serversocket
+import io/socketstream
+import io/teestream
+import js/error
+import js/fromjs
+import js/javascript
+import js/regex
+import js/timeout
+import layout/box
+import loader/connecterror
+import loader/loader
+import render/renderdocument
+import render/rendertext
+import types/buffersource
+import types/cell
+import types/color
+import types/cookie
+import types/formdata
+import types/referer
+import types/url
+import types/opt
+import utils/twtstr
+import xhr/formdata as formdata_impl
+
+import chakasu/charset
+import chakasu/decoderstream
+
+import chame/tags
+
+type
+  LoadInfo* = enum
+    CONNECT, DOWNLOAD, RENDER, DONE
+
+  BufferCommand* = enum
+    LOAD, RENDER, WINDOW_CHANGE, FIND_ANCHOR, READ_SUCCESS, READ_CANCELED,
+    CLICK, FIND_NEXT_LINK, FIND_PREV_LINK, FIND_NEXT_MATCH, FIND_PREV_MATCH,
+    GET_SOURCE, GET_LINES, UPDATE_HOVER, PASS_FD, CONNECT, CONNECT2,
+    GOTO_ANCHOR, CANCEL, GET_TITLE, SELECT, REDIRECT_TO_FD, READ_FROM_FD,
+    SET_CONTENT_TYPE
+
+  # LOADING_PAGE: istream open
+  # LOADING_RESOURCES: istream closed, resources open
+  # LOADED: istream closed, resources closed
+  BufferState* = enum
+    LOADING_PAGE, LOADING_RESOURCES, LOADED
+
+  HoverType* = enum
+    HOVER_TITLE = "TITLE"
+    HOVER_LINK = "URL"
+
+  BufferMatch* = object
+    success*: bool
+    x*: int
+    y*: int
+    str*: string
+
+  Buffer* = ref object
+    rfd: int # file descriptor of command pipe
+    fd: int # file descriptor of buffer source
+    alive: bool
+    readbufsize: int
+    contenttype: string #TODO already stored in source
+    lines: FlexibleGrid
+    rendered: bool
+    source: BufferSource
+    width: int
+    height: int
+    attrs: WindowAttributes
+    window: Window
+    document: Document
+    viewport: Viewport
+    prevstyled: StyledNode
+    selector: Selector[int]
+    istream: Stream
+    sstream: Stream
+    available: int
+    pstream: Stream # pipe stream
+    srenderer: StreamRenderer
+    connected: bool
+    state: BufferState
+    prevnode: StyledNode
+    loader: FileLoader
+    config: BufferConfig
+    userstyle: CSSStylesheet
+    tasks: array[BufferCommand, int] #TODO this should have arguments
+    savetask: bool
+    hovertext: array[HoverType, string]
+    estream: Stream # error stream
+
+  InterfaceOpaque = ref object
+    stream: Stream
+    len: int
+
+  BufferInterface* = ref object
+    map: PromiseMap
+    packetid: int
+    opaque: InterfaceOpaque
+    stream*: Stream
+
+proc getFromOpaque[T](opaque: pointer, res: var T) =
+  let opaque = cast[InterfaceOpaque](opaque)
+  if opaque.len != 0:
+    opaque.stream.sread(res)
+
+proc newBufferInterface*(stream: Stream): BufferInterface =
+  let opaque = InterfaceOpaque(stream: stream)
+  result = BufferInterface(
+    map: newPromiseMap(cast[pointer](opaque)),
+    packetid: 1, # ids below 1 are invalid
+    opaque: opaque,
+    stream: stream
+  )
+
+proc resolve*(iface: BufferInterface, packetid, len: int) =
+  iface.opaque.len = len
+  iface.map.resolve(packetid)
+
+proc hasPromises*(iface: BufferInterface): bool =
+  return not iface.map.empty()
+
+# get enum identifier of proxy function
+func getFunId(fun: NimNode): string =
+  let name = fun[0] # sym
+  result = name.strVal.toScreamingSnakeCase()
+  if result[^1] == '=':
+    result = "SET_" & result[0..^2]
+
+proc buildInterfaceProc(fun: NimNode, funid: string): tuple[fun, name: NimNode] =
+  let name = fun[0] # sym
+  let params = fun[3] # formalparams
+  let retval = params[0] # sym
+  var body = newStmtList()
+  assert params.len >= 2 # return type, this value
+  let nup = ident(funid) # add this to enums
+  let this2 = newIdentDefs(ident("iface"), ident("BufferInterface"))
+  let thisval = this2[0]
+  body.add(quote do:
+    `thisval`.stream.swrite(BufferCommand.`nup`)
+    `thisval`.stream.swrite(`thisval`.packetid))
+  var params2: seq[NimNode]
+  var retval2: NimNode
+  var addfun: NimNode
+  if retval.kind == nnkEmpty:
+    addfun = quote do:
+      `thisval`.map.addEmptyPromise(`thisval`.packetid)
+    retval2 = ident("EmptyPromise")
+  else:
+    addfun = quote do:
+      addPromise[`retval`](`thisval`.map, `thisval`.packetid, getFromOpaque[`retval`])
+    retval2 = newNimNode(nnkBracketExpr).add(
+      ident("Promise"),
+      retval)
+  params2.add(retval2)
+  params2.add(this2)
+  for i in 2 ..< params.len:
+    let param = params[i]
+    for i in 0 ..< param.len - 2:
+      let id2 = newIdentDefs(ident(param[i].strVal), param[^2])
+      params2.add(id2)
+  for i in 2 ..< params2.len:
+    let s = params2[i][0] # sym e.g. url
+    body.add(quote do:
+      when typeof(`s`) is FileHandle:
+        SocketStream(`thisval`.stream).sendFileHandle(`s`)
+      else:
+        `thisval`.stream.swrite(`s`))
+  body.add(quote do:
+    `thisval`.stream.flush())
+  body.add(quote do:
+    let promise = `addfun`
+    inc `thisval`.packetid
+    return promise)
+  var pragmas: NimNode
+  if retval.kind == nnkEmpty:
+    pragmas = newNimNode(nnkPragma).add(ident("discardable"))
+  else:
+    pragmas = newEmptyNode()
+  return (newProc(name, params2, body, pragmas = pragmas), nup)
+
+type
+  ProxyFunction = ref object
+    iname: NimNode # internal name
+    ename: NimNode # enum name
+    params: seq[NimNode]
+    istask: bool
+  ProxyMap = Table[string, ProxyFunction]
+
+# Name -> ProxyFunction
+var ProxyFunctions {.compileTime.}: ProxyMap
+
+proc getProxyFunction(funid: string): ProxyFunction =
+  if funid notin ProxyFunctions:
+    ProxyFunctions[funid] = ProxyFunction()
+  return ProxyFunctions[funid]
+
+macro proxy0(fun: untyped) =
+  fun[0] = ident(fun[0].strVal & "_internal")
+  return fun
+
+macro proxy1(fun: typed) =
+  let funid = getFunId(fun)
+  let iproc = buildInterfaceProc(fun, funid)
+  let pfun = getProxyFunction(funid)
+  pfun.iname = ident(fun[0].strVal & "_internal")
+  pfun.ename = iproc[1]
+  pfun.params.add(fun[3][0])
+  var params2: seq[NimNode]
+  params2.add(fun[3][0])
+  for i in 1 ..< fun[3].len:
+    let param = fun[3][i]
+    pfun.params.add(param)
+    for i in 0 ..< param.len - 2:
+      let id2 = newIdentDefs(ident(param[i].strVal), param[^2])
+      params2.add(id2)
+  ProxyFunctions[funid] = pfun
+  return iproc[0]
+
+macro proxy(fun: typed) =
+  quote do:
+    proxy0(`fun`)
+    proxy1(`fun`)
+
+macro task(fun: typed) =
+  let funid = getFunId(fun) 
+  let pfun = getProxyFunction(funid)
+  pfun.istask = true
+  fun
+
+func url(buffer: Buffer): URL =
+  return buffer.source.location
+
+func charsets(buffer: Buffer): seq[Charset] =
+  if buffer.source.charset != CHARSET_UNKNOWN:
+    return @[buffer.source.charset]
+  return buffer.config.charsets
+
+func getTitleAttr(node: StyledNode): string =
+  if node == nil:
+    return ""
+  if node.t == STYLED_ELEMENT and node.node != nil:
+    let element = Element(node.node)
+    if element.attrb("title"):
+      return element.attr("title")
+  if node.node != nil:
+    var node = node.node
+    for element in node.ancestors:
+      if element.attrb("title"):
+        return element.attr("title")
+  #TODO pseudo-elements
+
+const ClickableElements = {
+  TAG_A, TAG_INPUT, TAG_OPTION, TAG_BUTTON, TAG_TEXTAREA, TAG_LABEL
+}
+
+func isClickable(styledNode: StyledNode): bool =
+  if styledNode.t != STYLED_ELEMENT or styledNode.node == nil:
+    return false
+  if styledNode.computed{"visibility"} != VISIBILITY_VISIBLE:
+    return false
+  let element = Element(styledNode.node)
+  if element.tagType == TAG_A:
+    return HTMLAnchorElement(element).href != ""
+  return element.tagType in ClickableElements
+
+func getClickable(styledNode: StyledNode): Element =
+  var styledNode = styledNode
+  while styledNode != nil:
+    if styledNode.isClickable():
+      return Element(styledNode.node)
+    styledNode = stylednode.parent
+
+func submitForm(form: HTMLFormElement, submitter: Element): Option[Request]
+
+func canSubmitOnClick(fae: FormAssociatedElement): bool =
+  if fae.form == nil:
+    return false
+  if fae.form.canSubmitImplicitly():
+    return true
+  if fae.tagType == TAG_BUTTON:
+    if HTMLButtonElement(fae).ctype == BUTTON_SUBMIT:
+      return true
+  if fae.tagType == TAG_INPUT:
+    if HTMLInputElement(fae).inputType in {INPUT_SUBMIT, INPUT_BUTTON}:
+      return true
+  return false
+
+func getClickHover(styledNode: StyledNode): string =
+  let clickable = styledNode.getClickable()
+  if clickable != nil:
+    case clickable.tagType
+    of TAG_A:
+      return HTMLAnchorElement(clickable).href
+    of TAG_INPUT:
+      #TODO this is inefficient and also quite stupid
+      if clickable.tagType in FormAssociatedElements:
+        let fae = FormAssociatedElement(clickable)
+        if fae.canSubmitOnClick():
+          let req = fae.form.submitForm(fae)
+          if req.isSome:
+            return $req.get.url
+      return "<input>"
+    of TAG_OPTION:
+      return "<option>"
+    of TAG_BUTTON:
+      return "<button>"
+    of TAG_TEXTAREA:
+      return "<textarea>"
+    else: discard
+
+func getCursorStyledNode(buffer: Buffer, cursorx, cursory: int): StyledNode =
+  let i = buffer.lines[cursory].findFormatN(cursorx) - 1
+  if i >= 0:
+    return buffer.lines[cursory].formats[i].node
+
+func getCursorElement(buffer: Buffer, cursorx, cursory: int): Element =
+  let styledNode = buffer.getCursorStyledNode(cursorx, cursory)
+  if styledNode == nil or styledNode.node == nil:
+    return nil
+  if styledNode.t == STYLED_ELEMENT:
+    return Element(styledNode.node)
+  return styledNode.node.parentElement
+
+func getCursorClickable(buffer: Buffer, cursorx, cursory: int): Element =
+  let styledNode = buffer.getCursorStyledNode(cursorx, cursory)
+  if styledNode != nil:
+    return styledNode.getClickable()
+
+func cursorBytes(buffer: Buffer, y: int, cc: int): int =
+  let line = buffer.lines[y].str
+  var w = 0
+  var i = 0
+  while i < line.len and w < cc:
+    var r: Rune
+    fastRuneAt(line, i, r)
+    w += r.twidth(w)
+  return i
+
+proc navigate(buffer: Buffer, url: URL) =
+  #TODO how?
+  eprint "navigate to", url
+
+proc findPrevLink*(buffer: Buffer, cursorx, cursory: int): tuple[x, y: int] {.proxy.} =
+  if cursory >= buffer.lines.len: return (-1, -1)
+  let line = buffer.lines[cursory]
+  var i = line.findFormatN(cursorx) - 1
+  var link: Element = nil
+  if i >= 0:
+    link = line.formats[i].node.getClickable()
+  dec i
+
+  var ly = 0 #last y
+  var lx = 0 #last x
+  template link_beginning() =
+    #go to beginning of link
+    ly = y #last y
+    lx = format.pos #last x
+
+    #on the current line
+    let line = buffer.lines[y]
+    while i >= 0:
+      let format = line.formats[i]
+      let nl = format.node.getClickable()
+      if nl == fl:
+        lx = format.pos
+      dec i
+
+    #on previous lines
+    for iy in countdown(ly - 1, 0):
+      let line = buffer.lines[iy]
+      i = line.formats.len - 1
+      while i >= 0:
+        let format = line.formats[i]
+        let nl = format.node.getClickable()
+        if nl == fl:
+          ly = iy
+          lx = format.pos
+        dec i
+
+  while i >= 0:
+    let format = line.formats[i]
+    let fl = format.node.getClickable()
+    if fl != nil and fl != link:
+      let y = cursory
+      link_beginning
+      return (lx, ly)
+    dec i
+
+  for y in countdown(cursory - 1, 0):
+    let line = buffer.lines[y]
+    i = line.formats.len - 1
+    while i >= 0:
+      let format = line.formats[i]
+      let fl = format.node.getClickable()
+      if fl != nil and fl != link:
+        link_beginning
+        return (lx, ly)
+      dec i
+  return (-1, -1)
+
+proc findNextLink*(buffer: Buffer, cursorx, cursory: int): tuple[x, y: int] {.proxy.} =
+  if cursory >= buffer.lines.len: return (-1, -1)
+  let line = buffer.lines[cursory]
+  var i = line.findFormatN(cursorx) - 1
+  var link: Element = nil
+  if i >= 0:
+    link = line.formats[i].node.getClickable()
+  inc i
+
+  while i < line.formats.len:
+    let format = line.formats[i]
+    let fl = format.node.getClickable()
+    if fl != nil and fl != link:
+      return (format.pos, cursory)
+    inc i
+
+  for y in (cursory + 1)..(buffer.lines.len - 1):
+    let line = buffer.lines[y]
+    i = 0
+    while i < line.formats.len:
+      let format = line.formats[i]
+      let fl = format.node.getClickable()
+      if fl != nil and fl != link:
+        return (format.pos, y)
+      inc i
+  return (-1, -1)
+
+proc findPrevMatch*(buffer: Buffer, regex: Regex, cursorx, cursory: int, wrap: bool): BufferMatch {.proxy.} =
+  if cursory >= buffer.lines.len: return
+  var y = cursory
+  let b = buffer.cursorBytes(y, cursorx)
+  let res = regex.exec(buffer.lines[y].str, 0, b)
+  if res.success and res.captures.len > 0:
+    let cap = res.captures[^1]
+    let x = buffer.lines[y].str.width(0, cap.s)
+    let str = buffer.lines[y].str.substr(cap.s, cap.e - 1)
+    return BufferMatch(success: true, x: x, y: y, str: str)
+  dec y
+  while true:
+    if y < 0:
+      if wrap:
+        y = buffer.lines.high
+      else:
+        break
+    let res = regex.exec(buffer.lines[y].str)
+    if res.success and res.captures.len > 0:
+      let cap = res.captures[^1]
+      let x = buffer.lines[y].str.width(0, cap.s)
+      let str = buffer.lines[y].str.substr(cap.s, cap.e - 1)
+      return BufferMatch(success: true, x: x, y: y, str: str)
+    if y == cursory:
+      break
+    dec y
+
+proc findNextMatch*(buffer: Buffer, regex: Regex, cursorx, cursory: int, wrap: bool): BufferMatch {.proxy.} =
+  if cursory >= buffer.lines.len: return
+  var y = cursory
+  let b = buffer.cursorBytes(y, cursorx + 1)
+  let res = regex.exec(buffer.lines[y].str, b, buffer.lines[y].str.len)
+  if res.success and res.captures.len > 0:
+    let cap = res.captures[0]
+    let x = buffer.lines[y].str.width(0, cap.s)
+    let str = buffer.lines[y].str.substr(cap.s, cap.e - 1)
+    return BufferMatch(success: true, x: x, y: y, str: str)
+  inc y
+  while true:
+    if y > buffer.lines.high:
+      if wrap:
+        y = 0
+      else:
+        break
+    let res = regex.exec(buffer.lines[y].str)
+    if res.success and res.captures.len > 0:
+      let cap = res.captures[0]
+      let x = buffer.lines[y].str.width(0, cap.s)
+      let str = buffer.lines[y].str.substr(cap.s, cap.e - 1)
+      return BufferMatch(success: true, x: x, y: y, str: str)
+    if y == cursory:
+      break
+    inc y
+
+proc gotoAnchor*(buffer: Buffer): tuple[x, y: int] {.proxy.} =
+  if buffer.document == nil: return (-1, -1)
+  let anchor = buffer.document.getElementById(buffer.url.anchor)
+  if anchor == nil: return
+  for y in 0 ..< buffer.lines.len:
+    let line = buffer.lines[y]
+    for i in 0 ..< line.formats.len:
+      let format = line.formats[i]
+      if format.node != nil and anchor in format.node.node:
+        return (format.pos, y)
+  return (-1, -1)
+
+proc do_reshape(buffer: Buffer) =
+  case buffer.contenttype
+  of "text/html":
+    if buffer.viewport == nil:
+      buffer.viewport = Viewport(window: buffer.attrs)
+    let ret = renderDocument(buffer.document, buffer.userstyle,
+      buffer.viewport, buffer.prevstyled)
+    buffer.lines = ret.grid
+    buffer.prevstyled = ret.styledRoot
+  else:
+    buffer.lines.renderStream(buffer.srenderer, buffer.available)
+    buffer.available = 0
+
+proc windowChange*(buffer: Buffer, attrs: WindowAttributes) {.proxy.} =
+  buffer.attrs = attrs
+  buffer.viewport = Viewport(window: buffer.attrs)
+  buffer.width = buffer.attrs.width
+  buffer.height = buffer.attrs.height - 1
+
+type UpdateHoverResult* = object
+  link*: Option[string]
+  title*: Option[string]
+  repaint*: bool
+
+proc updateHover*(buffer: Buffer, cursorx, cursory: int): UpdateHoverResult {.proxy.} =
+  if buffer.lines.len == 0: return
+  var thisnode: StyledNode
+  let i = buffer.lines[cursory].findFormatN(cursorx) - 1
+  if i >= 0:
+    thisnode = buffer.lines[cursory].formats[i].node
+  let prevnode = buffer.prevnode
+
+  if thisnode != prevnode and (thisnode == nil or prevnode == nil or thisnode.node != prevnode.node):
+    for styledNode in thisnode.branch:
+      if styledNode.t == STYLED_ELEMENT and styledNode.node != nil:
+        let elem = Element(styledNode.node)
+        if not elem.hover:
+          elem.hover = true
+          result.repaint = true
+
+    let title = thisnode.getTitleAttr()
+    if buffer.hovertext[HOVER_TITLE] != title:
+      result.title = some(title)
+      buffer.hovertext[HOVER_TITLE] = title
+    let click = thisnode.getClickHover()
+    if buffer.hovertext[HOVER_LINK] != click:
+      result.link = some(click)
+      buffer.hovertext[HOVER_LINK] = click
+
+    for styledNode in prevnode.branch:
+      if styledNode.t == STYLED_ELEMENT and styledNode.node != nil:
+        let elem = Element(styledNode.node)
+        if elem.hover:
+          elem.hover = false
+          result.repaint = true
+  if result.repaint:
+    buffer.do_reshape()
+
+  buffer.prevnode = thisnode
+
+proc loadResource(buffer: Buffer, elem: HTMLLinkElement): EmptyPromise =
+  let document = buffer.document
+  let href = elem.attr("href")
+  if href == "": return
+  let url = parseURL(href, document.url.some)
+  if url.isSome:
+    let url = url.get
+    let media = elem.media
+    if media != "":
+      let cvals = parseListOfComponentValues(newStringStream(media))
+      let media = parseMediaQueryList(cvals)
+      if not media.applies(document.window): return
+    return buffer.loader.fetch(newRequest(url))
+      .then(proc(res: JSResult[Response]): Promise[JSResult[string]] =
+        if res.isOk:
+          let res = res.get
+          #TODO we should use ReadableStreams for this (which would allow us to
+          # parse CSS asynchronously)
+          if res.contenttype == "text/css":
+            return res.text()
+          res.unregisterFun()
+      ).then(proc(s: JSResult[string]) =
+        if s.isOk:
+          #TODO this is extremely inefficient, and text() should return
+          # utf8 anyways
+          let ss = newStringStream(s.get)
+          #TODO non-utf-8 css
+          let source = newDecoderStream(ss, cs = CHARSET_UTF_8).readAll()
+          let ss2 = newStringStream(source)
+          elem.sheet = parseStylesheet(ss2))
+
+proc loadResource(buffer: Buffer, elem: HTMLImageElement): EmptyPromise =
+  let document = buffer.document
+  let src = elem.attr("src")
+  if src == "": return
+  let url = parseURL(src, document.url.some)
+  if url.isSome:
+    let url = url.get
+    return buffer.loader.fetch(newRequest(url))
+      .then(proc(res: JSResult[Response]): Promise[JSResult[string]] =
+        if res.isErr:
+          return
+        let res = res.get
+        if res.contenttype == "image/png":
+          #TODO using text() for PNG is wrong
+          return res.text()
+      ).then(proc(pngData: JSResult[string]) =
+        if pngData.isErr:
+          return
+        let pngData = pngData.get
+        elem.bitmap = fromPNG(toOpenArrayByte(pngData, 0, pngData.high)))
+
+proc loadResources(buffer: Buffer): EmptyPromise =
+  let document = buffer.document
+  var promises: seq[EmptyPromise]
+  if document.html != nil:
+    var searchElems = {TAG_LINK}
+    if buffer.config.images:
+      searchElems.incl(TAG_IMG)
+    for elem in document.html.elements(searchElems):
+      var p: EmptyPromise = nil
+      case elem.tagType
+      of TAG_LINK:
+        let elem = HTMLLinkElement(elem)
+        if elem.rel == "stylesheet":
+          p = buffer.loadResource(elem)
+      of TAG_IMG:
+        let elem = HTMLImageElement(elem)
+        p = buffer.loadResource(elem)
+      else: discard
+      if p != nil:
+        promises.add(p)
+  return all(promises)
+
+type ConnectResult* = object
+  invalid*: bool
+  code*: int
+  needsAuth*: bool
+  redirect*: Request
+  contentType*: string
+  cookies*: seq[Cookie]
+  referrerpolicy*: Option[ReferrerPolicy]
+  charset*: Charset
+
+proc connect*(buffer: Buffer): ConnectResult {.proxy.} =
+  if buffer.connected:
+    return ConnectResult(invalid: true)
+  let source = buffer.source
+  # Warning: source content type overrides received content types, but source
+  # charset is just a fallback.
+  let setct = source.contenttype.isNone
+  if not setct:
+    buffer.contenttype = source.contenttype.get
+  var charset = source.charset
+  var needsAuth = false
+  var redirect: Request
+  var cookies: seq[Cookie]
+  var referrerpolicy: Option[ReferrerPolicy]
+  case source.t
+  of CLONE:
+    #TODO clone should probably just fork() the buffer instead.
+    let s = connectSocketStream(source.clonepid, blocking = false)
+    buffer.istream = s
+    buffer.fd = int(s.source.getFd())
+    if buffer.istream == nil:
+      return ConnectResult(code: ERROR_SOURCE_NOT_FOUND)
+    if setct:
+      buffer.contenttype = "text/plain"
+  of LOAD_PIPE:
+    discard fcntl(source.fd, F_SETFL, fcntl(source.fd, F_GETFL, 0) or O_NONBLOCK)
+    buffer.istream = newPosixStream(source.fd)
+    buffer.fd = source.fd
+    if setct:
+      buffer.contenttype = "text/plain"
+  of LOAD_REQUEST:
+    let request = source.request
+    let response = buffer.loader.doRequest(request, blocking = true, canredir = true)
+    if response.body == nil:
+      return ConnectResult(code: response.res)
+    if response.charset != CHARSET_UNKNOWN:
+      charset = charset
+    if setct:
+      buffer.contenttype = response.contenttype
+    buffer.istream = response.body
+    let fd = SocketStream(response.body).source.getFd()
+    buffer.fd = int(fd)
+    needsAuth = response.status == 401 # Unauthorized
+    redirect = response.redirect
+    if "Set-Cookie" in response.headers.table:
+      for s in response.headers.table["Set-Cookie"]:
+        let cookie = newCookie(s, response.url)
+        if cookie.isOk:
+          cookies.add(cookie.get)
+    if "Referrer-Policy" in response.headers.table:
+      referrerpolicy = getReferrerPolicy(response.headers.table["Referrer-Policy"][0])
+  buffer.connected = true
+  return ConnectResult(
+    charset: charset,
+    needsAuth: needsAuth,
+    redirect: redirect,
+    cookies: cookies,
+    contentType: if setct: buffer.contenttype else: ""
+  )
+
+# After connect, pager will call one of the following:
+# * connect2, telling loader to load at last (we block loader until then)
+# * redirectToFd, telling loader to load into the passed fd
+proc connect2*(buffer: Buffer) {.proxy.} =
+  if buffer.source.t == LOAD_REQUEST:
+    # Notify loader that we can proceed with loading the input stream.
+    let ss = SocketStream(buffer.istream)
+    ss.swrite(false)
+    ss.setBlocking(false)
+  buffer.istream = newTeeStream(buffer.istream, buffer.sstream,
+    closedest = false)
+  buffer.selector.registerHandle(buffer.fd, {Read}, 0)
+
+proc redirectToFd*(buffer: Buffer, fd: FileHandle, wait: bool) {.proxy.} =
+  #TODO also clone & fd
+  if buffer.source.t == LOAD_REQUEST:
+    let ss = SocketStream(buffer.istream)
+    ss.swrite(true)
+    ss.sendFileHandle(fd)
+    if wait:
+      #TODO this is kind of dumb
+      # Basically, after redirect the network process keeps the socket open,
+      # and writes a boolean after transfer has been finished. This way,
+      # we can block this promise so it only returns after e.g. the whole
+      # file has been saved.
+      var dummy: bool
+      ss.sread(dummy)
+    discard close(fd)
+    ss.close()
+
+proc readFromFd*(buffer: Buffer, fd: FileHandle, ishtml: bool) {.proxy.} =
+  let contentType = if ishtml:
+    "text/html"
+  else:
+    "text/plain"
+  buffer.source = BufferSource(
+    t: LOAD_PIPE,
+    fd: fd,
+    location: buffer.source.location,
+    contenttype: some(contentType),
+    charset: buffer.source.charset
+  )
+  buffer.contenttype = contentType
+  discard fcntl(fd, F_SETFL, fcntl(fd, F_GETFL, 0) or O_NONBLOCK)
+  let ps = newPosixStream(fd)
+  buffer.istream = newTeeStream(ps, buffer.sstream,
+    closedest = false)
+  buffer.fd = fd
+  buffer.selector.registerHandle(buffer.fd, {Read}, 0)
+
+proc setContentType*(buffer: Buffer, contentType: string) {.proxy.} =
+  buffer.source.contenttype = some(contentType)
+
+const BufferSize = 4096
+
+proc finishLoad(buffer: Buffer): EmptyPromise =
+  if buffer.state != LOADING_PAGE:
+    let p = EmptyPromise()
+    p.resolve()
+    return p
+  var p: EmptyPromise
+  case buffer.contenttype
+  of "text/html":
+    buffer.sstream.setPosition(0)
+    buffer.available = 0
+    if buffer.window == nil:
+      buffer.window = newWindow(buffer.config.scripting, buffer.selector,
+        buffer.attrs)
+    let doc = parseHTML(buffer.sstream, charsets = buffer.charsets,
+      window = buffer.window, url = buffer.url)
+    buffer.document = doc
+    buffer.state = LOADING_RESOURCES
+    p = buffer.loadResources()
+  else:
+    p = EmptyPromise()
+    p.resolve()
+  buffer.selector.unregister(buffer.fd)
+  buffer.loader.unregistered.add(buffer.fd)
+  buffer.fd = -1
+  buffer.istream.close()
+  return p
+
+type LoadResult* = tuple[
+  atend: bool,
+  lines: int,
+  bytes: int
+]
+
+proc load*(buffer: Buffer): LoadResult {.proxy, task.} =
+  if buffer.state == LOADED:
+    return (true, buffer.lines.len, -1)
+  else:
+    buffer.savetask = true
+
+proc resolveTask[T](buffer: Buffer, cmd: BufferCommand, res: T) =
+  let packetid = buffer.tasks[cmd]
+  if packetid == 0:
+    return # no task to resolve (TODO this is kind of inefficient)
+  let len = slen(buffer.tasks[cmd]) + slen(res)
+  buffer.pstream.swrite(len)
+  buffer.pstream.swrite(packetid)
+  buffer.tasks[cmd] = 0
+  buffer.pstream.swrite(res)
+  buffer.pstream.flush()
+
+proc onload(buffer: Buffer) =
+  var res: LoadResult = (false, buffer.lines.len, -1)
+  case buffer.state
+  of LOADING_RESOURCES:
+    assert false
+  of LOADED:
+    buffer.resolveTask(LOAD, res)
+    return
+  of LOADING_PAGE:
+    discard
+  let op = buffer.sstream.getPosition()
+  var s = newString(buffer.readbufsize)
+  try:
+    buffer.sstream.setPosition(op + buffer.available)
+    let n = buffer.istream.readData(addr s[0], buffer.readbufsize)
+    if n != 0: # n can be 0 if we get EOF. (in which case we shouldn't reshape unnecessarily.)
+      s.setLen(n)
+      buffer.sstream.setPosition(op)
+      if buffer.readbufsize < BufferSize:
+        buffer.readbufsize = min(BufferSize, buffer.readbufsize * 2)
+      buffer.available += s.len
+      case buffer.contenttype
+      of "text/html":
+        res.bytes = buffer.available
+      else:
+        buffer.do_reshape()
+    if buffer.istream.atEnd():
+      res.atend = true
+      buffer.finishLoad().then(proc() =
+        buffer.state = LOADED
+        buffer.resolveTask(LOAD, res))
+      return
+    buffer.resolveTask(LOAD, res)
+  except ErrorAgain, ErrorWouldBlock:
+    if buffer.readbufsize > 1:
+      buffer.readbufsize = buffer.readbufsize div 2
+
+proc getTitle*(buffer: Buffer): string {.proxy.} =
+  if buffer.document != nil:
+    return buffer.document.title
+
+proc render*(buffer: Buffer): int {.proxy.} =
+  buffer.do_reshape()
+  return buffer.lines.len
+
+proc cancel*(buffer: Buffer): int {.proxy.} =
+  #TODO TODO TODO cancel resource loading too
+  if buffer.state != LOADING_PAGE: return
+  buffer.istream.close()
+  buffer.state = LOADED
+  case buffer.contenttype
+  of "text/html":
+    buffer.sstream.setPosition(0)
+    buffer.available = 0
+    if buffer.window == nil:
+      buffer.window = newWindow(buffer.config.scripting, buffer.selector,
+        buffer.attrs)
+    buffer.document = parseHTML(buffer.sstream,
+      charsets = buffer.charsets, window = buffer.window,
+      url = buffer.url, canReinterpret = false)
+    buffer.do_reshape()
+  return buffer.lines.len
+
+#https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#multipart/form-data-encoding-algorithm
+proc serializeMultipartFormData(entries: seq[FormDataEntry]): FormData =
+  let formData = newFormData0()
+  for entry in entries:
+    let name = makeCRLF(entry.name)
+    if entry.isstr:
+      let value = makeCRLF(entry.svalue)
+      formData.append(name, value)
+    else:
+      formData.append(name, entry.value, opt(entry.filename))
+  return formData
+
+proc serializePlainTextFormData(kvs: seq[(string, string)]): string =
+  for it in kvs:
+    let (name, value) = it
+    result &= name
+    result &= '='
+    result &= value
+    result &= "\r\n"
+
+func submitForm(form: HTMLFormElement, submitter: Element): Option[Request] =
+  if form.constructingEntryList:
+    return
+  let entrylist = form.constructEntryList(submitter).get(@[])
+
+  let action = if submitter.action() == "":
+    $form.document.url
+  else:
+    submitter.action()
+
+  let url = submitter.document.parseURL(action)
+  if url.isnone:
+    return none(Request)
+
+  var parsedaction = url.get
+  let scheme = parsedaction.scheme
+  let enctype = submitter.enctype()
+  let formmethod = submitter.formmethod()
+  if formmethod == FORM_METHOD_DIALOG:
+    #TODO
+    return none(Request)
+  let httpmethod = if formmethod == FORM_METHOD_GET:
+    HTTP_GET
+  else:
+    assert formmethod == FORM_METHOD_POST
+    HTTP_POST
+
+  #let target = if submitter.isSubmitButton() and submitter.attrb("formtarget"):
+  #  submitter.attr("formtarget")
+  #else:
+  #  submitter.target()
+  #let noopener = true #TODO
+
+  template mutateActionUrl() =
+    let kvlist = entrylist.toNameValuePairs()
+    let query = serializeApplicationXWWWFormUrlEncoded(kvlist)
+    parsedaction.query = query.some
+    return newRequest(parsedaction, httpmethod).some
+
+  template submitAsEntityBody() =
+    var mimetype: string
+    var body: Opt[string]
+    var multipart: Opt[FormData]
+    case enctype
+    of FORM_ENCODING_TYPE_URLENCODED:
+      let kvlist = entrylist.toNameValuePairs()
+      body.ok(serializeApplicationXWWWFormUrlEncoded(kvlist))
+      mimeType = $enctype
+    of FORM_ENCODING_TYPE_MULTIPART:
+      multipart.ok(serializeMultipartFormData(entrylist))
+      mimetype = $enctype
+    of FORM_ENCODING_TYPE_TEXT_PLAIN:
+      let kvlist = entrylist.toNameValuePairs()
+      body.ok(serializePlainTextFormData(kvlist))
+      mimetype = $enctype
+    let req = newRequest(parsedaction, httpmethod, @{"Content-Type": mimetype},
+      body, multipart)
+    return some(req) #TODO multipart
+
+  template getActionUrl() =
+    return newRequest(parsedaction).some
+
+  case scheme
+  of "http", "https":
+    if formmethod == FORM_METHOD_GET:
+      mutateActionUrl
+    else:
+      assert formmethod == FORM_METHOD_POST
+      submitAsEntityBody
+  of "ftp":
+    getActionUrl
+  of "data":
+    if formmethod == FORM_METHOD_GET:
+      mutateActionUrl
+    else:
+      assert formmethod == FORM_METHOD_POST
+      getActionUrl
+
+proc setFocus(buffer: Buffer, e: Element): bool =
+  if buffer.document.focus != e:
+    buffer.document.focus = e
+    buffer.do_reshape()
+    return true
+
+proc restoreFocus(buffer: Buffer): bool =
+  if buffer.document.focus != nil:
+    buffer.document.focus = nil
+    buffer.do_reshape()
+    return true
+
+type ReadSuccessResult* = object
+  open*: Option[Request]
+  repaint*: bool
+
+func implicitSubmit(input: HTMLInputElement): Option[Request] =
+  if input.form != nil and input.form.canSubmitImplicitly():
+    return submitForm(input.form, input.form)
+
+proc readSuccess*(buffer: Buffer, s: string): ReadSuccessResult {.proxy.} =
+  if buffer.document.focus != nil:
+    case buffer.document.focus.tagType
+    of TAG_INPUT:
+      let input = HTMLInputElement(buffer.document.focus)
+      case input.inputType
+      of INPUT_SEARCH, INPUT_TEXT, INPUT_PASSWORD:
+        input.value = s
+        input.invalid = true
+        buffer.do_reshape()
+        result.repaint = true
+        result.open = implicitSubmit(input)
+      of INPUT_FILE:
+        let cdir = parseURL("file://" & getCurrentDir() & DirSep)
+        let path = parseURL(s, cdir)
+        if path.issome:
+          input.file = path
+          input.invalid = true
+          buffer.do_reshape()
+          result.repaint = true
+          result.open = implicitSubmit(input)
+      else: discard
+    of TAG_TEXTAREA:
+      let textarea = HTMLTextAreaElement(buffer.document.focus)
+      textarea.value = s
+      textarea.invalid = true
+      buffer.do_reshape()
+      result.repaint = true
+    else: discard
+    let r = buffer.restoreFocus()
+    if not result.repaint:
+      result.repaint = r
+
+type ReadLineResult* = object
+  prompt*: string
+  value*: string
+  hide*: bool
+  area*: bool
+
+type
+  SelectResult* = object
+    multiple*: bool
+    options*: seq[string]
+    selected*: seq[int]
+
+  ClickResult* = object
+    open*: Option[Request]
+    readline*: Option[ReadLineResult]
+    repaint*: bool
+    select*: Option[SelectResult]
+
+proc click(buffer: Buffer, clickable: Element): ClickResult
+
+proc click(buffer: Buffer, label: HTMLLabelElement): ClickResult =
+  let control = label.control
+  if control != nil:
+    return buffer.click(control)
+
+proc click(buffer: Buffer, select: HTMLSelectElement): ClickResult =
+  let repaint = buffer.setFocus(select)
+  var options: seq[string]
+  var selected: seq[int]
+  var i = 0
+  for option in select.options:
+    options.add(option.textContent.stripAndCollapse())
+    if option.selected:
+      selected.add(i)
+    inc i
+  let select = SelectResult(
+    multiple: select.attrb("multiple"),
+    options: options,
+    selected: selected
+  )
+  return ClickResult(
+    repaint: repaint,
+    select: some(select)
+  )
+
+func baseURL(buffer: Buffer): URL =
+  return buffer.document.baseURL
+
+proc evalJSURL(buffer: Buffer, url: URL): Opt[string] =
+  let encodedScriptSource = ($url)["javascript:".len..^1]
+  let scriptSource = percentDecode(encodedScriptSource)
+  let ctx = buffer.window.jsctx
+  let ret = ctx.eval(scriptSource, $buffer.baseURL, JS_EVAL_TYPE_GLOBAL)
+  if JS_IsException(ret):
+    ctx.writeException(buffer.estream)
+    return err() # error
+  if JS_IsUndefined(ret):
+    return err() # no need to navigate
+  let s = ?fromJS[string](ctx, ret)
+  JS_FreeValue(ctx, ret)
+  # Navigate to result.
+  return ok(s)
+
+proc click(buffer: Buffer, anchor: HTMLAnchorElement): ClickResult =
+  var repaint = buffer.restoreFocus()
+  let url = parseURL(anchor.href, some(buffer.baseURL))
+  if url.isSome:
+    let url = url.get
+    if url.scheme == "javascript":
+      if buffer.config.scripting:
+        let s = buffer.evalJSURL(url)
+        buffer.do_reshape()
+        repaint = true
+        if s.isSome:
+          let url = newURL("data:text/html," & s.get).get
+          let req = newRequest(url, HTTP_GET)
+          return ClickResult(
+            repaint: repaint,
+            open: some(req)
+          )
+      return ClickResult(
+        repaint: repaint
+      )
+    return ClickResult(
+      repaint: repaint,
+      open: some(newRequest(url, HTTP_GET))
+    )
+  return ClickResult(
+    repaint: repaint
+  )
+
+proc click(buffer: Buffer, option: HTMLOptionElement): ClickResult =
+  let select = option.select
+  if select != nil:
+    return buffer.click(select)
+
+proc click(buffer: Buffer, button: HTMLButtonElement): ClickResult =
+  if button.form != nil:
+    case button.ctype
+    of BUTTON_SUBMIT: result.open = submitForm(button.form, button)
+    of BUTTON_RESET:
+      button.form.reset()
+      buffer.do_reshape()
+      return ClickResult(repaint: true)
+    of BUTTON_BUTTON: discard
+    result.repaint = buffer.setFocus(button)
+
+proc click(buffer: Buffer, textarea: HTMLTextAreaElement): ClickResult =
+  let repaint = buffer.setFocus(textarea)
+  let readline = ReadLineResult(
+    value: textarea.value,
+    area: true,
+  )
+  return ClickResult(
+    readline: some(readline),
+    repaint: repaint
+  )
+
+proc click(buffer: Buffer, input: HTMLInputElement): ClickResult =
+  result.repaint = buffer.restoreFocus()
+  case input.inputType
+  of INPUT_SEARCH:
+    result.repaint = buffer.setFocus(input)
+    result.readline = some(ReadLineResult(
+      prompt: "SEARCH: ",
+      value: input.value
+    ))
+  of INPUT_TEXT, INPUT_PASSWORD:
+    result.repaint = buffer.setFocus(input)
+    result.readline = some(ReadLineResult(
+      prompt: "TEXT: ",
+      value: input.value,
+      hide: input.inputType == INPUT_PASSWORD
+    ))
+  of INPUT_FILE:
+    result.repaint = buffer.setFocus(input)
+    var path = if input.file.issome:
+      input.file.get.path.serialize_unicode()
+    else:
+      ""
+    result.readline = some(ReadLineResult(
+      prompt: "Filename: ",
+      value: path
+    ))
+  of INPUT_CHECKBOX:
+    input.checked = not input.checked
+    input.invalid = true
+    result.repaint = true
+    buffer.do_reshape()
+  of INPUT_RADIO:
+    for radio in input.radiogroup:
+      radio.checked = false
+      radio.invalid = true
+    input.checked = true
+    input.invalid = true
+    result.repaint = true
+    buffer.do_reshape()
+  of INPUT_RESET:
+    if input.form != nil:
+      input.form.reset()
+      result.repaint = true
+      buffer.do_reshape()
+  of INPUT_SUBMIT, INPUT_BUTTON:
+    if input.form != nil:
+      result.open = submitForm(input.form, input)
+  else:
+    result.repaint = buffer.restoreFocus()
+
+proc click(buffer: Buffer, clickable: Element): ClickResult =
+  case clickable.tagType
+  of TAG_LABEL:
+    return buffer.click(HTMLLabelElement(clickable))
+  of TAG_SELECT:
+    return buffer.click(HTMLSelectElement(clickable))
+  of TAG_A:
+    return buffer.click(HTMLAnchorElement(clickable))
+  of TAG_OPTION:
+    return buffer.click(HTMLOptionElement(clickable))
+  of TAG_BUTTON:
+    return buffer.click(HTMLButtonElement(clickable))
+  of TAG_TEXTAREA:
+    return buffer.click(HTMLTextAreaElement(clickable))
+  of TAG_INPUT:
+    return buffer.click(HTMLInputElement(clickable))
+  else:
+    result.repaint = buffer.restoreFocus()
+
+proc dispatchEvent(buffer: Buffer, ctype: string, elem: Element): tuple[
+      called: bool,
+      canceled: bool
+    ] =
+  var called = false
+  var canceled = false
+  for a in elem.branch:
+    var stop = false
+    for el in a.eventListeners:
+      if el.ctype == "click":
+        let event = newEvent(buffer.window.jsctx, ctype, elem, a)
+        let e = el.callback(event)
+        called = true
+        if e.isErr:
+          buffer.window.jsctx.writeException(buffer.estream)
+        if FLAG_STOP_IMMEDIATE_PROPAGATION in event.flags:
+          stop = true
+          break
+        if FLAG_STOP_PROPAGATION in event.flags:
+          stop = true
+        if FLAG_CANCELED in event.flags:
+          canceled = true
+    if stop:
+      break
+  return (called, canceled)
+
+proc click*(buffer: Buffer, cursorx, cursory: int): ClickResult {.proxy.} =
+  if buffer.lines.len <= cursory: return
+  var called = false
+  var canceled = false
+  if buffer.config.scripting:
+    let elem = buffer.getCursorElement(cursorx, cursory)
+    (called, canceled) = buffer.dispatchEvent("click", elem)
+    if called:
+      buffer.do_reshape()
+  if not canceled:
+    let clickable = buffer.getCursorClickable(cursorx, cursory)
+    if clickable != nil:
+      var res = buffer.click(clickable)
+      res.repaint = called
+      return res
+  return ClickResult(repaint: called)
+
+proc select*(buffer: Buffer, selected: seq[int]): ClickResult {.proxy.} =
+  if buffer.document.focus != nil and
+      buffer.document.focus.tagType == TAG_SELECT:
+    let select = HTMLSelectElement(buffer.document.focus)
+    var i = 0
+    var j = 0
+    var repaint = false
+    for option in select.options:
+      var wasSelected = option.selected
+      if i < selected.len and selected[i] == j:
+        option.selected = true
+        inc i
+      else:
+        option.selected = false
+      if not repaint:
+        repaint = wasSelected != option.selected
+      inc j
+    return ClickResult(repaint: buffer.restoreFocus())
+
+proc readCanceled*(buffer: Buffer): bool {.proxy.} =
+  return buffer.restoreFocus()
+
+proc findAnchor*(buffer: Buffer, anchor: string): bool {.proxy.} =
+  return buffer.document != nil and buffer.document.getElementById(anchor) != nil
+
+type GetLinesResult* = tuple[
+  numLines: int,
+  lines: seq[SimpleFlexibleLine]
+]
+
+proc getLines*(buffer: Buffer, w: Slice[int]): GetLinesResult {.proxy.} =
+  var w = w
+  if w.b < 0 or w.b > buffer.lines.high:
+    w.b = buffer.lines.high
+  #TODO this is horribly inefficient
+  for y in w:
+    var line = SimpleFlexibleLine(str: buffer.lines[y].str)
+    for f in buffer.lines[y].formats:
+      line.formats.add(SimpleFormatCell(format: f.format, pos: f.pos))
+    result.lines.add(line)
+  result.numLines = buffer.lines.len
+
+proc passFd*(buffer: Buffer, fd: FileHandle) {.proxy.} =
+  buffer.source.fd = fd
+
+#TODO this is mostly broken
+proc getSource*(buffer: Buffer) {.proxy.} =
+  let ssock = initServerSocket()
+  let stream = ssock.acceptSocketStream()
+  let op = buffer.sstream.getPosition()
+  buffer.sstream.setPosition(0)
+  stream.write(buffer.sstream.readAll())
+  buffer.sstream.setPosition(op)
+  stream.close()
+  ssock.close()
+
+macro bufferDispatcher(funs: static ProxyMap, buffer: Buffer,
+    cmd: BufferCommand, packetid: int) =
+  let switch = newNimNode(nnkCaseStmt)
+  switch.add(ident("cmd"))
+  for k, v in funs:
+    let ofbranch = newNimNode(nnkOfBranch)
+    ofbranch.add(v.ename)
+    let stmts = newStmtList()
+    let call = newCall(v.iname, buffer)
+    for i in 2 ..< v.params.len:
+      let param = v.params[i]
+      for i in 0 ..< param.len - 2:
+        let id = ident(param[i].strVal)
+        let typ = param[^2]
+        stmts.add(quote do:
+          when `typ` is FileHandle:
+            let `id` = SocketStream(`buffer`.pstream).recvFileHandle()
+          else:
+            var `id`: `typ`
+            `buffer`.pstream.sread(`id`))
+        call.add(id)
+    var rval: NimNode
+    if v.params[0].kind == nnkEmpty:
+      stmts.add(call)
+    else:
+      rval = ident("retval")
+      stmts.add(quote do:
+        let `rval` = `call`)
+    var resolve = newStmtList()
+    if rval == nil:
+      resolve.add(quote do:
+        let len = slen(`packetid`)
+        buffer.pstream.swrite(len)
+        buffer.pstream.swrite(`packetid`)
+        buffer.pstream.flush())
+    else:
+      resolve.add(quote do:
+        let len = slen(`packetid`) + slen(`rval`)
+        buffer.pstream.swrite(len)
+        buffer.pstream.swrite(`packetid`)
+        buffer.pstream.swrite(`rval`)
+        buffer.pstream.flush())
+    if v.istask:
+      let en = v.ename
+      stmts.add(quote do:
+        if buffer.savetask:
+          buffer.savetask = false
+          buffer.tasks[BufferCommand.`en`] = `packetid`
+        else:
+          `resolve`)
+    else:
+      stmts.add(resolve)
+    ofbranch.add(stmts)
+    switch.add(ofbranch)
+  return switch
+
+proc readCommand(buffer: Buffer) =
+  var cmd: BufferCommand
+  buffer.pstream.sread(cmd)
+  var packetid: int
+  buffer.pstream.sread(packetid)
+  bufferDispatcher(ProxyFunctions, buffer, cmd, packetid)
+
+proc handleRead(buffer: Buffer, fd: int) =
+  if fd == buffer.rfd:
+    try:
+      buffer.readCommand()
+    except EOFError:
+      #eprint "EOF error", $buffer.url & "\nMESSAGE:",
+      #       getCurrentExceptionMsg() & "\n",
+      #       getStackTrace(getCurrentException())
+      buffer.alive = false
+  elif fd == buffer.fd:
+    buffer.onload()
+  elif fd in buffer.loader.connecting:
+    buffer.loader.onConnected(fd)
+    if buffer.config.scripting:
+      buffer.window.runJSJobs()
+  elif fd in buffer.loader.ongoing:
+    buffer.loader.onRead(fd)
+    if buffer.config.scripting:
+      buffer.window.runJSJobs()
+  elif fd in buffer.loader.unregistered:
+    discard # ignore
+  else: assert false
+
+proc handleError(buffer: Buffer, fd: int, err: OSErrorCode) =
+  if fd == buffer.rfd:
+    # Connection reset by peer, probably. Close the buffer.
+    buffer.alive = false
+  elif fd == buffer.fd:
+    buffer.onload()
+  elif fd in buffer.loader.connecting:
+    # probably shouldn't happen. TODO
+    assert false, $fd & ": " & $err
+  elif fd in buffer.loader.ongoing:
+    buffer.loader.onError(fd)
+    if buffer.config.scripting:
+      buffer.window.runJSJobs()
+  elif fd in buffer.loader.unregistered:
+    discard # ignore
+  else:
+    assert false, $fd & ": " & $err
+
+proc runBuffer(buffer: Buffer, rfd: int) =
+  buffer.rfd = rfd
+  while buffer.alive:
+    let events = buffer.selector.select(-1)
+    for event in events:
+      if Read in event.events:
+        buffer.handleRead(event.fd)
+      if Error in event.events:
+        buffer.handleError(event.fd, event.errorCode)
+      if not buffer.alive:
+        break
+      if selectors.Event.Timer in event.events:
+        assert buffer.window != nil
+        assert buffer.window.timeouts.runTimeoutFd(event.fd)
+        buffer.window.runJSJobs()
+    buffer.loader.unregistered.setLen(0)
+
+proc launchBuffer*(config: BufferConfig, source: BufferSource,
+    attrs: WindowAttributes, loader: FileLoader, ssock: ServerSocket) =
+  let buffer = Buffer(
+    alive: true,
+    userstyle: parseStylesheet(config.userstyle),
+    attrs: attrs,
+    config: config,
+    loader: loader,
+    source: source,
+    sstream: newStringStream(),
+    viewport: Viewport(window: attrs),
+    width: attrs.width,
+    height: attrs.height - 1
+  )
+  buffer.readbufsize = BufferSize
+  buffer.selector = newSelector[int]()
+  loader.registerFun = proc(fd: int) = buffer.selector.registerHandle(fd, {Read}, 0)
+  loader.unregisterFun = proc(fd: int) = buffer.selector.unregister(fd)
+  buffer.srenderer = newStreamRenderer(buffer.sstream, buffer.charsets)
+  if buffer.config.scripting:
+    buffer.window = newWindow(buffer.config.scripting, buffer.selector,
+      buffer.attrs, proc(url: URL) = buffer.navigate(url), some(buffer.loader))
+  let socks = ssock.acceptSocketStream()
+  buffer.estream = newFileStream(stderr)
+  buffer.pstream = socks
+  let rfd = int(socks.source.getFd())
+  buffer.selector.registerHandle(rfd, {Read}, 0)
+  buffer.runBuffer(rfd)
+  buffer.pstream.close()
+  buffer.loader.quit()
+  quit(0)
diff --git a/src/server/forkserver.nim b/src/server/forkserver.nim
new file mode 100644
index 00000000..fafaf9c9
--- /dev/null
+++ b/src/server/forkserver.nim
@@ -0,0 +1,249 @@
+import options
+import streams
+import tables
+
+when defined(posix):
+  import posix
+
+import config/config
+import display/window
+import io/posixstream
+import io/serialize
+import io/serversocket
+import io/urlfilter
+import loader/headers
+import loader/loader
+import server/buffer
+import types/buffersource
+import types/cookie
+import types/url
+import utils/twtstr
+
+type
+  ForkCommand* = enum
+    FORK_BUFFER, FORK_LOADER, REMOVE_CHILD, LOAD_CONFIG
+
+  ForkServer* = ref object
+    process*: Pid
+    istream*: Stream
+    ostream*: Stream
+    estream*: PosixStream
+
+  ForkServerContext = object
+    istream: Stream
+    ostream: Stream
+    children: seq[(Pid, Pid)]
+
+proc newFileLoader*(forkserver: ForkServer, defaultHeaders: Headers,
+    filter = newURLFilter(default = true), cookiejar: CookieJar = nil,
+    proxy: URL = nil, acceptProxy = false): FileLoader =
+  forkserver.ostream.swrite(FORK_LOADER)
+  var defaultHeaders = defaultHeaders
+  let config = LoaderConfig(
+    defaultHeaders: defaultHeaders,
+    filter: filter,
+    cookiejar: cookiejar,
+    proxy: proxy,
+    acceptProxy: acceptProxy
+  )
+  forkserver.ostream.swrite(config)
+  forkserver.ostream.flush()
+  var process: Pid
+  forkserver.istream.sread(process)
+  return FileLoader(process: process)
+
+proc loadForkServerConfig*(forkserver: ForkServer, config: Config) =
+  forkserver.ostream.swrite(LOAD_CONFIG)
+  forkserver.ostream.swrite(config.getForkServerConfig())
+  forkserver.ostream.flush()
+
+proc removeChild*(forkserver: Forkserver, pid: Pid) =
+  forkserver.ostream.swrite(REMOVE_CHILD)
+  forkserver.ostream.swrite(pid)
+  forkserver.ostream.flush()
+
+proc trapSIGINT() =
+  # trap SIGINT, so e.g. an external editor receiving an interrupt in the
+  # same process group can't just kill the process
+  # Note that the main process normally quits on interrupt (thus terminating
+  # all child processes as well).
+  setControlCHook(proc() {.noconv.} = discard)
+
+proc forkLoader(ctx: var ForkServerContext, config: LoaderConfig): Pid =
+  var pipefd: array[2, cint]
+  if pipe(pipefd) == -1:
+    raise newException(Defect, "Failed to open pipe.")
+  let pid = fork()
+  if pid == 0:
+    # child process
+    trapSIGINT()
+    for i in 0 ..< ctx.children.len: ctx.children[i] = (Pid(0), Pid(0))
+    ctx.children.setLen(0)
+    zeroMem(addr ctx, sizeof(ctx))
+    discard close(pipefd[0]) # close read
+    try:
+      runFileLoader(pipefd[1], config)
+    except CatchableError:
+      let e = getCurrentException()
+      # taken from system/excpt.nim
+      let msg = e.getStackTrace() & "Error: unhandled exception: " & e.msg &
+        " [" & $e.name & "]\n"
+      stderr.write(msg)
+      quit(1)
+    doAssert false
+  let readfd = pipefd[0] # get read
+  discard close(pipefd[1]) # close write
+  var readf: File
+  if not open(readf, FileHandle(readfd), fmRead):
+    raise newException(Defect, "Failed to open output handle.")
+  assert readf.readChar() == char(0u8)
+  close(readf)
+  discard close(pipefd[0])
+  return pid
+
+proc forkBuffer(ctx: var ForkServerContext): Pid =
+  var source: BufferSource
+  var config: BufferConfig
+  var attrs: WindowAttributes
+  var mainproc: Pid
+  ctx.istream.sread(source)
+  ctx.istream.sread(config)
+  ctx.istream.sread(attrs)
+  ctx.istream.sread(mainproc)
+  let loaderPid = ctx.forkLoader(
+    LoaderConfig(
+      defaultHeaders: config.headers,
+      filter: config.filter,
+      cookiejar: config.cookiejar,
+      referrerpolicy: config.referrerpolicy,
+      #TODO these should be in a separate config I think
+      proxy: config.proxy,
+    )
+  )
+  var pipefd: array[2, cint]
+  if pipe(pipefd) == -1:
+    raise newException(Defect, "Failed to open pipe.")
+  let pid = fork()
+  if pid == -1:
+    raise newException(Defect, "Failed to fork process.")
+  if pid == 0:
+    # child process
+    trapSIGINT()
+    for i in 0 ..< ctx.children.len: ctx.children[i] = (Pid(0), Pid(0))
+    ctx.children.setLen(0)
+    zeroMem(addr ctx, sizeof(ctx))
+    discard close(pipefd[0]) # close read
+    let ssock = initServerSocket(buffered = false)
+    let ps = newPosixStream(pipefd[1])
+    ps.write(char(0))
+    ps.close()
+    discard close(stdin.getFileHandle())
+    discard close(stdout.getFileHandle())
+    let loader = FileLoader(process: loaderPid)
+    try:
+      launchBuffer(config, source, attrs, loader, ssock)
+    except CatchableError:
+      let e = getCurrentException()
+      # taken from system/excpt.nim
+      let msg = e.getStackTrace() & "Error: unhandled exception: " & e.msg &
+        " [" & $e.name & "]\n"
+      stderr.write(msg)
+      quit(1)
+    doAssert false
+  discard close(pipefd[1]) # close write
+  let ps = newPosixStream(pipefd[0])
+  assert ps.readChar() == char(0)
+  ps.close()
+  ctx.children.add((pid, loaderPid))
+  return pid
+
+proc runForkServer() =
+  var ctx: ForkServerContext
+  ctx.istream = newPosixStream(stdin.getFileHandle())
+  ctx.ostream = newPosixStream(stdout.getFileHandle())
+  while true:
+    try:
+      var cmd: ForkCommand
+      ctx.istream.sread(cmd)
+      case cmd
+      of REMOVE_CHILD:
+        var pid: Pid
+        ctx.istream.sread(pid)
+        for i in 0 .. ctx.children.high:
+          if ctx.children[i][0] == pid:
+            ctx.children.del(i)
+            break
+      of FORK_BUFFER:
+        ctx.ostream.swrite(ctx.forkBuffer())
+      of FORK_LOADER:
+        var config: LoaderConfig
+        ctx.istream.sread(config)
+        let pid = ctx.forkLoader(config)
+        ctx.ostream.swrite(pid)
+        ctx.children.add((pid, Pid(-1)))
+      of LOAD_CONFIG:
+        var config: ForkServerConfig
+        ctx.istream.sread(config)
+        set_cjk_ambiguous(config.ambiguous_double)
+        SocketDirectory = config.tmpdir
+      ctx.ostream.flush()
+    except EOFError:
+      # EOF
+      break
+  ctx.istream.close()
+  ctx.ostream.close()
+  # Clean up when the main process crashed.
+  for childpair in ctx.children:
+    let a = childpair[0]
+    let b = childpair[1]
+    discard kill(cint(a), cint(SIGTERM))
+    if b != -1:
+      discard kill(cint(b), cint(SIGTERM))
+  quit(0)
+
+proc newForkServer*(): ForkServer =
+  var pipefd_in: array[2, cint] # stdin in forkserver
+  var pipefd_out: array[2, cint] # stdout in forkserver
+  var pipefd_err: array[2, cint] # stderr in forkserver
+  if pipe(pipefd_in) == -1:
+    raise newException(Defect, "Failed to open input pipe.")
+  if pipe(pipefd_out) == -1:
+    raise newException(Defect, "Failed to open output pipe.")
+  if pipe(pipefd_err) == -1:
+    raise newException(Defect, "Failed to open error pipe.")
+  let pid = fork()
+  if pid == -1:
+    raise newException(Defect, "Failed to fork the fork process.")
+  elif pid == 0:
+    # child process
+    trapSIGINT()
+    discard close(pipefd_in[1]) # close write
+    discard close(pipefd_out[0]) # close read
+    discard close(pipefd_err[0]) # close read
+    let readfd = pipefd_in[0]
+    let writefd = pipefd_out[1]
+    let errfd = pipefd_err[1]
+    discard dup2(readfd, stdin.getFileHandle())
+    discard dup2(writefd, stdout.getFileHandle())
+    discard dup2(errfd, stderr.getFileHandle())
+    stderr.flushFile()
+    discard close(pipefd_in[0])
+    discard close(pipefd_out[1])
+    discard close(pipefd_err[1])
+    runForkServer()
+    doAssert false
+  else:
+    discard close(pipefd_in[0]) # close read
+    discard close(pipefd_out[1]) # close write
+    discard close(pipefd_err[1]) # close write
+    var writef, readf: File
+    if not open(writef, pipefd_in[1], fmWrite):
+      raise newException(Defect, "Failed to open output handle")
+    if not open(readf, pipefd_out[0], fmRead):
+      raise newException(Defect, "Failed to open input handle")
+    discard fcntl(pipefd_err[0], F_SETFL, fcntl(pipefd_err[0], F_GETFL, 0) or O_NONBLOCK)
+    return ForkServer(
+      ostream: newFileStream(writef),
+      istream: newFileStream(readf),
+      estream: newPosixStream(pipefd_err[0])
+    )