about summary refs log tree commit diff stats
diff options
context:
space:
mode:
authorbptato <nincsnevem662@gmail.com>2024-05-30 00:19:48 +0200
committerbptato <nincsnevem662@gmail.com>2024-06-20 17:50:22 +0200
commit60dc37269cd2dc8cdf23d9f77680f6af9490032f (patch)
tree9a72ba24daffa546f92704e7e06cf84fded2d89d
parenta146a22b11cea39bc691417d9d9a1292b7177552 (diff)
downloadchawan-60dc37269cd2dc8cdf23d9f77680f6af9490032f.tar.gz
img, loader: separate out png codec into cgi, misc improvements
* multi-processed and sandboxed PNG decoding & encoding (through local
  CGI)
* improved request body passing (including support for output id as
  response body)
* simplified & faster blob()/text() - now every request starts
  suspended, and OngoingData.buf has been replaced with loader's
  buffering capability
* image caching: we no longer pull bitmaps from the container after
  every single getLines call

Next steps: replace our bespoke PNG decoder with something more usable,
add other decoders, and make them stream.
-rw-r--r--Makefile8
-rw-r--r--adapter/img/png.nim (renamed from src/img/png.nim)38
-rw-r--r--res/urimethodmap1
-rw-r--r--src/html/chadombuilder.nim2
-rw-r--r--src/html/dom.nim168
-rw-r--r--src/img/bitmap.nim6
-rw-r--r--src/img/painter.nim6
-rw-r--r--src/io/bufreader.nim41
-rw-r--r--src/io/bufwriter.nim26
-rw-r--r--src/io/promise.nim10
-rw-r--r--src/io/urlfilter.nim2
-rw-r--r--src/js/console.nim2
-rw-r--r--src/js/jscolor.nim4
-rw-r--r--src/loader/cgi.nim53
-rw-r--r--src/loader/loader.nim160
-rw-r--r--src/loader/loaderhandle.nim41
-rw-r--r--src/loader/request.nim49
-rw-r--r--src/loader/response.nim124
-rw-r--r--src/local/client.nim4
-rw-r--r--src/local/container.nim8
-rw-r--r--src/local/pager.nim40
-rw-r--r--src/local/term.nim6
-rw-r--r--src/server/buffer.nim82
-rw-r--r--src/types/blob.nim11
-rw-r--r--src/types/cookie.nim5
-rw-r--r--src/types/urimethodmap.nim9
-rw-r--r--src/types/url.nim18
-rw-r--r--src/utils/twtstr.nim3
-rw-r--r--todo9
29 files changed, 645 insertions, 291 deletions
diff --git a/Makefile b/Makefile
index f4a816a2..fdb900bb 100644
--- a/Makefile
+++ b/Makefile
@@ -51,6 +51,7 @@ all: $(OUTDIR_BIN)/cha $(OUTDIR_BIN)/mancha $(OUTDIR_CGI_BIN)/http \
 	$(OUTDIR_CGI_BIN)/cha-finger $(OUTDIR_CGI_BIN)/about \
 	$(OUTDIR_CGI_BIN)/data $(OUTDIR_CGI_BIN)/file $(OUTDIR_CGI_BIN)/ftp \
 	$(OUTDIR_CGI_BIN)/man $(OUTDIR_CGI_BIN)/spartan \
+	$(OUTDIR_CGI_BIN)/png \
 	$(OUTDIR_LIBEXEC)/urldec $(OUTDIR_LIBEXEC)/urlenc \
 	$(OUTDIR_LIBEXEC)/md2html $(OUTDIR_LIBEXEC)/ansi2html
 
@@ -167,6 +168,12 @@ $(OUTDIR_CGI_BIN)/gopher: adapter/protocol/gopher.nim adapter/protocol/curlwrap.
 	$(NIMC) $(FLAGS) -d:curlLibName:$(CURLLIBNAME) --nimcache:"$(OBJDIR)/$(TARGET)/gopher" \
 		-o:"$(OUTDIR_CGI_BIN)/gopher" adapter/protocol/gopher.nim
 
+$(OUTDIR_CGI_BIN)/png: adapter/img/png.nim src/utils/sandbox.nim
+	@mkdir -p "$(OUTDIR_CGI_BIN)"
+	$(NIMC) $(FLAGS) --nimcache:"$(OBJDIR)/$(TARGET)/png" \
+                -d:disableSandbox=$(DANGER_DISABLE_SANDBOX) \
+                -o:"$(OUTDIR_CGI_BIN)/png" adapter/img/png.nim
+
 $(OUTDIR_LIBEXEC)/urldec: adapter/tools/urldec.nim src/utils/twtstr.nim
 	@mkdir -p "$(OUTDIR_LIBEXEC)"
 	$(NIMC) $(FLAGS) --nimcache:"$(OBJDIR)/$(TARGET)/urldec" \
@@ -221,6 +228,7 @@ install:
 	install -m755 "$(OUTDIR_CGI_BIN)/cha-finger" $(LIBEXECDIR_CHAWAN)/cgi-bin
 	install -m755 "$(OUTDIR_CGI_BIN)/man" $(LIBEXECDIR_CHAWAN)/cgi-bin
 	install -m755 "$(OUTDIR_CGI_BIN)/spartan" $(LIBEXECDIR_CHAWAN)/cgi-bin
+	install -m755 "$(OUTDIR_CGI_BIN)/png" $(LIBEXECDIR_CHAWAN)/cgi-bin
 	install -m755 "$(OUTDIR_LIBEXEC)/urldec" $(LIBEXECDIR_CHAWAN)/urldec
 	install -m755 "$(OUTDIR_LIBEXEC)/urlenc" $(LIBEXECDIR_CHAWAN)/urlenc
 	mkdir -p "$(DESTDIR)$(MANPREFIX1)"
diff --git a/src/img/png.nim b/adapter/img/png.nim
index 667b589f..b9d56cf0 100644
--- a/src/img/png.nim
+++ b/adapter/img/png.nim
@@ -1,7 +1,13 @@
+import std/options
+import std/os
+import std/strutils
+
 import bindings/zlib
 import img/bitmap
 import types/color
 import utils/endians
+import utils/sandbox
+import utils/twtstr
 
 type PNGWriter = object
   buf: pointer
@@ -504,3 +510,35 @@ proc fromPNG*(iq: openArray[uint8]): Bitmap =
   if not reader.isend:
     reader.err "IEND not found"
   return reader.bmp
+
+proc main() =
+  enterNetworkSandbox()
+  case getEnv("MAPPED_URI_PATH")
+  of "decode":
+    let s = stdin.readAll()
+    let bmp = fromPNG(s.toOpenArrayByte(0, s.high))
+    if bmp != nil:
+      stdout.write("X-Image-Dimensions: " & $bmp.width & "x" & $bmp.height &
+        "\n\n")
+      discard stdout.writeBuffer(addr bmp.px[0], bmp.px.len * sizeof(bmp.px[0]))
+  of "encode":
+    let headers = getEnv("REQUEST_HEADERS")
+    let bmp = Bitmap()
+    for hdr in headers.split('\n'):
+      if hdr.until(':') == "X-Image-Dimensions":
+        let s = hdr.after(':').strip().split('x')
+        #TODO error handling
+        bmp.width = parseUInt64(s[0], allowSign = false).get
+        bmp.height = parseUInt64(s[1], allowSign = false).get
+    let L = bmp.width * bmp.height
+    bmp.px = cast[seq[ARGBColor]](newSeqUninitialized[uint32](L))
+    doAssert stdin.readBuffer(addr bmp.px[0], L * 4) == int(L * 4)
+    var outlen: int
+    stdout.write("X-Image-Dimensions: " & $bmp.width & "x" & $bmp.height &
+      "\n\n")
+    let p = bmp.toPNG(outlen)
+    discard stdout.writeBuffer(p, outlen)
+  else:
+    stdout.write("Cha-Control: ConnectionError 4 invalid command")
+
+main()
diff --git a/res/urimethodmap b/res/urimethodmap
index e711bed6..d11dab86 100644
--- a/res/urimethodmap
+++ b/res/urimethodmap
@@ -16,3 +16,4 @@ spartan:	cgi-bin:spartan
 man:		cgi-bin:man
 man-k:		cgi-bin:man
 man-l:		cgi-bin:man
+img-codec+png:	cgi-bin:png
diff --git a/src/html/chadombuilder.nim b/src/html/chadombuilder.nim
index d1450f64..446972cf 100644
--- a/src/html/chadombuilder.nim
+++ b/src/html/chadombuilder.nim
@@ -374,7 +374,7 @@ proc parseFromString(ctx: JSContext; parser: DOMParser; str, t: string):
   of "text/xml", "application/xml", "application/xhtml+xml", "image/svg+xml":
     return err(newInternalError("XML parsing is not supported yet"))
   else:
-    return err(newTypeError("Invalid mime type"))
+    return errTypeError("Invalid mime type")
 
 proc addHTMLModule*(ctx: JSContext) =
   ctx.registerType(DOMParser)
diff --git a/src/html/dom.nim b/src/html/dom.nim
index 0a11791f..265810ce 100644
--- a/src/html/dom.nim
+++ b/src/html/dom.nim
@@ -17,12 +17,12 @@ import html/script
 import img/bitmap
 import img/painter
 import img/path
-import img/png
 import io/dynstream
 import io/promise
 import js/console
 import js/domexception
 import js/timeout
+import loader/headers
 import loader/loader
 import loader/request
 import monoucha/fromjs
@@ -89,6 +89,8 @@ type
     factory*: CAtomFactory
     loadingResourcePromises*: seq[EmptyPromise]
     images*: bool
+    # ID of the next image
+    imageId: int
 
   # Navigator stuff
   Navigator* = object
@@ -433,6 +435,9 @@ jsDestructor(CSSStyleDeclaration)
 
 proc parseColor(element: Element; s: string): ARGBColor
 
+func console(window: Window): Console =
+  return window.internalConsole
+
 proc resetTransform(state: var DrawingState) =
   state.transformMatrix = newIdentityMatrix(3)
 
@@ -606,12 +611,41 @@ proc clip(ctx: CanvasRenderingContext2D; fillRule = cfrNonZero) {.jsfunc.} =
 # CanvasUserInterface
 
 # CanvasText
+const unifont = readFile"res/unifont_jp-15.0.05.png"
+proc loadUnifont(window: Window) =
+  #TODO this is very wrong, we should move the unifont file in a CGI script or
+  # something
+  if unifontBitmap != nil:
+    return
+  let request = newRequest(
+    newURL("img-codec+png:decode").get,
+    httpMethod = hmPost,
+    body = RequestBody(t: rbtString, s: unifont)
+  )
+  let response = window.loader.doRequest(request)
+  assert response.res == 0
+  let dims = response.headers.table["X-Image-Dimensions"][0]
+  let width = parseUInt64(dims.until('x'), allowSign = false).get
+  let height = parseUInt64(dims.after('x'), allowSign = false).get
+  let len = int(width) * int(height)
+  let bitmap = ImageBitmap(
+    px: cast[seq[ARGBColor]](newSeqUninitialized[uint32](len)),
+    width: width,
+    height: height
+  )
+  window.loader.resume(response.outputId)
+  response.body.recvDataLoop(addr bitmap.px[0], len * 4)
+  response.body.sclose()
+  unifontBitmap = bitmap
+
 #TODO maxwidth
 proc fillText(ctx: CanvasRenderingContext2D; text: string; x, y: float64)
     {.jsfunc.} =
   for v in [x, y]:
     if classify(v) in {fcInf, fcNegInf, fcNan}:
       return
+  #TODO should not be loaded here...
+  ctx.canvas.document_internal.window.loadUnifont()
   let vec = ctx.transform(Vector2D(x: x, y: y))
   ctx.bitmap.fillText(text, vec.x, vec.y, ctx.state.fillStyle,
     ctx.state.textAlign)
@@ -622,6 +656,8 @@ proc strokeText(ctx: CanvasRenderingContext2D; text: string; x, y: float64)
   for v in [x, y]:
     if classify(v) in {fcInf, fcNegInf, fcNan}:
       return
+  #TODO should not be loaded here...
+  ctx.canvas.document_internal.window.loadUnifont()
   let vec = ctx.transform(Vector2D(x: x, y: y))
   ctx.bitmap.strokeText(text, vec.x, vec.y, ctx.state.strokeStyle,
     ctx.state.textAlign)
@@ -1293,7 +1329,7 @@ func supports(tokenList: DOMTokenList; token: string):
     if it[0] == localName:
       let lowercase = token.toLowerAscii()
       return ok(lowercase in it[1])
-  return err(newTypeError("No supported tokens defined for attribute"))
+  return errTypeError("No supported tokens defined for attribute")
 
 func value(tokenList: DOMTokenList): string {.jsfget.} =
   return $tokenList
@@ -1486,7 +1522,7 @@ func url(location: Location): URL =
 proc setLocation*(document: Document; s: string): Err[JSError]
     {.jsfset: "location".} =
   if document.location == nil:
-    return err(newTypeError("document.location is not an object"))
+    return errTypeError("document.location is not an object")
   let url = parseURL(s)
   if url.isNone:
     return errDOMException("Invalid URL", "SyntaxError")
@@ -2823,9 +2859,6 @@ proc style*(element: Element): CSSStyleDeclaration {.jsfget.} =
 var appliesFwdDecl*: proc(mqlist: MediaQueryList; window: Window): bool
   {.nimcall, noSideEffect.}
 
-func console(window: Window): Console =
-  return window.internalConsole
-
 # see https://html.spec.whatwg.org/multipage/links.html#link-type-stylesheet
 #TODO make this somewhat compliant with ^this
 proc loadResource(window: Window; link: HTMLLinkElement) =
@@ -2862,6 +2895,10 @@ proc loadResource(window: Window; link: HTMLLinkElement) =
     )
     window.loadingResourcePromises.add(p)
 
+proc getImageId(window: Window): int =
+  result = window.imageId
+  inc window.imageId
+
 proc loadResource(window: Window; image: HTMLImageElement) =
   if not window.images or image.fetchStarted:
     return
@@ -2873,19 +2910,46 @@ proc loadResource(window: Window; image: HTMLImageElement) =
   if url.isSome:
     let url = url.get
     let p = window.loader.fetch(newRequest(url))
-      .then(proc(res: JSResult[Response]): Promise[JSResult[Blob]] =
+      .then(proc(res: JSResult[Response]): Promise[JSResult[Response]] =
         if res.isNone:
           return
-        let res = res.get
-        if res.getContentType() == "image/png":
-          return res.blob()
-      ).then(proc(pngData: JSResult[Blob]) =
-        if pngData.isNone:
+        let response = res.get
+        let contentType = response.getContentType()
+        if contentType.until('/') != "image":
+          return
+        let request = newRequest(
+          newURL("img-codec+" & contentType.after('/') & ":decode").get,
+          httpMethod = hmPost,
+          body = RequestBody(t: rbtOutput, outputId: response.outputId)
+        )
+        let r = window.loader.fetch(request)
+        window.loader.resume(response.outputId)
+        response.unregisterFun()
+        response.body.sclose()
+        return r
+      ).then(proc(res: JSResult[Response]): EmptyPromise =
+        if res.isNone:
+          return
+        let response = res.get
+        # we can close immediately; loader will not clean this output up until
+        # the `resume' command in pager.
+        response.unregisterFun()
+        response.body.sclose()
+        if "X-Image-Dimensions" notin response.headers.table:
+          window.console.error("X-Image-Dimensions missing")
+          return
+        let dims = response.headers.table["X-Image-Dimensions"][0]
+        let width = parseUInt64(dims.until('x'), allowSign = false)
+        let height = parseUInt64(dims.after('x'), allowSign = false)
+        if width.isNone or height.isNone:
+          window.console.error("wrong X-Image-Dimensions")
           return
-        let pngData = pngData.get
-        let buffer = cast[ptr UncheckedArray[uint8]](pngData.buffer)
-        let high = int(pngData.size) - 1
-        image.bitmap = fromPNG(toOpenArray(buffer, 0, high))
+        image.bitmap = NetworkBitmap(
+          width: width.get,
+          height: height.get,
+          outputId: response.outputId,
+          imageId: window.getImageId()
+        )
       )
     window.loadingResourcePromises.add(p)
 
@@ -2897,7 +2961,7 @@ proc reflectEvent(element: Element; target: EventTarget; name: StaticAtom;
   let fun = ctx.newFunction(["event"], value)
   assert ctx != nil
   if JS_IsException(fun):
-    document.window.console.log("Exception in body content attribute of",
+    document.window.console.error("Exception in body content attribute of",
       urls, ctx.getExceptionMsg())
   else:
     let jsTarget = ctx.toJS(target)
@@ -3587,9 +3651,11 @@ proc fetchClassicScript(element: HTMLScriptElement; url: URL;
   if response.res != 0:
     element.onComplete(ScriptResult(t: RESULT_NULL))
     return
+  window.loader.resume(response.outputId)
   let s = response.body.recvAll()
   let cs = if cs == CHARSET_UNKNOWN: CHARSET_UTF_8 else: cs
   let source = s.decodeAll(cs)
+  response.body.sclose()
   let script = window.jsctx.createClassicScript(source, url, options, false)
   element.onComplete(ScriptResult(t: RESULT_SCRIPT, script: script))
 
@@ -3685,11 +3751,12 @@ proc execute*(element: HTMLScriptElement) =
       let urls = script.baseURL.serialize(excludepassword = true)
       let ctx = window.jsctx
       if JS_IsException(script.record):
-        window.console.log("Exception in document", urls, ctx.getExceptionMsg())
+        window.console.error("Exception in document", urls,
+          ctx.getExceptionMsg())
       else:
         let ret = ctx.evalFunction(script.record)
         if JS_IsException(ret):
-          window.console.log("Exception in document", urls,
+          window.console.error("Exception in document", urls,
             ctx.getExceptionMsg())
         JS_FreeValue(ctx, ret)
     document.currentScript = oldCurrentScript
@@ -4174,17 +4241,58 @@ proc getContext*(jctx: JSContext; this: HTMLCanvasElement; contextId: string;
 
 #TODO quality should be `any'
 proc toBlob(ctx: JSContext; this: HTMLCanvasElement; callback: JSValue;
-    s = "image/png", quality: float64 = 1): JSValue {.jsfunc.} =
-  var outlen: int
-  let buf = this.bitmap.toPNG(outlen)
-  let blob = newBlob(buf, outlen, "image/png", proc() = dealloc(buf))
-  var jsBlob = toJS(ctx, blob)
-  let res = JS_Call(ctx, callback, JS_UNDEFINED, 1, jsBlob.toJSValueArray())
-  JS_FreeValue(ctx, jsBlob)
-  # Hack. TODO: implement JSValue to callback
-  if res == JS_EXCEPTION:
-    return JS_EXCEPTION
-  JS_FreeValue(ctx, res)
+    contentType = "image/png"; quality: float64 = 1): JSValue {.jsfunc.} =
+  if not contentType.startsWith("image/"):
+    return
+  #TODO this is dumb (and slow)
+  var s = newString(this.bitmap.px.len * 4)
+  copyMem(addr s[0], addr this.bitmap.px[0], this.bitmap.px.len * 4)
+  let request = newRequest(
+    newURL("img-codec+" & contentType.after('/') & ":encode").get,
+    httpMethod = hmPost,
+    headers = newHeaders({
+      "X-Image-Dimensions": $this.bitmap.width & 'x' & $this.bitmap.height
+    }),
+    body = RequestBody(t: rbtString, s: s)
+  )
+  # callback will go out of scope when we return, so capture a new reference.
+  let callback = JS_DupValue(ctx, callback)
+  let window = this.document.window
+  window.loader.fetch(request).then(proc(res: JSResult[Response]):
+      EmptyPromise =
+    if res.isNone:
+      if contentType != "image/png":
+        # redo as PNG.
+        # Note: this sounds dumb, and is dumb, but also standard mandated so
+        # whatever.
+        discard ctx.toBlob(this, callback, "image/png", quality)
+      else: # the png decoder doesn't work...
+        window.console.error("missing/broken PNG decoder")
+      JS_FreeValue(ctx, callback)
+      return
+    let response = res.get
+    if "X-Image-Dimensions" notin response.headers.table:
+      window.console.error("X-Image-Dimensions missing")
+      JS_FreeValue(ctx, callback)
+      return
+    let dims = response.headers.table["X-Image-Dimensions"][0]
+    let width = parseUInt64(dims.until('x'), allowSign = false)
+    let height = parseUInt64(dims.after('x'), allowSign = false)
+    if width.isNone or height.isNone:
+      window.console.error("wrong X-Image-Dimensions")
+      JS_FreeValue(ctx, callback)
+      return
+    response.blob().then(proc(blob: JSResult[Blob]) =
+      let jsBlob = toJS(ctx, blob)
+      let res = JS_Call(ctx, callback, JS_UNDEFINED, 1, jsBlob.toJSValueArray())
+      if JS_IsException(res):
+        window.console.error("Exception in canvas toBlob:",
+          ctx.getExceptionMsg())
+      else:
+        JS_FreeValue(ctx, res)
+      JS_FreeValue(ctx, callback)
+    )
+  )
   return JS_UNDEFINED
 
 # Forward declaration hack
diff --git a/src/img/bitmap.nim b/src/img/bitmap.nim
index 9f9b3401..a186b47f 100644
--- a/src/img/bitmap.nim
+++ b/src/img/bitmap.nim
@@ -8,7 +8,11 @@ type
 
   ImageBitmap* = ref object of Bitmap
 
-proc newBitmap*(width, height: uint64): Bitmap =
+  NetworkBitmap* = ref object of Bitmap
+    outputId*: int
+    imageId*: int
+
+proc newBitmap*(width, height: uint64): ImageBitmap =
   return ImageBitmap(
     px: newSeq[ARGBColor](width * height),
     width: width,
diff --git a/src/img/painter.nim b/src/img/painter.nim
index a9848d7d..d02f3602 100644
--- a/src/img/painter.nim
+++ b/src/img/painter.nim
@@ -4,7 +4,6 @@ import std/unicode
 import css/cssvalues
 import img/bitmap
 import img/path
-import img/png
 import types/color
 import types/line
 import types/vector
@@ -139,15 +138,12 @@ proc clearRect*(bmp: Bitmap; x0, x1, y0, y1: uint64) =
 proc clear*(bmp: Bitmap) =
   bmp.clearRect(0, bmp.width, 0, bmp.height)
 
-const unifont = readFile"res/unifont_jp-15.0.05.png"
-var unifontBitmap: Bitmap
+var unifontBitmap*: Bitmap = nil
 var glyphCache: seq[tuple[u: uint32, bmp: Bitmap]]
 var glyphCacheI = 0
 proc getCharBmp(u: uint32): Bitmap =
   # We only have the BMP.
   let u = if u <= 0xFFFF: u else: 0xFFFD
-  if unifontBitmap == nil:
-    unifontBitmap = fromPNG(toOpenArrayByte(unifont, 0, unifont.high))
   for (cu, bmp) in glyphCache:
     if cu == u:
       return bmp
diff --git a/src/io/bufreader.nim b/src/io/bufreader.nim
index 90940424..bd2486be 100644
--- a/src/io/bufreader.nim
+++ b/src/io/bufreader.nim
@@ -2,8 +2,10 @@ import std/options
 import std/sets
 import std/tables
 
+import img/bitmap
 import io/dynstream
 import io/socketstream
+import loader/request
 import types/blob
 import types/color
 import types/formdata
@@ -32,6 +34,8 @@ proc sread*(reader: var BufferedReader; blob: var Blob)
 proc sread*[T](reader: var BufferedReader; o: var Option[T])
 proc sread*[T, E](reader: var BufferedReader; o: var Result[T, E])
 proc sread*(reader: var BufferedReader; c: var ARGBColor) {.inline.}
+proc sread*(reader: var BufferedReader; o: var RequestBody)
+proc sread*(reader: var BufferedReader; bmp: var Bitmap)
 
 proc initReader*(stream: DynStream; len, auxLen: int): BufferedReader =
   assert len != 0
@@ -169,7 +173,7 @@ proc sread*(reader: var BufferedReader; blob: var Blob) =
     let buffer = alloc(blob.size)
     reader.readData(blob.buffer, int(blob.size))
     blob.buffer = buffer
-    blob.deallocFun = proc() = dealloc(buffer)
+    blob.deallocFun = deallocBlob
 
 proc sread*[T](reader: var BufferedReader; o: var Option[T]) =
   var x: bool
@@ -201,3 +205,38 @@ proc sread*[T, E](reader: var BufferedReader; o: var Result[T, E]) =
 
 proc sread*(reader: var BufferedReader; c: var ARGBColor) =
   reader.sread(uint32(c))
+
+proc sread*(reader: var BufferedReader; o: var RequestBody) =
+  var t: RequestBodyType
+  reader.sread(t)
+  o = RequestBody(t: t)
+  case t
+  of rbtNone: discard
+  of rbtString: reader.sread(o.s)
+  of rbtMultipart: reader.sread(o.multipart)
+  of rbtOutput: reader.sread(o.outputId)
+
+proc sread*(reader: var BufferedReader; bmp: var Bitmap) =
+  var isImageBitmap: bool
+  var width: uint64
+  var height: uint64
+  reader.sread(isImageBitmap)
+  reader.sread(width)
+  reader.sread(height)
+  if isImageBitmap:
+    bmp = ImageBitmap(
+      width: width,
+      height: height
+    )
+    reader.sread(bmp.px)
+  else:
+    var outputId: int
+    var imageId: int
+    reader.sread(outputId)
+    reader.sread(imageId)
+    bmp = NetworkBitmap(
+      width: width,
+      height: height,
+      outputId: outputId,
+      imageId: imageId
+    )
diff --git a/src/io/bufwriter.nim b/src/io/bufwriter.nim
index fd3c12a8..56e30f5b 100644
--- a/src/io/bufwriter.nim
+++ b/src/io/bufwriter.nim
@@ -5,8 +5,10 @@ import std/options
 import std/sets
 import std/tables
 
+import img/bitmap
 import io/dynstream
 import io/socketstream
+import loader/request
 import types/blob
 import types/color
 import types/formdata
@@ -34,7 +36,7 @@ proc swrite*(writer: var BufferedWriter; b: bool)
 proc swrite*(writer: var BufferedWriter; url: URL)
 proc swrite*(writer: var BufferedWriter; tup: tuple)
 proc swrite*[I, T](writer: var BufferedWriter; a: array[I, T])
-proc swrite*(writer: var BufferedWriter; s: seq)
+proc swrite*[T](writer: var BufferedWriter; s: openArray[T])
 proc swrite*[U, V](writer: var BufferedWriter; t: Table[U, V])
 proc swrite*(writer: var BufferedWriter; obj: object)
 proc swrite*(writer: var BufferedWriter; obj: ref object)
@@ -43,6 +45,8 @@ proc swrite*(writer: var BufferedWriter; blob: Blob)
 proc swrite*[T](writer: var BufferedWriter; o: Option[T])
 proc swrite*[T, E](writer: var BufferedWriter; o: Result[T, E])
 proc swrite*(writer: var BufferedWriter; c: ARGBColor) {.inline.}
+proc swrite*(writer: var BufferedWriter; o: RequestBody)
+proc swrite*(writer: var BufferedWriter; bmp: Bitmap)
 
 const InitLen = sizeof(int) * 2
 const SizeInit = max(64, InitLen)
@@ -130,7 +134,7 @@ proc swrite*[I, T](writer: var BufferedWriter; a: array[I, T]) =
   for x in a:
     writer.swrite(x)
 
-proc swrite*(writer: var BufferedWriter; s: seq) =
+proc swrite*[T](writer: var BufferedWriter; s: openArray[T]) =
   writer.swrite(s.len)
   for x in s:
     writer.swrite(x)
@@ -188,3 +192,21 @@ proc swrite*[T, E](writer: var BufferedWriter; o: Result[T, E]) =
 
 proc swrite*(writer: var BufferedWriter; c: ARGBColor) =
   writer.swrite(uint32(c))
+
+proc swrite*(writer: var BufferedWriter; o: RequestBody) =
+  writer.swrite(o.t)
+  case o.t
+  of rbtNone: discard
+  of rbtString: writer.swrite(o.s)
+  of rbtMultipart: writer.swrite(o.multipart)
+  of rbtOutput: writer.swrite(o.outputId)
+
+proc swrite*(writer: var BufferedWriter; bmp: Bitmap) =
+  writer.swrite(bmp of ImageBitmap)
+  writer.swrite(bmp.width)
+  writer.swrite(bmp.height)
+  if bmp of ImageBitmap:
+    writer.swrite(bmp.px)
+  else:
+    writer.swrite(NetworkBitmap(bmp).outputId)
+    writer.swrite(NetworkBitmap(bmp).imageId)
diff --git a/src/io/promise.nim b/src/io/promise.nim
index 183091cb..a1a5cfc9 100644
--- a/src/io/promise.nim
+++ b/src/io/promise.nim
@@ -132,6 +132,14 @@ proc then*[T](promise: Promise[T]; cb: (proc(x: T): EmptyPromise)): EmptyPromise
       next.resolve())
   return next
 
+proc then*[T](promise: EmptyPromise; cb: (proc(): T)): Promise[T]
+    {.discardable.} =
+  let next = Promise[T]()
+  promise.then(proc() =
+    next.res = cb()
+    next.resolve())
+  return next
+
 proc then*[T, U](promise: Promise[T]; cb: (proc(x: T): U)): Promise[U]
     {.discardable.} =
   let next = Promise[U]()
@@ -195,7 +203,7 @@ proc promiseThenCallback(ctx: JSContext; this_val: JSValue; argc: cint;
 
 proc fromJSEmptyPromise*(ctx: JSContext; val: JSValue): JSResult[EmptyPromise] =
   if not JS_IsObject(val):
-    return err(newTypeError("Value is not an object"))
+    return errTypeError("Value is not an object")
   var p = EmptyPromise()
   GC_ref(p)
   let tmp = JS_NewObject(ctx)
diff --git a/src/io/urlfilter.nim b/src/io/urlfilter.nim
index e86e0e6b..20983498 100644
--- a/src/io/urlfilter.nim
+++ b/src/io/urlfilter.nim
@@ -6,7 +6,7 @@ import types/url
 #TODO add denyhost/s for blocklists
 type URLFilter* = object
   scheme: Option[string]
-  allowschemes: seq[string]
+  allowschemes*: seq[string]
   allowhost*: Option[string]
   allowhosts: seq[Regex]
   default: bool
diff --git a/src/js/console.nim b/src/js/console.nim
index 0de66162..2ea0fa91 100644
--- a/src/js/console.nim
+++ b/src/js/console.nim
@@ -35,7 +35,7 @@ proc clear(console: Console) {.jsfunc.} =
 proc debug(console: Console; ss: varargs[string]) {.jsfunc.} =
   console.log(ss)
 
-proc error(console: Console; ss: varargs[string]) {.jsfunc.} =
+proc error*(console: Console; ss: varargs[string]) {.jsfunc.} =
   console.log(ss)
 
 proc info(console: Console; ss: varargs[string]) {.jsfunc.} =
diff --git a/src/js/jscolor.nim b/src/js/jscolor.nim
index aa6fd8ed..8491e778 100644
--- a/src/js/jscolor.nim
+++ b/src/js/jscolor.nim
@@ -12,10 +12,10 @@ import utils/twtstr
 
 func parseLegacyColor*(s: string): JSResult[RGBColor] =
   if s == "":
-    return err(newTypeError("Color value must not be the empty string"))
+    return errTypeError("Color value must not be the empty string")
   let s = s.strip(chars = AsciiWhitespace).toLowerAscii()
   if s == "transparent":
-    return err(newTypeError("Color must not be transparent"))
+    return errTypeError("Color must not be transparent")
   return ok(parseLegacyColor0(s))
 
 proc toJS*(ctx: JSContext; rgb: RGBColor): JSValue =
diff --git a/src/loader/cgi.nim b/src/loader/cgi.nim
index f3c5b1e3..ee3d3160 100644
--- a/src/loader/cgi.nim
+++ b/src/loader/cgi.nim
@@ -42,8 +42,8 @@ proc setupEnv(cmd, scriptName, pathInfo, requestURI, myDir: string;
   if url.query.isSome:
     putEnv("QUERY_STRING", url.query.get)
   if request.httpMethod == hmPost:
-    if request.multipart.isSome:
-      putEnv("CONTENT_TYPE", request.multipart.get.getContentType())
+    if request.body.t == rbtMultipart:
+      putEnv("CONTENT_TYPE", request.body.multipart.getContentType())
     else:
       putEnv("CONTENT_TYPE", request.headers.getOrDefault("Content-Type", ""))
     putEnv("CONTENT_LENGTH", $contentLen)
@@ -126,7 +126,7 @@ proc handleLine(handle: LoaderHandle; line: string; headers: Headers) =
   headers.add(k, v)
 
 proc loadCGI*(handle: LoaderHandle; request: Request; cgiDir: seq[string];
-    prevURL: URL; insecureSSLNoVerify: bool) =
+    prevURL: URL; insecureSSLNoVerify: bool; ostream: var PosixStream) =
   if cgiDir.len == 0:
     handle.sendResult(ERROR_NO_CGI_DIR)
     return
@@ -181,16 +181,11 @@ proc loadCGI*(handle: LoaderHandle; request: Request; cgiDir: seq[string];
     return
   # Pipe the request body as stdin for POST.
   var pipefd_read: array[0..1, cint] # parent -> child
-  let needsPipe = request.body.isSome or request.multipart.isSome
-  if needsPipe:
+  if request.body.t != rbtNone:
     if pipe(pipefd_read) == -1:
       handle.sendResult(ERROR_FAIL_SETUP_CGI)
       return
-  var contentLen = 0
-  if request.body.isSome:
-    contentLen = request.body.get.len
-  elif request.multipart.isSome:
-    contentLen = request.multipart.get.calcLength()
+  let contentLen = request.body.contentLength()
   stdout.flushFile()
   stderr.flushFile()
   let pid = fork()
@@ -199,7 +194,7 @@ proc loadCGI*(handle: LoaderHandle; request: Request; cgiDir: seq[string];
   elif pid == 0:
     discard close(pipefd[0]) # close read
     discard dup2(pipefd[1], 1) # dup stdout
-    if needsPipe:
+    if request.body.t != rbtNone:
       discard close(pipefd_read[1]) # close write
       if pipefd_read[0] != 0:
         discard dup2(pipefd_read[0], 0) # dup stdin
@@ -220,38 +215,32 @@ proc loadCGI*(handle: LoaderHandle; request: Request; cgiDir: seq[string];
     quit(1)
   else:
     discard close(pipefd[1]) # close write
-    if needsPipe:
+    if request.body.t != rbtNone:
       discard close(pipefd_read[0]) # close read
       let ps = newPosixStream(pipefd_read[1])
-      if request.body.isSome:
-        ps.write(request.body.get)
-      elif request.multipart.isSome:
-        let multipart = request.multipart.get
-        for entry in multipart.entries:
-          ps.writeEntry(entry, multipart.boundary)
-        ps.writeEnd(multipart.boundary)
-      ps.sclose()
+      case request.body.t
+      of rbtString:
+        ps.write(request.body.s)
+        ps.sclose()
+      of rbtMultipart:
+        let boundary = request.body.multipart.boundary
+        for entry in request.body.multipart.entries:
+          ps.writeEntry(entry, boundary)
+        ps.writeEnd(boundary)
+        ps.sclose()
+      of rbtOutput:
+        ostream = ps
+      of rbtNone: discard
     handle.parser = HeaderParser(headers: newHeaders())
     handle.istream = newPosixStream(pipefd[0])
 
-proc killHandle(handle: LoaderHandle) =
-  if handle.parser.state != hpsBeforeLines:
-    # not an ideal solution, but better than silently eating malformed
-    # headers
-    handle.output.ostream.setBlocking(true)
-    handle.sendStatus(500)
-    handle.sendHeaders(newHeaders())
-    const msg = "Error: malformed header in CGI script"
-    discard handle.output.ostream.sendData(msg)
-  handle.parser = nil
-
 proc parseHeaders0(handle: LoaderHandle; buffer: LoaderBuffer): int =
   let parser = handle.parser
   var s = parser.lineBuffer
   let L = if buffer == nil: 1 else: buffer.len
   for i in 0 ..< L:
     template die =
-      handle.killHandle()
+      handle.parser = nil
       return -1
     let c = if buffer != nil:
       char(buffer.page[i])
diff --git a/src/loader/loader.nim b/src/loader/loader.nim
index c84f247a..89d97cde 100644
--- a/src/loader/loader.nim
+++ b/src/loader/loader.nim
@@ -8,11 +8,17 @@
 #  S: output ID
 #  S: status code
 #  S: headers
+#  C: resume
 #  S: response body
 # else:
 #  S: error message
 #
 # The body is passed to the stream as-is, so effectively nothing can follow it.
+#
+# Note: if the consumer closes the request's body after headers have been
+# passed, it will *not* be cleaned up until a `resume' command is
+# received. (This allows for passing outputIds to the pager for later
+# addCacheFile commands there.)
 
 import std/deques
 import std/nativesockets
@@ -57,7 +63,7 @@ type
     process*: int
     clientPid*: int
     connecting*: Table[int, ConnectData]
-    ongoing*: Table[int, OngoingData]
+    ongoing*: Table[int, Response]
     unregistered*: seq[int]
     registerFun*: proc(fd: int)
     unregisterFun*: proc(fd: int)
@@ -71,11 +77,6 @@ type
     stream*: SocketStream
     request: Request
 
-  OngoingData* = object
-    buf: string
-    response*: Response
-    bodyRead: Promise[string]
-
   LoaderCommand = enum
     lcAddCacheFile
     lcAddClient
@@ -155,10 +156,12 @@ proc rejectHandle(handle: LoaderHandle; code: ConnectErrorCode; msg = "") =
   handle.sendResult(code, msg)
   handle.close()
 
-func findOutput(ctx: LoaderContext; id: int): OutputHandle =
+func findOutput(ctx: LoaderContext; id: int; client: ClientData): OutputHandle =
   assert id != -1
   for it in ctx.outputMap.values:
     if it.outputId == id:
+      # verify that it's safe to access this handle.
+      doAssert ctx.isPrivileged(client) or client.pid == it.ownerPid
       return it
   return nil
 
@@ -211,11 +214,8 @@ proc getOutputId(ctx: LoaderContext): int =
   result = ctx.outputNum
   inc ctx.outputNum
 
-proc redirectToFile(ctx: LoaderContext; output: OutputHandle;
-    targetPath: string): bool =
-  let ps = newPosixStream(targetPath, O_CREAT or O_WRONLY, 0o600)
-  if ps == nil:
-    return false
+proc redirectToStream(ctx: LoaderContext; output: OutputHandle;
+    ps: PosixStream): bool =
   if output.currentBuffer != nil:
     let n = ps.sendData(output.currentBuffer, output.currentBufferIdx)
     if unlikely(n < output.currentBuffer.len - output.currentBufferIdx):
@@ -226,7 +226,9 @@ proc redirectToFile(ctx: LoaderContext; output: OutputHandle;
     if unlikely(n < buffer.len):
       ps.sclose()
       return false
-  if output.parent != nil:
+  if output.istreamAtEnd:
+    ps.sclose()
+  elif output.parent != nil:
     output.parent.outputs.add(OutputHandle(
       parent: output.parent,
       ostream: ps,
@@ -235,6 +237,13 @@ proc redirectToFile(ctx: LoaderContext; output: OutputHandle;
     ))
   return true
 
+proc redirectToFile(ctx: LoaderContext; output: OutputHandle;
+    targetPath: string): bool =
+  let ps = newPosixStream(targetPath, O_CREAT or O_WRONLY, 0o600)
+  if ps == nil:
+    return false
+  return ctx.redirectToStream(output, ps)
+
 type AddCacheFileResult = tuple[outputId: int; cacheFile: string]
 
 proc addCacheFile(ctx: LoaderContext; client: ClientData; output: OutputHandle):
@@ -335,8 +344,7 @@ proc loadStreamRegular(ctx: LoaderContext; handle, cachedHandle: LoaderHandle) =
       output.ostream.sclose()
       output.ostream = nil
   handle.outputs.setLen(0)
-  handle.istream.sclose()
-  handle.istream = nil
+  handle.iclose()
 
 proc loadStream(ctx: LoaderContext; handle: LoaderHandle; request: Request) =
   ctx.passedFdMap.withValue(request.url.pathname, fdp):
@@ -406,10 +414,17 @@ proc loadResource(ctx: LoaderContext; client: ClientData; config: LoaderClientCo
           redo = true
           continue
     if request.url.scheme == "cgi-bin":
+      var ostream: PosixStream = nil
       handle.loadCGI(request, ctx.config.cgiDir, prevurl,
-        config.insecureSSLNoVerify)
+        config.insecureSSLNoVerify, ostream)
       if handle.istream != nil:
         ctx.addFd(handle)
+        if ostream != nil:
+          let output = ctx.findOutput(request.body.outputId, client)
+          if output != nil:
+            doAssert ctx.redirectToStream(output, ostream)
+          else:
+            ostream.sclose()
       else:
         handle.close()
     elif request.url.scheme == "stream":
@@ -451,8 +466,7 @@ proc setupRequestDefaults(request: Request; config: LoaderClientConfig) =
 
 proc load(ctx: LoaderContext; stream: SocketStream; request: Request;
     client: ClientData; config: LoaderClientConfig) =
-  let handle = newLoaderHandle(stream, ctx.getOutputId(), client.pid,
-    request.suspended)
+  let handle = newLoaderHandle(stream, ctx.getOutputId(), client.pid)
   when defined(debug):
     handle.url = request.url
     handle.output.url = request.url
@@ -514,9 +528,12 @@ proc addCacheFile(ctx: LoaderContext; stream: SocketStream;
     r: var BufferedReader) =
   var outputId: int
   var targetPid: int
+  var sourcePid: int
   r.sread(outputId)
   r.sread(targetPid)
-  let output = ctx.findOutput(outputId)
+  r.sread(sourcePid)
+  let sourceClient = ctx.clientData[sourcePid]
+  let output = ctx.findOutput(outputId, sourceClient)
   assert output != nil
   let targetClient = ctx.clientData[targetPid]
   let (id, file) = ctx.addCacheFile(targetClient, output)
@@ -531,7 +548,7 @@ proc redirectToFile(ctx: LoaderContext; stream: SocketStream;
   var targetPath: string
   r.sread(outputId)
   r.sread(targetPath)
-  let output = ctx.findOutput(outputId)
+  let output = ctx.findOutput(outputId, ctx.pagerClient)
   var success = false
   if output != nil:
     success = ctx.redirectToFile(output, targetPath)
@@ -583,9 +600,7 @@ proc tee(ctx: LoaderContext; stream: SocketStream; client: ClientData;
   var targetPid: int
   r.sread(sourceId)
   r.sread(targetPid)
-  let output = ctx.findOutput(sourceId)
-  # only allow tee'ing outputs owned by client
-  doAssert output.ownerPid == client.pid
+  let output = ctx.findOutput(sourceId, client)
   if output != nil:
     let id = ctx.getOutputId()
     output.tee(stream, id, targetPid)
@@ -602,7 +617,7 @@ proc suspend(ctx: LoaderContext; stream: SocketStream; client: ClientData;
   var ids: seq[int]
   r.sread(ids)
   for id in ids:
-    let output = ctx.findOutput(id)
+    let output = ctx.findOutput(id, client)
     if output != nil:
       output.suspended = true
       if output.registered:
@@ -615,7 +630,7 @@ proc resume(ctx: LoaderContext; stream: SocketStream; client: ClientData;
   var ids: seq[int]
   r.sread(ids)
   for id in ids:
-    let output = ctx.findOutput(id)
+    let output = ctx.findOutput(id, client)
     if output != nil:
       output.suspended = false
       assert not output.registered
@@ -793,10 +808,9 @@ proc finishCycle(ctx: LoaderContext; unregRead: var seq[LoaderHandle];
     if handle.istream != nil:
       ctx.selector.unregister(handle.istream.fd)
       ctx.handleMap.del(handle.istream.fd)
-      handle.istream.sclose()
-      handle.istream = nil
       if handle.parser != nil:
         handle.finishParse()
+      handle.iclose()
       for output in handle.outputs:
         output.istreamAtEnd = true
         if output.isEmpty:
@@ -816,10 +830,9 @@ proc finishCycle(ctx: LoaderContext; unregRead: var seq[LoaderHandle];
           # premature end of all output streams; kill istream too
           ctx.selector.unregister(handle.istream.fd)
           ctx.handleMap.del(handle.istream.fd)
-          handle.istream.sclose()
-          handle.istream = nil
           if handle.parser != nil:
             handle.finishParse()
+          handle.iclose()
 
 proc runFileLoader*(fd: cint; config: LoaderConfig) =
   var ctx = initLoaderContext(fd, config)
@@ -861,12 +874,7 @@ proc getRedirect*(response: Response; request: Request): Request =
             status == 302 and request.httpMethod == hmPost:
           return newRequest(url.get, hmGet)
         else:
-          return newRequest(
-            url.get,
-            request.httpMethod,
-            body = request.body,
-            multipart = request.multipart
-          )
+          return newRequest(url.get, request.httpMethod, body = request.body)
   return nil
 
 template withLoaderPacketWriter(stream: SocketStream; loader: FileLoader;
@@ -898,7 +906,6 @@ proc startRequest*(loader: FileLoader; request: Request;
     w.swrite(config)
   return stream
 
-#TODO: add init
 proc fetch*(loader: FileLoader; input: Request): FetchPromise =
   let stream = loader.startRequest(input)
   let fd = int(stream.fd)
@@ -913,10 +920,7 @@ proc fetch*(loader: FileLoader; input: Request): FetchPromise =
 
 proc reconnect*(loader: FileLoader; data: ConnectData) =
   data.stream.sclose()
-  let stream = loader.connect()
-  stream.withLoaderPacketWriter loader, w:
-    w.swrite(lcLoad)
-    w.swrite(data.request)
+  let stream = loader.startRequest(data.request)
   let fd = int(stream.fd)
   loader.registerFun(fd)
   loader.connecting[fd] = ConnectData(
@@ -925,18 +929,6 @@ proc reconnect*(loader: FileLoader; data: ConnectData) =
     stream: stream
   )
 
-proc switchStream*(data: var ConnectData; stream: SocketStream) =
-  data.stream = stream
-
-proc switchStream*(loader: FileLoader; data: var OngoingData;
-    stream: SocketStream) =
-  data.response.body = stream
-  let fd = int(stream.fd)
-  data.response.unregisterFun = proc() =
-    loader.ongoing.del(fd)
-    loader.unregistered.add(fd)
-    loader.unregisterFun(fd)
-
 proc suspend*(loader: FileLoader; fds: seq[int]) =
   let stream = loader.connect()
   stream.withLoaderPacketWriter loader, w:
@@ -944,13 +936,16 @@ proc suspend*(loader: FileLoader; fds: seq[int]) =
     w.swrite(fds)
   stream.sclose()
 
-proc resume*(loader: FileLoader; fds: seq[int]) =
+proc resume*(loader: FileLoader; fds: openArray[int]) =
   let stream = loader.connect()
   stream.withLoaderPacketWriter loader, w:
     w.swrite(lcResume)
     w.swrite(fds)
   stream.sclose()
 
+proc resume*(loader: FileLoader; fds: int) =
+  loader.resume([fds])
+
 proc tee*(loader: FileLoader; sourceId, targetPid: int): (SocketStream, int) =
   let stream = loader.connect()
   stream.withLoaderPacketWriter loader, w:
@@ -962,15 +957,20 @@ proc tee*(loader: FileLoader; sourceId, targetPid: int): (SocketStream, int) =
   r.sread(outputId)
   return (stream, outputId)
 
-proc addCacheFile*(loader: FileLoader; outputId, targetPid: int):
-    AddCacheFileResult =
+# sourcePid is the PID of the output's owner. This is used in pager for images,
+# so that we can be sure that a container only loads images on the page that
+# it owns.
+proc addCacheFile*(loader: FileLoader; outputId, targetPid: int;
+    sourcePid = -1): AddCacheFileResult =
   let stream = loader.connect()
   if stream == nil:
     return (-1, "")
+  let sourcePid = if sourcePid == -1: loader.clientPid else: sourcePid
   stream.withLoaderPacketWriter loader, w:
     w.swrite(lcAddCacheFile)
     w.swrite(outputId)
     w.swrite(targetPid)
+    w.swrite(sourcePid)
   var r = stream.initPacketReader()
   var outputId: int
   var cacheFile: string
@@ -990,18 +990,18 @@ proc redirectToFile*(loader: FileLoader; outputId: int; targetPath: string):
   var r = stream.initPacketReader()
   r.sread(result)
 
-const BufferSize = 4096
-
 proc onConnected*(loader: FileLoader; fd: int) =
   let connectData = loader.connecting[fd]
   let stream = connectData.stream
   let promise = connectData.promise
   let request = connectData.request
+  # delete before resolving the promise
+  loader.connecting.del(fd)
   var r = stream.initPacketReader()
   var res: int
   r.sread(res) # packet 1
-  let response = newResponse(res, request, stream)
   if res == 0:
+    let response = newResponse(res, request, stream)
     r.sread(response.outputId) # packet 1
     r = stream.initPacketReader()
     r.sread(response.status) # packet 2
@@ -1011,13 +1011,12 @@ proc onConnected*(loader: FileLoader; fd: int) =
     response.body = stream
     assert loader.unregisterFun != nil
     response.unregisterFun = proc() =
-      loader.ongoing.del(fd)
-      loader.unregistered.add(fd)
-      loader.unregisterFun(fd)
-    loader.ongoing[fd] = OngoingData(
-      response: response,
-      bodyRead: response.bodyRead
-    )
+      loader.ongoing.del(response.body.fd)
+      loader.unregistered.add(response.body.fd)
+      loader.unregisterFun(response.body.fd)
+    response.resumeFun = proc(outputId: int) =
+      loader.resume(outputId)
+    loader.ongoing[fd] = response
     stream.setBlocking(false)
     promise.resolve(JSResult[Response].ok(response))
   else:
@@ -1030,40 +1029,27 @@ proc onConnected*(loader: FileLoader; fd: int) =
     stream.sclose()
     let err = newTypeError("NetworkError when attempting to fetch resource")
     promise.resolve(JSResult[Response].err(err))
-  loader.connecting.del(fd)
 
 proc onRead*(loader: FileLoader; fd: int) =
-  loader.ongoing.withValue(fd, buffer):
-    let response = buffer[].response
-    while not response.body.isend:
-      let olen = buffer[].buf.len
-      try:
-        buffer[].buf.setLen(olen + BufferSize)
-        let n = response.body.recvData(addr buffer[].buf[olen], BufferSize)
-        buffer[].buf.setLen(olen + n)
-        if n == 0:
-          break
-      except ErrorAgain:
-        buffer[].buf.setLen(olen)
-        break
+  let response = loader.ongoing.getOrDefault(fd)
+  if response != nil:
+    response.onRead(response)
     if response.body.isend:
-      buffer[].bodyRead.resolve(buffer[].buf)
-      buffer[].bodyRead = nil
-      buffer[].buf = ""
+      response.bodyRead.resolve()
+      response.bodyRead = nil
       response.unregisterFun()
 
 proc onError*(loader: FileLoader; fd: int) =
-  loader.ongoing.withValue(fd, buffer):
-    let response = buffer[].response
+  let response = loader.ongoing.getOrDefault(fd)
+  if response != nil:
     when defined(debug):
       var lbuf {.noinit.}: array[BufferSize, char]
       if not response.body.isend:
         let n = response.body.recvData(addr lbuf[0], lbuf.len)
         assert n == 0
       assert response.body.isend
-    buffer[].bodyRead.resolve(buffer[].buf)
-    buffer[].bodyRead = nil
-    buffer[].buf = ""
+    response.bodyRead.resolve()
+    response.bodyRead = nil
     response.unregisterFun()
 
 # Note: this blocks until headers are received.
diff --git a/src/loader/loaderhandle.nim b/src/loader/loaderhandle.nim
index 00f6f754..31a41571 100644
--- a/src/loader/loaderhandle.nim
+++ b/src/loader/loaderhandle.nim
@@ -3,6 +3,7 @@ import std/net
 import std/tables
 
 import io/bufwriter
+import io/dynstream
 import io/posixstream
 import loader/headers
 
@@ -44,14 +45,15 @@ type
     status*: uint16
 
   ResponseState = enum
-    rsBeforeResult, rsBeforeStatus, rsBeforeHeaders, rsAfterHeaders
+    rsBeforeResult, rsAfterFailure, rsBeforeStatus, rsBeforeHeaders,
+    rsAfterHeaders
 
   LoaderHandle* = ref object
     istream*: PosixStream # stream for taking input
     outputs*: seq[OutputHandle] # list of outputs to be streamed into
     cacheId*: int # if cached, our ID in a client cacheMap
     parser*: HeaderParser # only exists for CGI handles
-    rstate: ResponseState # just an enum for sanity checks
+    rstate: ResponseState # track response state
     when defined(debug):
       url*: URL
 
@@ -69,15 +71,14 @@ when defined(debug):
     return s
 
 # Create a new loader handle, with the output stream ostream.
-proc newLoaderHandle*(ostream: PosixStream; outputId, pid: int;
-    suspended: bool): LoaderHandle =
+proc newLoaderHandle*(ostream: PosixStream; outputId, pid: int): LoaderHandle =
   let handle = LoaderHandle(cacheId: -1)
   handle.outputs.add(OutputHandle(
     ostream: ostream,
     parent: handle,
     outputId: outputId,
     ownerPid: pid,
-    suspended: suspended
+    suspended: true
   ))
   return handle
 
@@ -108,15 +109,17 @@ proc bufferCleared*(output: OutputHandle) =
     output.currentBuffer = nil
 
 proc tee*(outputIn: OutputHandle; ostream: PosixStream; outputId, pid: int) =
-  outputIn.parent.outputs.add(OutputHandle(
-    parent: outputIn.parent,
+  let parent = outputIn.parent
+  parent.outputs.add(OutputHandle(
+    parent: parent,
     ostream: ostream,
     currentBuffer: outputIn.currentBuffer,
     currentBufferIdx: outputIn.currentBufferIdx,
     buffers: outputIn.buffers,
     istreamAtEnd: outputIn.istreamAtEnd,
     outputId: outputId,
-    ownerPid: pid
+    ownerPid: pid,
+    suspended: outputIn.suspended
   ))
 
 template output*(handle: LoaderHandle): OutputHandle =
@@ -133,6 +136,7 @@ proc sendResult*(handle: LoaderHandle; res: int; msg = "") =
     if res == 0: # success
       assert msg == ""
       w.swrite(output.outputId)
+      inc handle.rstate
     else: # error
       w.swrite(msg)
   output.ostream.setBlocking(blocking)
@@ -164,12 +168,27 @@ proc sendData*(ps: PosixStream; buffer: LoaderBuffer; si = 0): int {.inline.} =
   assert buffer.len - si > 0
   return ps.sendData(addr buffer.page[si], buffer.len - si)
 
+proc iclose*(handle: LoaderHandle) =
+  if handle.istream != nil:
+    if handle.rstate notin {rsBeforeResult, rsAfterFailure, rsAfterHeaders}:
+      assert handle.outputs.len == 1
+      # not an ideal solution, but better than silently eating malformed
+      # headers
+      try:
+        handle.sendStatus(500)
+        handle.sendHeaders(newHeaders())
+        handle.output.ostream.setBlocking(true)
+        const msg = "Error: malformed header in CGI script"
+        discard handle.output.ostream.sendData(msg)
+      except ErrorBrokenPipe:
+        discard # receiver is dead
+    handle.istream.sclose()
+    handle.istream = nil
+
 proc close*(handle: LoaderHandle) =
+  handle.iclose()
   for output in handle.outputs:
     #TODO assert not output.registered
     if output.ostream != nil:
       output.ostream.sclose()
       output.ostream = nil
-  if handle.istream != nil:
-    handle.istream.sclose()
-    handle.istream = nil
diff --git a/src/loader/request.nim b/src/loader/request.nim
index 277481f1..f92098cb 100644
--- a/src/loader/request.nim
+++ b/src/loader/request.nim
@@ -58,17 +58,27 @@ type
     of rwtWindow:
       window*: EnvironmentSettings
 
+  RequestBodyType* = enum
+    rbtNone, rbtString, rbtMultipart, rbtOutput
+
+  RequestBody* = object
+    case t*: RequestBodyType
+    of rbtNone:
+      discard
+    of rbtString:
+      s*: string
+    of rbtMultipart:
+      multipart*: FormData
+    of rbtOutput:
+      outputId*: int
+
   Request* = ref object
     httpMethod*: HttpMethod
     url*: URL
     headers*: Headers
-    body*: Option[string]
-    multipart*: Option[FormData]
+    body*: RequestBody
     referrer*: URL
     proxy*: URL #TODO do something with this
-    # when set to true, the loader will not write data from the body (not
-    # headers!) into the output until a resume is received.
-    suspended*: bool
 
   JSRequest* = ref object
     request*: Request
@@ -81,6 +91,13 @@ type
 
 jsDestructor(JSRequest)
 
+proc contentLength*(body: RequestBody): int =
+  case body.t
+  of rbtNone: return 0
+  of rbtString: return body.s.len
+  of rbtMultipart: return body.multipart.calcLength()
+  of rbtOutput: return 0
+
 func headers(this: JSRequest): Headers {.jsfget.} =
   return this.request.headers
 
@@ -102,17 +119,14 @@ iterator pairs*(headers: Headers): (string, string) =
       yield (k, v)
 
 func newRequest*(url: URL; httpMethod = hmGet; headers = newHeaders();
-    body = none(string); multipart = none(FormData); proxy: URL = nil;
-    referrer: URL = nil; suspended = false): Request =
+    body = RequestBody(); proxy: URL = nil; referrer: URL = nil): Request =
   return Request(
     url: url,
     httpMethod: httpMethod,
     headers: headers,
     body: body,
-    multipart: multipart,
     referrer: referrer,
-    proxy: proxy,
-    suspended: suspended
+    proxy: proxy
   )
 
 func createPotentialCORSRequest*(url: URL; destination: RequestDestination;
@@ -178,7 +192,7 @@ proc fromJSBodyInit(ctx: JSContext; val: JSValue): JSResult[BodyInit] =
     let x = fromJS[string](ctx, val)
     if x.isSome:
       return ok(BodyInit(t: bitString, str: x.get))
-  return err(newTypeError("Invalid body init type"))
+  return errTypeError("Invalid body init type")
 
 func newRequest*[T: string|JSRequest](ctx: JSContext; resource: T;
     init = none(RequestInit)): JSResult[JSRequest] {.jsctor.} =
@@ -188,13 +202,12 @@ func newRequest*[T: string|JSRequest](ctx: JSContext; resource: T;
   when T is string:
     let url = ?newURL(resource)
     if url.username != "" or url.password != "":
-      return err(newTypeError("Input URL contains a username or password"))
+      return errTypeError("Input URL contains a username or password")
     var httpMethod = hmGet
     var headers = newHeaders()
     let referrer: URL = nil
     var credentials = cmSameOrigin
-    var body: Option[string]
-    var multipart: Option[FormData]
+    var body = RequestBody()
     var proxyUrl: URL #TODO?
     let fallbackMode = opt(rmCors)
     var window = RequestWindow(t: rwtClient)
@@ -205,7 +218,6 @@ func newRequest*[T: string|JSRequest](ctx: JSContext; resource: T;
     let referrer = resource.request.referrer
     var credentials = resource.credentialsMode
     var body = resource.request.body
-    var multipart = resource.request.multipart
     var proxyUrl = resource.request.proxy #TODO?
     let fallbackMode = none(RequestMode)
     var window = resource.window
@@ -226,8 +238,10 @@ func newRequest*[T: string|JSRequest](ctx: JSContext; resource: T;
     if init.body.isSome:
       let ibody = init.body.get
       case ibody.t
-      of bitFormData: multipart = some(ibody.formData)
-      of bitString: body = some(ibody.str)
+      of bitFormData:
+        body = RequestBody(t: rbtMultipart, multipart: ibody.formData)
+      of bitString:
+        body = RequestBody(t: rbtString, s: ibody.str)
       else: discard #TODO
       if httpMethod in {hmGet, hmHead}:
         return errTypeError("HEAD or GET Request cannot have a body.")
@@ -245,7 +259,6 @@ func newRequest*[T: string|JSRequest](ctx: JSContext; resource: T;
       httpMethod,
       headers,
       body,
-      multipart,
       proxy = proxyUrl,
       referrer = referrer
     ),
diff --git a/src/loader/response.nim b/src/loader/response.nim
index 8ea17e64..3834d5a9 100644
--- a/src/loader/response.nim
+++ b/src/loader/response.nim
@@ -3,6 +3,8 @@ import std/tables
 
 import chagashi/charset
 import chagashi/decoder
+import img/bitmap
+import io/posixstream
 import io/promise
 import io/socketstream
 import loader/headers
@@ -11,6 +13,7 @@ import monoucha/javascript
 import monoucha/jserror
 import monoucha/quickjs
 import types/blob
+import types/color
 import types/opt
 import types/url
 import utils/mimeguess
@@ -43,9 +46,12 @@ type
     headersGuard: HeadersGuard
     url*: URL #TODO should be urllist?
     unregisterFun*: proc()
-    bodyRead*: Promise[string]
+    resumeFun*: proc(outputId: int)
+    bodyRead*: EmptyPromise
     internalMessage*: string # should NOT be exposed to JS!
     outputId*: int
+    onRead*: proc(response: Response) {.nimcall.}
+    opaque*: RootRef
 
 jsDestructor(Response)
 
@@ -54,7 +60,7 @@ proc newResponse*(res: int; request: Request; stream: SocketStream): Response =
     res: res,
     url: request.url,
     body: stream,
-    bodyRead: Promise[string](),
+    bodyRead: EmptyPromise(),
     outputId: -1
   )
 
@@ -66,7 +72,8 @@ func makeNetworkError*(): Response {.jsstfunc: "Response.error".} =
     responseType: TYPE_ERROR,
     status: 0,
     headers: newHeaders(),
-    headersGuard: hgImmutable
+    headersGuard: hgImmutable,
+    bodyUsed: true
   )
 
 func sok(response: Response): bool {.jsfget: "ok".} =
@@ -102,6 +109,25 @@ func getContentType*(this: Response): string =
   # override buffer mime.types
   return DefaultGuess.guessContentType(this.url.pathname)
 
+type TextOpaque = ref object of RootObj
+  buf: string
+
+const BufferSize = 4096
+
+proc onReadText(response: Response) =
+  let opaque = TextOpaque(response.opaque)
+  while true:
+    let olen = opaque.buf.len
+    try:
+      opaque.buf.setLen(olen + BufferSize)
+      let n = response.body.recvData(addr opaque.buf[olen], BufferSize)
+      opaque.buf.setLen(olen + n)
+      if n == 0:
+        break
+    except ErrorAgain:
+      opaque.buf.setLen(olen)
+      break
+
 proc text*(response: Response): Promise[JSResult[string]] {.jsfunc.} =
   if response.body == nil:
     let p = newPromise[JSResult[string]]()
@@ -113,40 +139,96 @@ proc text*(response: Response): Promise[JSResult[string]] {.jsfunc.} =
       .err(newTypeError("Body has already been consumed"))
     p.resolve(err)
     return p
-  let bodyRead = response.bodyRead
-  response.bodyRead = nil
-  return bodyRead.then(proc(s: string): JSResult[string] =
+  let opaque = TextOpaque()
+  response.opaque = opaque
+  response.onRead = onReadText
+  response.bodyUsed = true
+  response.resumeFun(response.outputId)
+  response.resumeFun = nil
+  return response.bodyRead.then(proc(): JSResult[string] =
     let charset = response.getCharset(CHARSET_UTF_8)
-    #TODO this is inefficient
-    # maybe add a JS type that turns a seq[char] into JS strings
-    ok(s.decodeAll(charset))
+    ok(opaque.buf.decodeAll(charset))
   )
 
+type BlobOpaque = ref object of RootObj
+  p: pointer
+  len: int
+  size: int
+
+proc onReadBlob(response: Response) =
+  let opaque = BlobOpaque(response.opaque)
+  while true:
+    try:
+      let targetLen = opaque.len + BufferSize
+      if targetLen > opaque.size:
+        opaque.size = targetLen
+        opaque.p = realloc(opaque.p, targetLen)
+      let p = cast[ptr UncheckedArray[uint8]](opaque.p)
+      let n = response.body.recvData(addr p[opaque.len], BufferSize)
+      opaque.len += n
+      if n == 0:
+        break
+    except ErrorAgain:
+      break
+
 proc blob*(response: Response): Promise[JSResult[Blob]] {.jsfunc.} =
-  if response.bodyRead == nil:
+  if response.bodyUsed:
     let p = newPromise[JSResult[Blob]]()
     let err = JSResult[Blob]
       .err(newTypeError("Body has already been consumed"))
     p.resolve(err)
     return p
-  let bodyRead = response.bodyRead
-  response.bodyRead = nil
+  let opaque = BlobOpaque()
+  response.opaque = opaque
+  response.onRead = onReadBlob
+  response.bodyUsed = true
+  response.resumeFun(response.outputId)
+  response.resumeFun = nil
   let contentType = response.getContentType()
-  return bodyRead.then(proc(s: string): JSResult[Blob] =
-    if s.len == 0:
+  return response.bodyRead.then(proc(): JSResult[Blob] =
+    let p = realloc(opaque.p, opaque.len)
+    opaque.p = nil
+    if p == nil:
       return ok(newBlob(nil, 0, contentType, nil))
-    GC_ref(s)
-    let deallocFun = proc() =
-      GC_unref(s)
-    let blob = newBlob(unsafeAddr s[0], s.len, contentType, deallocFun)
-    ok(blob))
+    ok(newBlob(p, opaque.len, contentType, deallocBlob))
+  )
+
+type BitmapOpaque = ref object of RootObj
+  bmp: Bitmap
+  idx: int
+
+proc onReadBitmap(response: Response) =
+  let opaque = BitmapOpaque(response.opaque)
+  let bmp = opaque.bmp
+  while true:
+    try:
+      let p = cast[ptr UncheckedArray[uint8]](addr bmp.px[0])
+      let L = bmp.px.len * 4 - opaque.idx
+      let n = response.body.recvData(addr p[opaque.idx], L)
+      opaque.idx += n
+      if n == 0:
+        break
+    except ErrorAgain:
+      break
+
+proc saveToBitmap*(response: Response; bmp: Bitmap): EmptyPromise =
+  assert not response.bodyUsed
+  let opaque = BitmapOpaque(bmp: bmp, idx: 0)
+  let size = bmp.width * bmp.height
+  bmp.px = cast[seq[ARGBColor]](newSeqUninitialized[uint32](size))
+  response.opaque = opaque
+  response.onRead = onReadBitmap
+  response.bodyUsed = true
+  response.resumeFun(response.outputId)
+  response.resumeFun = nil
+  return response.bodyRead
 
 proc json(ctx: JSContext; this: Response): Promise[JSResult[JSValue]]
     {.jsfunc.} =
   return this.text().then(proc(s: JSResult[string]): JSResult[JSValue] =
     let s = ?s
-    return ok(JS_ParseJSON(ctx, cstring(s), cast[csize_t](s.len),
-      cstring"<input>")))
+    return ok(JS_ParseJSON(ctx, cstring(s), csize_t(s.len), cstring"<input>"))
+  )
 
 proc addResponseModule*(ctx: JSContext) =
   ctx.registerType(Response)
diff --git a/src/local/client.nim b/src/local/client.nim
index 8e83892c..ee469652 100644
--- a/src/local/client.nim
+++ b/src/local/client.nim
@@ -438,10 +438,10 @@ proc acceptBuffers(client: Client) =
         let pid = container.process
         if item.fdout == item.fdin:
           loader.shareCachedItem(container.cacheId, pid)
-          loader.resume(@[item.istreamOutputId])
+          loader.resume(item.istreamOutputId)
         else:
           outCacheId = loader.addCacheFile(item.ostreamOutputId, pid).outputId
-          loader.resume(@[item.istreamOutputId, item.ostreamOutputId])
+          loader.resume([item.istreamOutputId, item.ostreamOutputId])
         w.swrite(outCacheId)
     if item.fdin != -1:
       # pass down fdout
diff --git a/src/local/container.nim b/src/local/container.nim
index b64606a1..8717610f 100644
--- a/src/local/container.nim
+++ b/src/local/container.nim
@@ -7,6 +7,7 @@ import std/unicode
 
 import config/config
 import config/mimetypes
+import img/bitmap
 import io/bufstream
 import io/dynstream
 import io/promise
@@ -157,6 +158,7 @@ type
     mainConfig*: Config
     flags*: set[ContainerFlag]
     images*: seq[PosBitmap]
+    cachedImages*: seq[NetworkBitmap]
     luctx: LUContext
 
 jsDestructor(Highlight)
@@ -1741,6 +1743,12 @@ proc highlightMarks*(container: Container; display: var FixedGrid;
       hlformat.bgcolor = hlcolor
       display[y * display.width + x].format = hlformat
 
+func findCachedImage*(container: Container; id: int): NetworkBitmap =
+  for bmp in container.cachedImages:
+    if bmp.imageId == id:
+      return bmp
+  return nil
+
 proc handleEvent*(container: Container) =
   container.handleCommand()
   if container.needslines:
diff --git a/src/local/pager.nim b/src/local/pager.nim
index 007ad6a9..8760da86 100644
--- a/src/local/pager.nim
+++ b/src/local/pager.nim
@@ -11,6 +11,7 @@ import std/unicode
 import config/chapath
 import config/config
 import config/mailcap
+import img/bitmap
 import io/bufreader
 import io/dynstream
 import io/posixstream
@@ -474,9 +475,34 @@ proc draw*(pager: Pager) =
   else:
     pager.term.writeGrid(pager.statusgrid, 0, pager.attrs.height - 1)
   pager.term.outputGrid()
-  if container != nil and pager.redraw:
+  if container != nil and pager.redraw and pager.term.imageMode != imNone:
     pager.term.clearImages()
     for image in container.images:
+      if image.bmp of NetworkBitmap:
+        # add cache file to pager, but source it from the container.
+        let bmp = NetworkBitmap(image.bmp)
+        let cached = container.findCachedImage(bmp.imageId)
+        if cached == nil:
+          let (cacheId, _) = pager.loader.addCacheFile(bmp.outputId,
+            pager.loader.clientPid, container.process)
+          let request = newRequest(newURL("cache:" & $cacheId).get)
+          # capture bmp for the closure
+          (proc(bmp: Bitmap) =
+            pager.loader.fetch(request).then(proc(res: JSResult[Response]):
+                EmptyPromise =
+              if res.isNone:
+                return nil
+              return res.get.saveToBitmap(bmp)
+            ).then(proc() =
+              pager.redraw = true
+            )
+          )(bmp)
+          pager.loader.resume(bmp.outputId) # get rid of dangling output
+          container.cachedImages.add(bmp)
+          continue
+        image.bmp = cached
+      if uint64(image.bmp.px.len) < image.bmp.width * image.bmp.height:
+        continue # loading
       pager.term.outputImage(image.bmp, image.x - container.fromx,
         image.y - container.fromy, pager.attrs.width, pager.attrs.height - 1)
   if pager.askpromise != nil:
@@ -541,7 +567,6 @@ proc newContainer(pager: Pager; bufferConfig: BufferConfig;
     redirectDepth = 0; flags = {cfCanReinterpret, cfUserRequested};
     contentType = none(string); charsetStack: seq[Charset] = @[];
     url = request.url; cacheId = -1; cacheFile = ""): Container =
-  request.suspended = true
   let stream = pager.loader.startRequest(request, loaderConfig)
   pager.loader.registerFun(stream.fd)
   let container = newContainer(
@@ -1021,6 +1046,9 @@ proc applySiteconf(pager: Pager; url: var URL; charsetOverride: Charset;
       loaderConfig.insecureSSLNoVerify = sc.insecure_ssl_no_verify.get
     if sc.autofocus.isSome:
       res.autofocus = sc.autofocus.get
+  if res.images:
+    loaderConfig.filter.allowschemes
+      .add(pager.config.external.urimethodmap.imageProtos)
   return res
 
 # Load request in a new buffer.
@@ -1202,7 +1230,7 @@ proc updateReadLineISearch(pager: Pager; linemode: LineMode) =
 proc saveTo(pager: Pager; data: LineDataDownload; path: string) =
   if pager.loader.redirectToFile(data.outputId, path):
     pager.alert("Saving file to " & path)
-    pager.loader.resume(@[data.outputId])
+    pager.loader.resume(data.outputId)
     data.stream.sclose()
     pager.lineData = nil
   else:
@@ -1544,7 +1572,7 @@ proc filterBuffer(pager: Pager; stream: SocketStream; cmd: string;
   let url = parseURL("stream:" & $pid).get
   pager.loader.passFd(url.pathname, FileHandle(fdout))
   safeClose(fdout)
-  let response = pager.loader.doRequest(newRequest(url, suspended = true))
+  let response = pager.loader.doRequest(newRequest(url))
   return CheckMailcapResult(
     connect: true,
     fdout: response.body.fd,
@@ -1607,7 +1635,7 @@ proc checkMailcap(pager: Pager; container: Container; stream: SocketStream;
   block needsConnect:
     if entry.flags * {COPIOUSOUTPUT, HTMLOUTPUT, ANSIOUTPUT} == {}:
       # No output. Resume here, so that blocking needsterminal filters work.
-      pager.loader.resume(@[istreamOutputId])
+      pager.loader.resume(istreamOutputId)
       if canpipe:
         pager.runMailcapWritePipe(stream, needsterminal, cmd)
       else:
@@ -1632,7 +1660,7 @@ proc checkMailcap(pager: Pager; container: Container; stream: SocketStream;
     let url = parseURL("stream:" & $pid).get
     pager.loader.passFd(url.pathname, FileHandle(fdout))
     safeClose(cint(fdout))
-    let response = pager.loader.doRequest(newRequest(url, suspended = true))
+    let response = pager.loader.doRequest(newRequest(url))
     return CheckMailcapResult(
       connect: true,
       fdout: response.body.fd,
diff --git a/src/local/term.nim b/src/local/term.nim
index 7dd6d951..db479734 100644
--- a/src/local/term.nim
+++ b/src/local/term.nim
@@ -66,7 +66,7 @@ type
     attrs*: WindowAttributes
     colorMode: ColorMode
     formatMode: FormatMode
-    imageMode: ImageMode
+    imageMode*: ImageMode
     smcup: bool
     tc: Termcap
     tname: string
@@ -717,8 +717,6 @@ proc outputKittyImage(term: Terminal; x, y, offx, offy, dispw, disph: int;
 # x, y, maxw, maxh in cells
 # x, y can be negative, then image starts outside the screen
 proc outputImage*(term: Terminal; bmp: Bitmap; x, y, maxw, maxh: int) =
-  if term.imageMode == imNone:
-    return
   var xpx = x * term.attrs.ppc
   var ypx = y * term.attrs.ppl
   # calculate offset inside image to start from
@@ -737,7 +735,7 @@ proc outputImage*(term: Terminal; bmp: Bitmap; x, y, maxw, maxh: int) =
   let x = max(x, 0)
   let y = max(y, 0)
   case term.imageMode
-  of imNone: discard
+  of imNone: assert false
   of imSixel: term.outputSixelImage(x, y, offx, offy, dispw, disph, bmp)
   of imKitty: term.outputKittyImage(x, y, offx, offy, dispw, disph, bmp)
 
diff --git a/src/server/buffer.nim b/src/server/buffer.nim
index 1d172922..53b30aac 100644
--- a/src/server/buffer.nim
+++ b/src/server/buffer.nim
@@ -849,6 +849,7 @@ proc rewind(buffer: Buffer; offset: int; unregister = true): bool =
   let response = buffer.loader.doRequest(newRequest(url))
   if response.body == nil:
     return false
+  buffer.loader.resume(response.outputId)
   if unregister:
     buffer.selector.unregister(buffer.fd)
     buffer.loader.unregistered.add(buffer.fd)
@@ -921,13 +922,15 @@ proc clone*(buffer: Buffer; newurl: URL): int {.proxy.} =
     return -1
   # suspend outputs before tee'ing
   var ids: seq[int] = @[]
-  for data in buffer.loader.ongoing.values:
-    ids.add(data.response.outputId)
+  for response in buffer.loader.ongoing.values:
+    if response.onRead != nil:
+      ids.add(response.outputId)
   buffer.loader.suspend(ids)
   # ongoing transfers are now suspended; exhaust all data in the internal buffer
   # just to be safe.
-  for fd in buffer.loader.ongoing.keys:
-    buffer.loader.onRead(fd)
+  for fd, response in buffer.loader.ongoing:
+    if response.onRead != nil:
+      buffer.loader.onRead(fd)
   let pid = fork()
   if pid == -1:
     buffer.estream.write("Failed to clone buffer.\n")
@@ -963,29 +966,21 @@ proc clone*(buffer: Buffer; newurl: URL): int {.proxy.} =
     else:
       buffer.selector = newSelector[int]()
     #TODO set buffer.window.timeouts.selector
-    var cfds: seq[int] = @[]
-    for fd in buffer.loader.connecting.keys:
-      cfds.add(fd)
-    for fd in cfds:
-      # connecting: just reconnect
-      let data = buffer.loader.connecting[fd]
-      buffer.loader.connecting.del(fd)
-      buffer.loader.reconnect(data)
-    var ongoing: seq[OngoingData] = @[]
-    for data in buffer.loader.ongoing.values:
-      ongoing.add(data)
-      data.response.body.sclose()
+    var ongoing: seq[Response] = @[]
+    for response in buffer.loader.ongoing.values:
+      ongoing.add(response)
+      response.body.sclose()
     buffer.loader.ongoing.clear()
     let myPid = getCurrentProcessId()
-    for data in ongoing.mitems:
+    for response in ongoing.mitems:
       # tee ongoing streams
-      let (stream, outputId) = buffer.loader.tee(data.response.outputId, myPid)
+      let (stream, outputId) = buffer.loader.tee(response.outputId, myPid)
       # if -1, well, this side hasn't exhausted the socket's buffer
       doAssert outputId != -1 and stream != nil
-      data.response.outputId = outputId
-      data.response.body = stream
-      let fd = data.response.body.fd
-      buffer.loader.ongoing[fd] = data
+      response.outputId = outputId
+      response.body = stream
+      let fd = response.body.fd
+      buffer.loader.ongoing[fd] = response
       buffer.selector.registerHandle(fd, {Read}, 0)
     if buffer.istream != nil:
       # We do not own our input stream, so we can't tee it.
@@ -1011,6 +1006,16 @@ proc clone*(buffer: Buffer; newurl: URL): int {.proxy.} =
     r.sread(buffer.loader.key)
     buffer.rfd = buffer.pstream.fd
     buffer.selector.registerHandle(buffer.rfd, {Read}, 0)
+    # must reconnect after the new client is set up, or the client pids get
+    # mixed up.
+    var cfds: seq[int] = @[]
+    for fd in buffer.loader.connecting.keys:
+      cfds.add(fd)
+    for fd in cfds:
+      # connecting: just reconnect
+      let data = buffer.loader.connecting[fd]
+      buffer.loader.connecting.del(fd)
+      buffer.loader.reconnect(data)
     return 0
   else: # parent
     discard close(pipefd[1]) # close write
@@ -1227,10 +1232,10 @@ proc cancel*(buffer: Buffer) {.proxy.} =
     buffer.loader.unregistered.add(fd)
     data.stream.sclose()
   buffer.loader.connecting.clear()
-  for fd, data in buffer.loader.ongoing:
+  for fd, response in buffer.loader.ongoing:
     buffer.selector.unregister(fd)
     buffer.loader.unregistered.add(fd)
-    data.response.body.sclose()
+    response.body.sclose()
   buffer.loader.ongoing.clear()
   if buffer.istream != nil:
     buffer.selector.unregister(buffer.fd)
@@ -1247,7 +1252,7 @@ proc cancel*(buffer: Buffer) {.proxy.} =
   buffer.do_reshape()
 
 #https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#multipart/form-data-encoding-algorithm
-proc serializeMultipartFormData(entries: seq[FormDataEntry]): FormData =
+proc serializeMultipart(entries: seq[FormDataEntry]): FormData =
   let formData = newFormData0()
   for entry in entries:
     let name = makeCRLF(entry.name)
@@ -1298,7 +1303,7 @@ proc makeFormRequest(buffer: Buffer; parsedAction: URL; httpMethod: HttpMethod;
       # mutate action URL
       let kvlist = entryList.toNameValuePairs()
       #TODO with charset
-      parsedAction.query = some(serializeApplicationXWWWFormUrlEncoded(kvlist))
+      parsedAction.query = some(serializeFormURLEncoded(kvlist))
       return newRequest(parsedAction, httpMethod)
     return newRequest(parsedAction) # get action URL
   of frtMailto:
@@ -1306,18 +1311,16 @@ proc makeFormRequest(buffer: Buffer; parsedAction: URL; httpMethod: HttpMethod;
       # mailWithHeaders
       let kvlist = entryList.toNameValuePairs()
       #TODO with charset
-      let headers = serializeApplicationXWWWFormUrlEncoded(kvlist,
-        spaceAsPlus = false)
+      let headers = serializeFormURLEncoded(kvlist, spaceAsPlus = false)
       parsedAction.query = some(headers)
       return newRequest(parsedAction, httpMethod)
     # mail as body
     let kvlist = entryList.toNameValuePairs()
     let body = if enctype == fetTextPlain:
-      let text = serializePlainTextFormData(kvlist)
-      percentEncode(text, PathPercentEncodeSet)
+      percentEncode(serializePlainTextFormData(kvlist), PathPercentEncodeSet)
     else:
       #TODO with charset
-      serializeApplicationXWWWFormUrlEncoded(kvlist)
+      serializeFormURLEncoded(kvlist)
     if parsedAction.query.isNone:
       parsedAction.query = some("")
     if parsedAction.query.get != "":
@@ -1329,26 +1332,24 @@ proc makeFormRequest(buffer: Buffer; parsedAction: URL; httpMethod: HttpMethod;
       # mutate action URL
       let kvlist = entryList.toNameValuePairs()
       #TODO with charset
-      let query = serializeApplicationXWWWFormUrlEncoded(kvlist)
+      let query = serializeFormURLEncoded(kvlist)
       parsedAction.query = some(query)
       return newRequest(parsedAction, httpMethod)
     # submit as entity body
-    var body: Option[string]
-    var multipart: Option[FormData]
-    case enctype
+    let body = case enctype
     of fetUrlencoded:
       #TODO with charset
       let kvlist = entryList.toNameValuePairs()
-      body = some(serializeApplicationXWWWFormUrlEncoded(kvlist))
+      RequestBody(t: rbtString, s: serializeFormURLEncoded(kvlist))
     of fetMultipart:
       #TODO with charset
-      multipart = some(serializeMultipartFormData(entryList))
+      RequestBody(t: rbtMultipart, multipart: serializeMultipart(entryList))
     of fetTextPlain:
       #TODO with charset
       let kvlist = entryList.toNameValuePairs()
-      body = some(serializePlainTextFormData(kvlist))
+      RequestBody(t: rbtString, s: serializePlainTextFormData(kvlist))
     let headers = newHeaders({"Content-Type": $enctype})
-    return newRequest(parsedAction, httpMethod, headers, body, multipart)
+    return newRequest(parsedAction, httpMethod, headers, body)
 
 # https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#form-submission-algorithm
 proc submitForm(buffer: Buffer; form: HTMLFormElement; submitter: Element): Request =
@@ -1698,7 +1699,7 @@ proc getLines*(buffer: Buffer; w: Slice[int]): GetLinesResult {.proxy.} =
   if buffer.config.images:
     for image in buffer.images:
       if image.y <= w.b and
-          image.y + int(image.bmp.height) div buffer.attrs.ppl >= w.a:
+          image.y + int(image.bmp.height) >= w.a * buffer.attrs.ppl:
         result.images.add(image)
 
 proc markURL*(buffer: Buffer; schemes: seq[string]) {.proxy.} =
@@ -1849,7 +1850,6 @@ proc handleRead(buffer: Buffer; fd: int): bool =
     buffer.onload()
   elif fd in buffer.loader.connecting:
     buffer.loader.onConnected(fd)
-    buffer.loader.onRead(fd)
     if buffer.config.scripting:
       buffer.window.runJSJobs()
   elif fd in buffer.loader.ongoing:
diff --git a/src/types/blob.nim b/src/types/blob.nim
index ce5311ea..fb0bd92a 100644
--- a/src/types/blob.nim
+++ b/src/types/blob.nim
@@ -8,7 +8,7 @@ import monoucha/jstypes
 import utils/mimeguess
 
 type
-  DeallocFun = proc() {.closure, raises: [].}
+  DeallocFun = proc(blob: Blob) {.closure, raises: [].}
 
   Blob* = ref object of RootObj
     size* {.jsget.}: uint64
@@ -37,7 +37,7 @@ proc finalize(blob: Blob) {.jsfin.} =
   if blob.fd.isSome:
     discard close(blob.fd.get)
   if blob.deallocFun != nil and blob.buffer != nil:
-    blob.deallocFun()
+    blob.deallocFun(blob)
     blob.buffer = nil
 
 proc finalize(file: WebFile) {.jsfin.} =
@@ -58,6 +58,11 @@ type
   FilePropertyBag = object of BlobPropertyBag
     #TODO lastModified: int64
 
+proc deallocBlob*(blob: Blob) =
+  if blob.buffer != nil:
+    dealloc(blob.buffer)
+    blob.buffer = nil
+
 proc newWebFile(ctx: JSContext; fileBits: seq[string]; fileName: string;
     options = FilePropertyBag()): WebFile {.jsctor.} =
   let file = WebFile(
@@ -68,7 +73,7 @@ proc newWebFile(ctx: JSContext; fileBits: seq[string]; fileName: string;
     len += blobPart.len
   let buffer = alloc(len)
   file.buffer = buffer
-  file.deallocFun = proc() = dealloc(buffer)
+  file.deallocFun = deallocBlob
   var buf = cast[ptr UncheckedArray[uint8]](file.buffer)
   var i = 0
   for blobPart in fileBits:
diff --git a/src/types/cookie.nim b/src/types/cookie.nim
index 2cc5928c..a303b133 100644
--- a/src/types/cookie.nim
+++ b/src/types/cookie.nim
@@ -207,8 +207,7 @@ proc serialize*(cookiejar: CookieJar; url: URL): string =
     result &= "="
     result &= cookie.value
 
-proc newCookie*(str: string; url: URL = nil): JSResult[Cookie]
-    {.jsctor.} =
+proc newCookie*(str: string; url: URL = nil): JSResult[Cookie] {.jsctor.} =
   let cookie = Cookie(
     expires: -1,
     created: now().toTime().toUnix()
@@ -253,7 +252,7 @@ proc newCookie*(str: string; url: URL = nil): JSResult[Cookie]
         cookie.domain = val
         hasdomain = true
       else:
-        return err(newTypeError("Domains do not match"))
+        return errTypeError("Domains do not match")
   if not hasdomain:
     if url != nil:
       cookie.domain = url.host
diff --git a/src/types/urimethodmap.nim b/src/types/urimethodmap.nim
index eed8b112..4cb5b9ae 100644
--- a/src/types/urimethodmap.nim
+++ b/src/types/urimethodmap.nim
@@ -9,6 +9,7 @@ import utils/twtstr
 
 type URIMethodMap* = object
   map*: Table[string, string]
+  imageProtos*: seq[string]
 
 func rewriteURL(pattern, surl: string): string =
   result = ""
@@ -44,6 +45,10 @@ proc findAndRewrite*(this: URIMethodMap; url: var URL): URIMethodMapResult =
     return URI_RESULT_SUCCESS
   return URI_RESULT_NOT_FOUND
 
+proc insert(this: var URIMethodMap; k, v: string) =
+  if not this.map.hasKeyOrPut(k, v) and k.startsWith("img-codec+"):
+    this.imageProtos.add(k.until(':'))
+
 proc parseURIMethodMap*(this: var URIMethodMap; s: string) =
   for line in s.split('\n'):
     if line.len == 0 or line[0] == '#':
@@ -68,7 +73,7 @@ proc parseURIMethodMap*(this: var URIMethodMap; s: string) =
       v = "cgi-bin:" & v.substr("file:///cgi-bin/".len)
     elif v.startsWith("/cgi-bin/"):
       v = "cgi-bin:" & v.substr("/cgi-bin/".len)
-    discard this.map.hasKeyOrPut(k, v)
+    this.insert(k, v)
 
 proc parseURIMethodMap*(s: string): URIMethodMap =
   result = URIMethodMap()
@@ -76,4 +81,4 @@ proc parseURIMethodMap*(s: string): URIMethodMap =
 
 proc append*(this: var URIMethodMap; that: URIMethodMap) =
   for k, v in that.map:
-    discard this.map.hasKeyOrPut(k, v)
+    this.insert(k, v)
diff --git a/src/types/url.nim b/src/types/url.nim
index 627af4aa..f6e28d10 100644
--- a/src/types/url.nim
+++ b/src/types/url.nim
@@ -970,7 +970,7 @@ proc newURL*(url: URL): URL =
 proc setHref(url: URL; s: string): Err[JSError] {.jsfset: "href".} =
   let purl = basicParseURL(s)
   if purl.isNone:
-    return err(newTypeError(s & " is not a valid URL"))
+    return errTypeError(s & " is not a valid URL")
   purl.get.cloneInto(url)
 
 func isIP*(url: URL): bool =
@@ -980,7 +980,7 @@ func isIP*(url: URL): bool =
   return host.ipv4.isSome or host.ipv6.isSome
 
 #https://url.spec.whatwg.org/#concept-urlencoded-serializer
-proc parseApplicationXWWWFormUrlEncoded(input: string): seq[(string, string)] =
+proc parseFromURLEncoded(input: string): seq[(string, string)] =
   for s in input.split('&'):
     if s == "":
       continue
@@ -1002,8 +1002,8 @@ proc parseApplicationXWWWFormUrlEncoded(input: string): seq[(string, string)] =
     result.add((percentDecode(name), percentDecode(value)))
 
 #https://url.spec.whatwg.org/#concept-urlencoded-serializer
-proc serializeApplicationXWWWFormUrlEncoded*(kvs: seq[(string, string)];
-    spaceAsPlus = true): string =
+proc serializeFormURLEncoded*(kvs: seq[(string, string)]; spaceAsPlus = true):
+    string =
   for it in kvs:
     let (name, value) = it
     if result != "":
@@ -1013,7 +1013,7 @@ proc serializeApplicationXWWWFormUrlEncoded*(kvs: seq[(string, string)];
     result.percentEncode(value, ApplicationXWWWFormUrlEncodedSet, spaceAsPlus)
 
 proc initURLSearchParams(params: URLSearchParams; init: string) =
-  params.list = parseApplicationXWWWFormUrlEncoded(init)
+  params.list = parseFromURLEncoded(init)
 
 proc newURLSearchParams[
       T: seq[(string, string)]|
@@ -1034,7 +1034,7 @@ proc newURLSearchParams[
     result.initURLSearchParams(init)
 
 proc `$`*(params: URLSearchParams): string {.jsfunc.} =
-  return serializeApplicationXWWWFormUrlEncoded(params.list)
+  return serializeFormURLEncoded(params.list)
 
 proc update(params: URLSearchParams) =
   if params.url.isNone:
@@ -1076,13 +1076,13 @@ proc parseAPIURL(s: string; base: Option[string]): JSResult[URL] =
   let baseURL = if base.isSome:
     let x = parseURL(base.get)
     if x.isNone:
-      return err(newTypeError(base.get & " is not a valid URL"))
+      return errTypeError(base.get & " is not a valid URL")
     x
   else:
     none(URL)
   let url = parseURL(s, baseURL)
   if url.isNone:
-    return err(newTypeError(s & " is not a valid URL"))
+    return errTypeError(s & " is not a valid URL")
   return ok(url.get)
 
 proc newURL*(s: string; base: Option[string] = none(string)):
@@ -1212,7 +1212,7 @@ proc setSearch*(url: URL; s: string) {.jsfset: "search".} =
   let s = if s[0] == '?': s.substr(1) else: s
   url.query = some("")
   discard basicParseURL(s, url = url, stateOverride = some(usQuery))
-  url.searchParams.list = parseApplicationXWWWFormUrlEncoded(s)
+  url.searchParams.list = parseFromURLEncoded(s)
 
 proc hash*(url: URL): string {.jsfget.} =
   if url.fragment.get("") == "":
diff --git a/src/utils/twtstr.nim b/src/utils/twtstr.nim
index 8171010a..3744d9e2 100644
--- a/src/utils/twtstr.nim
+++ b/src/utils/twtstr.nim
@@ -291,6 +291,9 @@ func parseOctUInt32*(s: string; allowSign: static bool): Option[uint32] =
 func parseHexUInt32*(s: string; allowSign: static bool): Option[uint32] =
   return parseUIntImpl[uint32](s, allowSign, AsciiHexDigit, 16)
 
+func parseUInt64*(s: string; allowSign: static bool): Option[uint64] =
+  return parseUIntImpl[uint64](s, allowSign)
+
 #TODO not sure where this algorithm is from...
 # (probably from CSS)
 func parseFloat64*(s: string): float64 =
diff --git a/todo b/todo
index 32099bff..2c0be87d 100644
--- a/todo
+++ b/todo
@@ -75,16 +75,11 @@ layout engine:
 images:
 - more efficient kitty display (use IDs)
 - more efficient sixel display (store encoded images)
-- more efficient display in general (why are we repainting 3-4 times per
-  keypress?)
+- more efficient display in general (why are we repainting twice per keypress?)
 - document it (when performance is acceptable)
-- proper sixel color register allocation (current one is a hack)
+- proper sixel color register allocation, dithering
 - fix race condition where images decoded after buffer load won't display until
   reshape
-- remove in-buffer decoder; instead, decode images in fully locked down CGI
-  scripts
-	* then, the pager can just read the output from the cache on-demand
-	  instead of copying it from buffers
 - incremental decoding, interlaced images, animation
 man:
 - add a DOM -> man page converter so that we do not depend on pandoc