about summary refs log tree commit diff stats
diff options
context:
space:
mode:
-rw-r--r--Makefile37
-rw-r--r--adapter/format/gopher2html.nim19
-rw-r--r--adapter/gophertypes.nim19
-rw-r--r--adapter/protocol/curlerrors.nim17
-rw-r--r--adapter/protocol/curlwrap.nim (renamed from src/loader/curlwrap.nim)0
-rw-r--r--adapter/protocol/data.nim6
-rw-r--r--adapter/protocol/dirlist.nim (renamed from src/loader/dirlist.nim)0
-rw-r--r--adapter/protocol/file.nim11
-rw-r--r--adapter/protocol/ftp.nim8
-rw-r--r--adapter/protocol/gopher.nim28
-rw-r--r--adapter/protocol/http.nim128
-rw-r--r--doc/localcgi.md33
-rw-r--r--res/urimethodmap2
-rw-r--r--src/bindings/curl.nim3
-rw-r--r--src/css/cssparser.nim2
-rw-r--r--src/loader/cgi.nim32
-rw-r--r--src/loader/connecterror.nim37
-rw-r--r--src/loader/curlhandle.nim32
-rw-r--r--src/loader/http.nim137
-rw-r--r--src/loader/loader.nim130
-rw-r--r--src/loader/loaderhandle.nim4
-rw-r--r--src/server/buffer.nim12
-rw-r--r--src/types/blob.nim13
-rw-r--r--src/types/formdata.nim66
-rw-r--r--src/xhr/formdata.nim15
25 files changed, 433 insertions, 358 deletions
diff --git a/Makefile b/Makefile
index 1a86279c..2238170c 100644
--- a/Makefile
+++ b/Makefile
@@ -41,15 +41,15 @@ endif
 FLAGS += --nimcache:"$(OBJDIR)/$(TARGET)"
 
 .PHONY: all
-all: $(OUTDIR_BIN)/cha $(OUTDIR_LIBEXEC)/gopher2html $(OUTDIR_CGI_BIN)/gmifetch \
-	$(OUTDIR_LIBEXEC)/gmi2html $(OUTDIR_CGI_BIN)/cha-finger $(OUTDIR_CGI_BIN)/about \
-	$(OUTDIR_CGI_BIN)/data $(OUTDIR_CGI_BIN)/file $(OUTDIR_CGI_BIN)/ftp \
-	$(OUTDIR_CGI_BIN)/gopher
+all: $(OUTDIR_BIN)/cha $(OUTDIR_CGI_BIN)/http \
+	$(OUTDIR_CGI_BIN)/gmifetch $(OUTDIR_LIBEXEC)/gmi2html \
+	$(OUTDIR_CGI_BIN)/gopher $(OUTDIR_LIBEXEC)/gopher2html \
+	$(OUTDIR_CGI_BIN)/cha-finger $(OUTDIR_CGI_BIN)/about \
+	$(OUTDIR_CGI_BIN)/data $(OUTDIR_CGI_BIN)/file $(OUTDIR_CGI_BIN)/ftp
 
 $(OUTDIR_BIN)/cha: lib/libquickjs.a src/*.nim src/**/*.nim res/* res/**/*
 	@mkdir -p "$(OUTDIR)/$(TARGET)/bin"
-	$(NIMC) -d:curlLibName:$(CURLLIBNAME) -d:libexecPath=$(LIBEXECDIR) \
-		$(FLAGS) -o:"$(OUTDIR_BIN)/cha" src/main.nim
+	$(NIMC) -d:libexecPath=$(LIBEXECDIR) $(FLAGS) -o:"$(OUTDIR_BIN)/cha" src/main.nim
 	ln -sf "$(OUTDIR)/$(TARGET)/bin/cha" cha
 
 $(OUTDIR_LIBEXEC)/gopher2html: adapter/format/gopher2html.nim \
@@ -69,26 +69,34 @@ $(OUTDIR_CGI_BIN)/cha-finger: adapter/protocol/cha-finger
 	@mkdir -p $(OUTDIR_CGI_BIN)
 	cp adapter/protocol/cha-finger $(OUTDIR_CGI_BIN)
 
+$(OUTDIR_CGI_BIN)/http: adapter/protocol/http.nim adapter/protocol/curlwrap.nim \
+		adapter/protocol/curlerrors.nim src/bindings/curl.nim \
+		src/types/opt.nim src/utils/twtstr.nim
+	$(NIMC) $(FLAGS) -d:curlLibName:$(CURLLIBNAME) \
+		-o:"$(OUTDIR_CGI_BIN)/http" adapter/protocol/http.nim
+
 $(OUTDIR_CGI_BIN)/about: adapter/protocol/about.nim
 	$(NIMC) $(FLAGS) -o:"$(OUTDIR_CGI_BIN)/about" adapter/protocol/about.nim
 
 $(OUTDIR_CGI_BIN)/data: adapter/protocol/data.nim src/utils/twtstr.nim
 	$(NIMC) $(FLAGS) -o:"$(OUTDIR_CGI_BIN)/data" adapter/protocol/data.nim
 
-$(OUTDIR_CGI_BIN)/file: adapter/protocol/file.nim src/loader/dirlist.nim \
+$(OUTDIR_CGI_BIN)/file: adapter/protocol/file.nim adapter/protocol/dirlist.nim \
 		src/utils/twtstr.nim src/loader/connecterror.nim
 	$(NIMC) $(FLAGS) -o:"$(OUTDIR_CGI_BIN)/file" adapter/protocol/file.nim
 
 $(OUTDIR_CGI_BIN)/ftp: adapter/protocol/ftp.nim src/bindings/curl.nim \
-		src/loader/dirlist.nim src/utils/twtstr.nim src/types/url.nim \
+		adapter/protocol/dirlist.nim src/utils/twtstr.nim src/types/url.nim \
 		src/types/opt.nim src/loader/connecterror.nim
-	$(NIMC) $(FLAGS) -o:"$(OUTDIR_CGI_BIN)/ftp" adapter/protocol/ftp.nim
+	$(NIMC) $(FLAGS) -d:curlLibName:$(CURLLIBNAME) \
+		-o:"$(OUTDIR_CGI_BIN)/ftp" adapter/protocol/ftp.nim
 
-$(OUTDIR_CGI_BIN)/gopher: adapter/protocol/gopher.nim adapter/gophertypes.nim \
-		src/bindings/curl.nim src/loader/dirlist.nim \
-		src/utils/twtstr.nim src/types/url.nim src/types/opt.nim \
-		src/loader/connecterror.nim
-	$(NIMC) $(FLAGS) -o:"$(OUTDIR_CGI_BIN)/gopher" adapter/protocol/gopher.nim
+$(OUTDIR_CGI_BIN)/gopher: adapter/protocol/gopher.nim adapter/protocol/curlwrap.nim \
+		adapter/protocol/curlerrors.nim adapter/gophertypes.nim \
+		src/bindings/curl.nim src/loader/connecterror.nim \
+		src/utils/twtstr.nim src/types/url.nim src/types/opt.nim
+	$(NIMC) $(FLAGS) -d:curlLibName:$(CURLLIBNAME) \
+		-o:"$(OUTDIR_CGI_BIN)/gopher" adapter/protocol/gopher.nim
 
 CFLAGS = -g -Wall -O2 -DCONFIG_VERSION=\"$(shell cat lib/quickjs/VERSION)\"
 QJSOBJ = $(OBJDIR)/quickjs
@@ -165,6 +173,7 @@ uninstall:
 	@# intentionally not quoted
 	rm -f $(LIBEXECDIR_CHAWAN)/gopher2html
 	rm -f $(LIBEXECDIR_CHAWAN)/gmi2html
+	rm -f $(LIBEXECDIR_CHAWAN)/cgi-bin/http
 	rm -f $(LIBEXECDIR_CHAWAN)/cgi-bin/about
 	rm -f $(LIBEXECDIR_CHAWAN)/cgi-bin/data
 	rm -f $(LIBEXECDIR_CHAWAN)/cgi-bin/gmifetch
diff --git a/adapter/format/gopher2html.nim b/adapter/format/gopher2html.nim
index 33cd564a..1004cc2c 100644
--- a/adapter/format/gopher2html.nim
+++ b/adapter/format/gopher2html.nim
@@ -5,26 +5,9 @@ import std/os
 import std/streams
 import std/strutils
 
-import utils/twtstr
-
 include ../gophertypes
 
-func gopherType(c: char): GopherType =
-  return case c
-  of '0': TEXT_FILE
-  of '1': DIRECTORY
-  of '3': ERROR
-  of '5': DOS_BINARY
-  of '7': SEARCH
-  of 'm': MESSAGE
-  of 's': SOUND
-  of 'g': GIF
-  of 'h': HTML
-  of 'i': INFO
-  of 'I': IMAGE
-  of '9': BINARY
-  of 'p': PNG
-  else: UNKNOWN
+import utils/twtstr
 
 const ControlPercentEncodeSet = {char(0x00)..char(0x1F), char(0x7F)..char(0xFF)}
 const QueryPercentEncodeSet = (ControlPercentEncodeSet + {' ', '"', '#', '<', '>'})
diff --git a/adapter/gophertypes.nim b/adapter/gophertypes.nim
index dbc8d64d..a65b75fe 100644
--- a/adapter/gophertypes.nim
+++ b/adapter/gophertypes.nim
@@ -1,4 +1,4 @@
-type GopherType = enum
+type GopherType* = enum
   UNKNOWN = "unsupported"
   TEXT_FILE = "text file"
   ERROR = "error"
@@ -13,3 +13,20 @@ type GopherType = enum
   IMAGE = "image"
   BINARY = "binary"
   PNG = "png"
+
+func gopherType*(c: char): GopherType =
+  return case c
+  of '0': TEXT_FILE
+  of '1': DIRECTORY
+  of '3': ERROR
+  of '5': DOS_BINARY
+  of '7': SEARCH
+  of 'm': MESSAGE
+  of 's': SOUND
+  of 'g': GIF
+  of 'h': HTML
+  of 'i': INFO
+  of 'I': IMAGE
+  of '9': BINARY
+  of 'p': PNG
+  else: UNKNOWN
diff --git a/adapter/protocol/curlerrors.nim b/adapter/protocol/curlerrors.nim
new file mode 100644
index 00000000..3bb6cf6d
--- /dev/null
+++ b/adapter/protocol/curlerrors.nim
@@ -0,0 +1,17 @@
+import bindings/curl
+import loader/connecterror
+
+func curlErrorToChaError*(res: CURLcode): ConnectErrorCode =
+  return case res
+  of CURLE_OK: CONNECTION_SUCCESS
+  of CURLE_URL_MALFORMAT: ERROR_INVALID_URL #TODO should never occur...
+  of CURLE_COULDNT_CONNECT: ERROR_CONNECTION_REFUSED
+  of CURLE_COULDNT_RESOLVE_PROXY: ERROR_FAILED_TO_RESOLVE_PROXY
+  of CURLE_COULDNT_RESOLVE_HOST: ERROR_FAILED_TO_RESOLVE_HOST
+  of CURLE_PROXY: ERROR_PROXY_REFUSED_TO_CONNECT
+  else: ERROR_INTERNAL
+
+proc getCurlConnectionError*(res: CURLcode): string =
+  let e = $int(curlErrorToChaError(res))
+  let msg = $curl_easy_strerror(res)
+  return "Cha-Control: ConnectionError " & e & " " & msg & "\n"
diff --git a/src/loader/curlwrap.nim b/adapter/protocol/curlwrap.nim
index 7aef4182..7aef4182 100644
--- a/src/loader/curlwrap.nim
+++ b/adapter/protocol/curlwrap.nim
diff --git a/adapter/protocol/data.nim b/adapter/protocol/data.nim
index 7b022976..4f5da2e8 100644
--- a/adapter/protocol/data.nim
+++ b/adapter/protocol/data.nim
@@ -2,15 +2,17 @@ import std/envvars
 import std/base64
 import std/strutils
 
+import loader/connecterror
 import utils/twtstr
 
 proc main() =
   let str = getEnv("MAPPED_URI_PATH")
   const si = "data:".len # start index
+  const iu = $int(ERROR_INVALID_URL)
   var ct = str.until(',', si)
   for c in ct:
     if c notin AsciiAlphaNumeric and c != '/':
-      stdout.write("Cha-Control: ConnectionError -7 invalid data URL")
+      stdout.write("Cha-Control: ConnectionError " & iu  & " invalid data URL")
       return
   let sd = si + ct.len + 1 # data start
   let body = percentDecode(str, sd)
@@ -21,7 +23,7 @@ proc main() =
       stdout.write("Content-Type: " & ct & "\n\n")
       stdout.write(d)
     except ValueError:
-      stdout.write("Cha-Control: ConnectionError -7 invalid data URL")
+      stdout.write("Cha-Control: ConnectionError " & iu  & " invalid data URL")
   else:
     stdout.write("Content-Type: " & ct & "\n\n")
     stdout.write(body)
diff --git a/src/loader/dirlist.nim b/adapter/protocol/dirlist.nim
index 2328cd55..2328cd55 100644
--- a/src/loader/dirlist.nim
+++ b/adapter/protocol/dirlist.nim
diff --git a/adapter/protocol/file.nim b/adapter/protocol/file.nim
index f3ffa93e..168be58b 100644
--- a/adapter/protocol/file.nim
+++ b/adapter/protocol/file.nim
@@ -4,8 +4,9 @@ import std/streams
 import std/times
 import std/envvars
 
+import dirlist
+
 import loader/connecterror
-import loader/dirlist
 import utils/twtstr
 
 proc loadDir(path: string) =
@@ -84,14 +85,14 @@ proc loadFile(istream: Stream) =
   stdout.write("\n")
   let outs = newFileStream(stdout)
   while not istream.atEnd:
-    const bufferSize = 4096
-    var buffer {.noinit.}: array[bufferSize, char]
+    const BufferSize = 16384
+    var buffer {.noinit.}: array[BufferSize, char]
     while true:
-      let n = readData(istream, addr buffer[0], bufferSize)
+      let n = readData(istream, addr buffer[0], BufferSize)
       if n == 0:
         break
       outs.writeData(addr buffer[0], n)
-      if n < bufferSize:
+      if n < BufferSize:
         break
 
 proc main() =
diff --git a/adapter/protocol/ftp.nim b/adapter/protocol/ftp.nim
index ad4a51a3..7d072471 100644
--- a/adapter/protocol/ftp.nim
+++ b/adapter/protocol/ftp.nim
@@ -2,10 +2,12 @@ import std/envvars
 import std/options
 import std/strutils
 
+import curlerrors
+import curlwrap
+import dirlist
+
 import bindings/curl
 import loader/connecterror
-import loader/curlwrap
-import loader/dirlist
 import types/opt
 import types/url
 import utils/twtstr
@@ -185,7 +187,7 @@ proc main() =
   let res = curl_easy_perform(curl)
   if res != CURLE_OK:
     if not op.statusline:
-      stdout.write("Cha-Control: ConnectionError " & $int(res) & "\n")
+      stdout.write(getCurlConnectionError(res))
   elif op.dirmode:
     op.finish()
   curl_easy_cleanup(curl)
diff --git a/adapter/protocol/gopher.nim b/adapter/protocol/gopher.nim
index cf3038f7..91875a1e 100644
--- a/adapter/protocol/gopher.nim
+++ b/adapter/protocol/gopher.nim
@@ -1,38 +1,22 @@
 import std/envvars
 import std/options
 
+import curlwrap
+import curlerrors
+
+import ../gophertypes
+
 import bindings/curl
 import loader/connecterror
-import loader/curlwrap
-import loader/request
 import types/opt
 import types/url
 import utils/twtstr
 
-include ../gophertypes
-
 type GopherHandle = ref object
   curl: CURL
   t: GopherType
   statusline: bool
 
-func gopherType(c: char): GopherType =
-  return case c
-  of '0': TEXT_FILE
-  of '1': DIRECTORY
-  of '3': ERROR
-  of '5': DOS_BINARY
-  of '7': SEARCH
-  of 'm': MESSAGE
-  of 's': SOUND
-  of 'g': GIF
-  of 'h': HTML
-  of 'i': INFO
-  of 'I': IMAGE
-  of '9': BINARY
-  of 'p': PNG
-  else: UNKNOWN
-
 proc onStatusLine(op: GopherHandle) =
   var status: clong
   op.curl.getinfo(CURLINFO_RESPONSE_CODE, addr status)
@@ -107,7 +91,7 @@ proc main() =
     curl.setopt(CURLOPT_PROXY, proxy)
   let res = curl_easy_perform(curl)
   if res != CURLE_OK and not op.statusline:
-    stdout.write("Cha-Control: ConnectionError " & $int(res) & "\n")
+    stdout.write(getCurlConnectionError(res))
   curl_easy_cleanup(curl)
 
 main()
diff --git a/adapter/protocol/http.nim b/adapter/protocol/http.nim
new file mode 100644
index 00000000..10b0c060
--- /dev/null
+++ b/adapter/protocol/http.nim
@@ -0,0 +1,128 @@
+import std/envvars
+import std/options
+import std/strutils
+
+import curlerrors
+import curlwrap
+
+import bindings/curl
+import types/opt
+import utils/twtstr
+
+type
+  EarlyHintState = enum
+    NO_EARLY_HINT, EARLY_HINT_STARTED, EARLY_HINT_DONE
+
+  HttpHandle = ref object
+    curl: CURL
+    statusline: bool
+    connectreport: bool
+    earlyhint: EarlyHintState
+    slist: curl_slist
+
+proc curlWriteHeader(p: cstring, size, nitems: csize_t, userdata: pointer):
+    csize_t {.cdecl.} =
+  var line = newString(nitems)
+  if nitems > 0:
+    prepareMutation(line)
+    copyMem(addr line[0], p, nitems)
+
+  let op = cast[HttpHandle](userdata)
+  if not op.statusline:
+    op.statusline = true
+    var status: clong
+    op.curl.getinfo(CURLINFO_RESPONSE_CODE, addr status)
+    if status == 103 and op.earlyhint == NO_EARLY_HINT:
+      op.earlyhint = EARLY_HINT_STARTED
+    else:
+      op.connectreport = true
+      stdout.write("Status: " & $status & "\n")
+      stdout.write("Cha-Control: ControlDone\n")
+    return nitems
+
+  if line == "":
+    # empty line (last, before body)
+    if op.earlyhint == EARLY_HINT_STARTED:
+      # ignore; we do not have a way to stream headers yet.
+      op.earlyhint = EARLY_HINT_DONE
+      # reset statusline; we are awaiting the next line.
+      op.statusline = false
+      return nitems
+    return nitems
+
+  if op.earlyhint != EARLY_HINT_STARTED:
+    # Regrettably, we can only write early hint headers after the status
+    # code is already known.
+    # For now, it seems easiest to just ignore them all.
+    stdout.write(line)
+  return nitems
+
+# From the documentation: size is always 1.
+proc curlWriteBody(p: cstring, size, nmemb: csize_t, userdata: pointer):
+    csize_t {.cdecl.} =
+  return csize_t(stdout.writeBuffer(p, int(nmemb)))
+
+# From the documentation: size is always 1.
+proc readFromStdin(buffer: cstring, size, nitems: csize_t, userdata: pointer):
+    csize_t {.cdecl.} =
+  return csize_t(stdin.readBuffer(buffer, nitems))
+
+proc curlPreRequest(clientp: pointer, conn_primary_ip, conn_local_ip: cstring,
+    conn_primary_port, conn_local_port: cint): cint {.cdecl.} =
+  let op = cast[HttpHandle](clientp)
+  op.connectreport = true
+  stdout.write("Cha-Control: Connected\n")
+  return 0 # ok
+
+proc main() =
+  let curl = curl_easy_init()
+  doAssert curl != nil
+  let surl = getEnv("QUERY_STRING")
+  curl.setopt(CURLOPT_URL, surl)
+  let op = HttpHandle(curl: curl)
+  curl.setopt(CURLOPT_WRITEFUNCTION, curlWriteBody)
+  curl.setopt(CURLOPT_HEADERDATA, op)
+  curl.setopt(CURLOPT_HEADERFUNCTION, curlWriteHeader)
+  curl.setopt(CURLOPT_PREREQDATA, op)
+  curl.setopt(CURLOPT_PREREQFUNCTION, curlPreRequest)
+  let proxy = getEnv("ALL_PROXY")
+  if proxy != "":
+    curl.setopt(CURLOPT_PROXY, proxy)
+  case getEnv("REQUEST_METHOD")
+  of "GET":
+    curl.setopt(CURLOPT_HTTPGET, 1)
+  of "POST":
+    curl.setopt(CURLOPT_POST, 1)
+    let len = parseInt64(getEnv("CONTENT_LENGTH")).get
+    # > For any given platform/compiler curl_off_t must be typedef'ed to
+    # a 64-bit
+    # > wide signed integral data type. The width of this data type must remain
+    # > constant and independent of any possible large file support settings.
+    # >
+    # > As an exception to the above, curl_off_t shall be typedef'ed to
+    # a 32-bit
+    # > wide signed integral data type if there is no 64-bit type.
+    # It seems safe to assume that if the platform has no uint64 then Nim won't
+    # compile either. In return, we are allowed to post >2G of data.
+    curl.setopt(CURLOPT_POSTFIELDSIZE_LARGE, uint64(len))
+    curl.setopt(CURLOPT_READFUNCTION, readFromStdin)
+  else: discard #TODO
+  let headers = getEnv("REQUEST_HEADERS")
+  for line in headers.split("\r\n"):
+    if line.startsWithNoCase("Accept-Encoding: "):
+      let s = line.after(' ')
+      # From the CURLOPT_ACCEPT_ENCODING manpage:
+      # > The application does not have to keep the string around after
+      # > setting this option.
+      curl.setopt(CURLOPT_ACCEPT_ENCODING, cstring(s))
+    # This is OK, because curl_slist_append strdup's line.
+    op.slist = curl_slist_append(op.slist, cstring(line))
+  if op.slist != nil:
+    curl.setopt(CURLOPT_HTTPHEADER, op.slist)
+  let res = curl_easy_perform(curl)
+  if res != CURLE_OK and not op.connectreport:
+    stdout.write(getCurlConnectionError(res))
+    op.connectreport = true
+  curl_easy_cleanup(curl)
+
+main()
diff --git a/doc/localcgi.md b/doc/localcgi.md
index 56927634..af8e5288 100644
--- a/doc/localcgi.md
+++ b/doc/localcgi.md
@@ -65,7 +65,9 @@ Currently available commands are:
   sent before that.
 * `ConnectionError`: Must be the first reported header. Parameter 1 is the
   error code, see below. If any following parameters are given, they are
-  concatenated to form a custom error message. TODO implement this
+  concatenated to form a custom error message.  
+  Note: short but descriptive error messages are preferred, messages that
+  do not fit on the screen are currently truncated. (TODO fix this somehow :P)
 * `ControlDone`: Signals that no more special headers will be sent; this
   means that `Cha-Control` and `Status` headers sent after this must be
   interpreted as regular headers (and thus e.g. will be available for JS
@@ -74,7 +76,25 @@ Currently available commands are:
   take external input. For example, a HTTP client would have to send
   `Cha-Control: ControlDone` before returning the retrieved headers.
 
-TODO insert list of public error codes here
+List of public error codes:
+
+* `1 internal error`: An internal error prevented the script from retrieving
+  the requested resource. CGI scripts can also use this to signal that they
+  have no information on what went wrong.
+* `2 invalid method`: The client requested data using a method not supported
+  by this protocol.
+* `3 invalid URL`: The request URL could not be interpreted as a valid URL
+  for this format.
+* `4 file not found`: No file was found at the requested address, and thus
+  the request is meaningless. Note: this should only be used by protocols
+  that do not rely on a client-server architecture, e.g. local file access,
+  local databases, or peer-to-peer file retrieval mechanisms. A server
+  responding with "no file found" is NOT a connection error, and is better
+  represented as a response with a 404 status code.
+* `5 failed to resolve host`: The hostname could not be resolved.
+* `6 failed to resolve proxy`: The proxy could not be resolved.
+* `7 connection refused`: The server refused to establish a connection.
+* `8 proxy refused to connect`: The proxy refused to establish a connection.
 
 ## Environment variables
 
@@ -97,12 +117,15 @@ Chawan sets the following environment variables:
   variable is NOT percent-encoded.
 * `REQUEST_URI="$SCRIPT_NAME/$PATH_INFO?$QUERY_STRING`
 * `REQUEST_METHOD=` HTTP method used for making the request, e.g. GET or POST
+* `REQUEST_HEADERS=` A newline-separated list of all headers for this request.
 * `CONTENT_TYPE=` for POST requests, the Content-Type header. Not set for
   other request types (e.g. GET).
 * `CONTENT_LENGTH=` the content length, if $CONTENT_TYPE has been set.
-* `HTTP_PROXY=` and (lower case) `http_proxy=`: the proxy URL if a proxy
-  has been set and its scheme is either `http` or `https`.
-* `ALL_PROXY=` if a proxy has been set, the proxy URL.
+* `ALL_PROXY=` if a proxy has been set, the proxy URL. WARNING: for security
+  reasons, this MUST be respected when making external connections. If a
+  CGI script does not support proxies, it must never make any external
+  connections when the `ALL_PROXY` variable is set, even if this results in it
+  returning an error.
 * `HTTP_COOKIE=` if set, the Cookie header.
 * `HTTP_REFERER=` if set, the Referer header.
 
diff --git a/res/urimethodmap b/res/urimethodmap
index bba78e82..21f02533 100644
--- a/res/urimethodmap
+++ b/res/urimethodmap
@@ -1,5 +1,7 @@
 # Default urimethodmap file for Chawan.
 
+http:		cgi-bin:http?%s
+https:		cgi-bin:http?%s
 finger:		cgi-bin:cha-finger
 gemini:		cgi-bin:gmifetch?%s
 about:		cgi-bin:about
diff --git a/src/bindings/curl.nim b/src/bindings/curl.nim
index cd9e409a..a36a3fb7 100644
--- a/src/bindings/curl.nim
+++ b/src/bindings/curl.nim
@@ -95,15 +95,18 @@ type
     CURLOPT_HEADERDATA = CURLOPTTYPE_CBPOINT + 29
     CURLOPT_ACCEPT_ENCODING = CURLOPTTYPE_STRINGPOINT + 102
     CURLOPT_MIMEPOST = CURLOPTTYPE_OBJECTPOINT + 269
+    CURLOPT_PREREQDATA = CURLOPTTYPE_CBPOINT + 313
 
     # Functionpoint
     CURLOPT_WRITEFUNCTION = CURLOPTTYPE_FUNCTIONPOINT + 11
     CURLOPT_READFUNCTION = CURLOPTTYPE_FUNCTIONPOINT + 12
     CURLOPT_HEADERFUNCTION = CURLOPTTYPE_FUNCTIONPOINT + 79
+    CURLOPT_PREREQFUNCTION = CURLOPTTYPE_FUNCTIONPOINT + 312
 
     # Off-t
     CURLOPT_INFILESIZE_LARGE = CURLOPTTYPE_OFF_T + 115
     CURLOPT_RESUME_FROM_LARGE = CURLOPTTYPE_OFF_T + 116
+    CURLOPT_POSTFIELDSIZE_LARGE = CURLOPTTYPE_OFF_T + 120
 
     # Blob
     CURLOPT_SSLCERT_BLOB = CURLOPTTYPE_BLOB + 291
diff --git a/src/css/cssparser.nim b/src/css/cssparser.nim
index a8081ecc..a825e94d 100644
--- a/src/css/cssparser.nim
+++ b/src/css/cssparser.nim
@@ -248,7 +248,7 @@ proc consumeEscape(state: var CSSTokenizerState): string =
       num *= 0x10
       num += hexValue(c)
       inc i
-    if state.peek() in AsciiWhitespace:
+    if state.has() and state.peek() in AsciiWhitespace:
       discard state.consume()
     if num == 0 or num > 0x10FFFF or num in 0xD800..0xDFFF:
       return $Rune(0xFFFD)
diff --git a/src/loader/cgi.nim b/src/loader/cgi.nim
index 89361a7c..8fe96274 100644
--- a/src/loader/cgi.nim
+++ b/src/loader/cgi.nim
@@ -10,6 +10,7 @@ import loader/connecterror
 import loader/headers
 import loader/loaderhandle
 import loader/request
+import types/formdata
 import types/opt
 import types/url
 import utils/twtstr
@@ -37,6 +38,10 @@ proc setupEnv(cmd, scriptName, pathInfo, requestURI: string, request: Request,
   putEnv("SCRIPT_FILENAME", cmd)
   putEnv("REQUEST_URI", requestURI)
   putEnv("REQUEST_METHOD", $request.httpmethod)
+  var headers = ""
+  for k, v in request.headers:
+    headers &= k & ": " & v & "\r\n"
+  putEnv("REQUEST_HEADERS", headers)
   if prevURL != nil:
     putMappedURL(prevURL)
   if pathInfo != "":
@@ -44,19 +49,17 @@ proc setupEnv(cmd, scriptName, pathInfo, requestURI: string, request: Request,
   if url.query.isSome:
     putEnv("QUERY_STRING", url.query.get)
   if request.httpmethod == HTTP_POST:
-    putEnv("CONTENT_TYPE", request.headers.getOrDefault("Content-Type", ""))
+    if request.multipart.isSome:
+      putEnv("CONTENT_TYPE", request.multipart.get.getContentType())
+    else:
+      putEnv("CONTENT_TYPE", request.headers.getOrDefault("Content-Type", ""))
     putEnv("CONTENT_LENGTH", $contentLen)
   if "Cookie" in request.headers:
     putEnv("HTTP_COOKIE", request.headers["Cookie"])
   if request.referer != nil:
     putEnv("HTTP_REFERER", $request.referer)
   if request.proxy != nil:
-    let s = $request.proxy
-    if request.proxy.scheme == "https" or request.proxy.scheme == "http":
-      putEnv("http_proxy", s)
-      putEnv("HTTP_PROXY", s)
-      putEnv("HTTPS_proxy", s)
-    putEnv("ALL_PROXY", s)
+    putEnv("ALL_PROXY", $request.proxy)
 
 type ControlResult = enum
   RESULT_CONTROL_DONE, RESULT_CONTROL_CONTINUE, RESULT_ERROR
@@ -184,9 +187,7 @@ proc loadCGI*(handle: LoaderHandle, request: Request, cgiDir: seq[string],
   if request.body.isSome:
     contentLen = request.body.get.len
   elif request.multipart.isSome:
-    #TODO multipart
-    # maybe use curl formdata? (the mime api has no serialization functions)
-    discard
+    contentLen = request.multipart.get.calcLength()
   let pid = fork()
   if pid == -1:
     t handle.sendResult(ERROR_FAIL_SETUP_CGI)
@@ -214,8 +215,9 @@ proc loadCGI*(handle: LoaderHandle, request: Request, cgiDir: seq[string],
       if request.body.isSome:
         ps.write(request.body.get)
       elif request.multipart.isSome:
-        #TODO
-        discard
+        let multipart = request.multipart.get
+        for entry in multipart.entries:
+          ps.writeEntry(entry, multipart.boundary)
       ps.close()
     let ps = newPosixStream(pipefd[0])
     let headers = newHeaders()
@@ -249,8 +251,4 @@ proc loadCGI*(handle: LoaderHandle, request: Request, cgiDir: seq[string],
           handle.handleLine(line, headers)
     t handle.sendStatus(status)
     t handle.sendHeaders(headers)
-    var buffer: array[4096, uint8]
-    while not ps.atEnd:
-      let n = ps.readData(addr buffer[0], buffer.len)
-      t handle.sendData(addr buffer[0], n)
-    ps.close()
+    handle.istream = ps
diff --git a/src/loader/connecterror.nim b/src/loader/connecterror.nim
index 913e007f..acd4a28f 100644
--- a/src/loader/connecterror.nim
+++ b/src/loader/connecterror.nim
@@ -1,28 +1,31 @@
-import bindings/curl
-
 type ConnectErrorCode* = enum
-  ERROR_CGI_NO_DATA = (-17, "CGI script returned no data")
-  ERROR_CGI_MALFORMED_HEADER = (-16, "CGI script returned a malformed header")
-  ERROR_CGI_INVALID_CHA_CONTROL = (-15, "CGI got invalid Cha-Control header")
-  ERROR_TOO_MANY_REWRITES = (-14, "too many URI method map rewrites")
-  ERROR_INVALID_URI_METHOD_ENTRY = (-13, "invalid URI method entry")
-  ERROR_CGI_FILE_NOT_FOUND = (-12, "CGI file not found")
-  ERROR_INVALID_CGI_PATH = (-11, "invalid CGI path")
-  ERROR_FAIL_SETUP_CGI = (-10, "failed to set up CGI script")
-  ERROR_NO_CGI_DIR = (-9, "no local-CGI directory configured")
-  ERROR_INVALID_METHOD = (-8, "invalid method")
-  ERROR_INVALID_URL = (-7, "invalid URL")
-  ERROR_CONNECTION_REFUSED = (-6, "connection refused")
-  ERROR_FILE_NOT_FOUND = (-5, "file not found")
+  ERROR_CGI_NO_DATA = (-13, "CGI script returned no data")
+  ERROR_CGI_MALFORMED_HEADER = (-12, "CGI script returned a malformed header")
+  ERROR_CGI_INVALID_CHA_CONTROL = (-11, "CGI got invalid Cha-Control header")
+  ERROR_TOO_MANY_REWRITES = (-10, "too many URI method map rewrites")
+  ERROR_INVALID_URI_METHOD_ENTRY = (-9, "invalid URI method entry")
+  ERROR_CGI_FILE_NOT_FOUND = (-8, "CGI file not found")
+  ERROR_INVALID_CGI_PATH = (-7, "invalid CGI path")
+  ERROR_FAIL_SETUP_CGI = (-6, "failed to set up CGI script")
+  ERROR_NO_CGI_DIR = (-5, "no local-CGI directory configured")
   ERROR_SOURCE_NOT_FOUND = (-4, "clone source could not be found")
   ERROR_LOADER_KILLED = (-3, "loader killed during transfer")
   ERROR_DISALLOWED_URL = (-2, "url not allowed by filter")
   ERROR_UNKNOWN_SCHEME = (-1, "unknown scheme")
+  CONNECTION_SUCCESS = (0, "connection successful")
+  ERROR_INTERNAL = (1, "internal error")
+  ERROR_INVALID_METHOD = (2, "invalid method")
+  ERROR_INVALID_URL = (3, "invalid URL")
+  ERROR_FILE_NOT_FOUND = (4, "file not found")
+  ERROR_CONNECTION_REFUSED = (5, "connection refused")
+  ERROR_PROXY_REFUSED_TO_CONNECT = (6, "proxy refused to connect")
+  ERROR_FAILED_TO_RESOLVE_HOST = (7, "failed to resolve host")
+  ERROR_FAILED_TO_RESOLVE_PROXY = (8, "failed to resolve proxy")
 
 converter toInt*(code: ConnectErrorCode): int =
   return int(code)
 
 func getLoaderErrorMessage*(code: int): string =
-  if code < 0:
+  if code in int(ConnectErrorCode.low)..int(ConnectErrorCode.high):
     return $ConnectErrorCode(code)
-  return $curl_easy_strerror(CURLcode(cint(code)))
+  return "unexpected error code " & $code
diff --git a/src/loader/curlhandle.nim b/src/loader/curlhandle.nim
deleted file mode 100644
index 3c69c6c0..00000000
--- a/src/loader/curlhandle.nim
+++ /dev/null
@@ -1,32 +0,0 @@
-import bindings/curl
-import loader/headers
-import loader/loaderhandle
-import loader/request
-
-type
-  CurlHandle* = ref object of RootObj
-    curl*: CURL
-    statusline*: bool
-    headers*: Headers
-    request*: Request
-    handle*: LoaderHandle
-    mime*: curl_mime
-    slist*: curl_slist
-    finish*: proc(handle: CurlHandle)
-
-func newCurlHandle*(curl: CURL, request: Request, handle: LoaderHandle):
-    CurlHandle =
-  return CurlHandle(
-    headers: newHeaders(),
-    curl: curl,
-    handle: handle,
-    request: request
-  )
-
-proc cleanup*(handleData: CurlHandle) =
-  handleData.handle.close()
-  if handleData.mime != nil:
-    curl_mime_free(handleData.mime)
-  if handleData.slist != nil:
-    curl_slist_free_all(handleData.slist)
-  curl_easy_cleanup(handleData.curl)
diff --git a/src/loader/http.nim b/src/loader/http.nim
deleted file mode 100644
index d7bc3a8f..00000000
--- a/src/loader/http.nim
+++ /dev/null
@@ -1,137 +0,0 @@
-import options
-import strutils
-
-import bindings/curl
-import loader/curlhandle
-import loader/curlwrap
-import loader/headers
-import loader/loaderhandle
-import loader/request
-import types/blob
-import types/formdata
-import types/opt
-import types/url
-import utils/twtstr
-
-type
-  EarlyHintState = enum
-    NO_EARLY_HINT, EARLY_HINT_STARTED, EARLY_HINT_DONE
-
-  HttpHandle = ref object of CurlHandle
-    earlyhint: EarlyHintState
-
-func newHttpHandle(curl: CURL, request: Request, handle: LoaderHandle):
-    HttpHandle =
-  return HttpHandle(
-    headers: newHeaders(),
-    curl: curl,
-    handle: handle,
-    request: request
-  )
-
-proc curlWriteHeader(p: cstring, size: csize_t, nitems: csize_t,
-    userdata: pointer): csize_t {.cdecl.} =
-  var line = newString(nitems)
-  if nitems > 0:
-    prepareMutation(line)
-    copyMem(addr line[0], p, nitems)
-
-  let op = cast[HttpHandle](userdata)
-  if not op.statusline:
-    op.statusline = true
-    if op.earlyhint == NO_EARLY_HINT:
-      if not op.handle.sendResult(int(CURLE_OK)):
-        return 0
-    var status: clong
-    op.curl.getinfo(CURLINFO_RESPONSE_CODE, addr status)
-    if status == 103 and op.earlyhint == NO_EARLY_HINT:
-      op.earlyhint = EARLY_HINT_STARTED
-    else:
-      if not op.handle.sendStatus(cast[int](status)):
-        return 0
-    return nitems
-
-  let k = line.until(':')
-
-  if k.len == line.len:
-    # empty line (last, before body) or invalid (=> error)
-    if op.earlyhint == EARLY_HINT_STARTED:
-      # ignore; we do not have a way to stream headers yet.
-      op.earlyhint = EARLY_HINT_DONE
-      # reset statusline; we are awaiting the next line.
-      op.statusline = false
-      return nitems
-    if not op.handle.sendHeaders(op.headers):
-      return 0
-    return nitems
-
-  let v = line.substr(k.len + 1).strip()
-  op.headers.add(k, v)
-  return nitems
-
-# From the documentation: size is always 1.
-proc curlWriteBody(p: cstring, size: csize_t, nmemb: csize_t,
-    userdata: pointer): csize_t {.cdecl.} =
-  let handleData = cast[HttpHandle](userdata)
-  if nmemb > 0:
-    if not handleData.handle.sendData(p, int(nmemb)):
-      return 0
-  return nmemb
-
-proc applyPostBody(curl: CURL, request: Request, handleData: HttpHandle) =
-  if request.multipart.isOk:
-    handleData.mime = curl_mime_init(curl)
-    doAssert handleData.mime != nil
-    for entry in request.multipart.get:
-      let part = curl_mime_addpart(handleData.mime)
-      doAssert part != nil
-      curl_mime_name(part, cstring(entry.name))
-      if entry.isstr:
-        curl_mime_data(part, cstring(entry.svalue), csize_t(entry.svalue.len))
-      else:
-        let blob = entry.value
-        if blob.isfile: #TODO ?
-          curl_mime_filedata(part, cstring(WebFile(blob).path))
-        else:
-          curl_mime_data(part, blob.buffer, csize_t(blob.size))
-        # may be overridden by curl_mime_filedata, so set it here
-        curl_mime_filename(part, cstring(entry.filename))
-    curl.setopt(CURLOPT_MIMEPOST, handleData.mime)
-  elif request.body.issome:
-    curl.setopt(CURLOPT_POSTFIELDS, cstring(request.body.get))
-    curl.setopt(CURLOPT_POSTFIELDSIZE, request.body.get.len)
-
-proc loadHttp*(handle: LoaderHandle, curlm: CURLM,
-    request: Request): HttpHandle =
-  let curl = curl_easy_init()
-  doAssert curl != nil
-  let surl = request.url.serialize()
-  curl.setopt(CURLOPT_URL, surl)
-  let handleData = curl.newHttpHandle(request, handle)
-  curl.setopt(CURLOPT_WRITEDATA, handleData)
-  curl.setopt(CURLOPT_WRITEFUNCTION, curlWriteBody)
-  curl.setopt(CURLOPT_HEADERDATA, handleData)
-  curl.setopt(CURLOPT_HEADERFUNCTION, curlWriteHeader)
-  if "Accept-Encoding" in request.headers:
-    let s = request.headers["Accept-Encoding"]
-    curl.setopt(CURLOPT_ACCEPT_ENCODING, cstring(s))
-  if request.proxy != nil:
-    let purl = request.proxy.serialize()
-    curl.setopt(CURLOPT_PROXY, purl)
-  case request.httpmethod
-  of HTTP_GET:
-    curl.setopt(CURLOPT_HTTPGET, 1)
-  of HTTP_POST:
-    curl.setopt(CURLOPT_POST, 1)
-    curl.applyPostBody(request, handleData)
-  else: discard #TODO
-  for k, v in request.headers:
-    let header = k & ": " & v
-    handleData.slist = curl_slist_append(handleData.slist, cstring(header))
-  if handleData.slist != nil:
-    curl.setopt(CURLOPT_HTTPHEADER, handleData.slist)
-  let res = curl_multi_add_handle(curlm, curl)
-  if res != CURLM_OK:
-    discard handle.sendResult(int(res))
-    return nil
-  return handleData
diff --git a/src/loader/loader.nim b/src/loader/loader.nim
index 2116a403..acc7817a 100644
--- a/src/loader/loader.nim
+++ b/src/loader/loader.nim
@@ -11,15 +11,15 @@
 #
 # The body is passed to the stream as-is, so effectively nothing can follow it.
 
-import nativesockets
-import net
-import options
-import posix
-import streams
-import strutils
-import tables
-
-import bindings/curl
+import std/nativesockets
+import std/net
+import std/options
+import std/posix
+import std/selectors
+import std/streams
+import std/strutils
+import std/tables
+
 import io/posixstream
 import io/promise
 import io/serialize
@@ -30,9 +30,7 @@ import js/error
 import js/javascript
 import loader/cgi
 import loader/connecterror
-import loader/curlhandle
 import loader/headers
-import loader/http
 import loader/loaderhandle
 import loader/request
 import loader/response
@@ -81,12 +79,11 @@ type
     refcount: int
     ssock: ServerSocket
     alive: bool
-    curlm: CURLM
     config: LoaderConfig
-    extra_fds: seq[curl_waitfd]
-    handleList: seq[CurlHandle]
     handleMap: Table[int, LoaderHandle]
     referrerpolicy: ReferrerPolicy
+    selector: Selector[int]
+    fd: int
 
   LoaderConfig* = object
     defaultheaders*: Headers
@@ -102,12 +99,6 @@ type
 
   FetchPromise* = Promise[JSResult[Response]]
 
-proc addFd(ctx: LoaderContext, fd: int, flags: int) =
-  ctx.extra_fds.add(curl_waitfd(
-    fd: cast[cint](fd),
-    events: cast[cshort](flags)
-  ))
-
 #TODO this may be too low if we want to use urimethodmap for everything
 const MaxRewrites = 4
 
@@ -134,14 +125,18 @@ proc loadResource(ctx: LoaderContext, request: Request, handle: LoaderHandle) =
           inc tries
           redo = true
           continue
-    case request.url.scheme
-    of "http", "https":
-      let handleData = handle.loadHttp(ctx.curlm, request)
-      if handleData != nil:
-        ctx.handleList.add(handleData)
-    of "cgi-bin":
+    if request.url.scheme == "cgi-bin":
       handle.loadCGI(request, ctx.config.cgiDir, prevurl)
-      handle.close()
+      if handle.istream == nil:
+        handle.close()
+      else:
+        let fd = handle.istream.fd
+        ctx.selector.registerHandle(fd, {Read}, 0)
+        let ofl = fcntl(fd, F_GETFL, 0)
+        discard fcntl(fd, F_SETFL, ofl or O_NONBLOCK)
+        # yes, this puts the istream fd in addition to the ostream fd in
+        # handlemap to point to the same ref
+        ctx.handleMap[fd] = handle
     else:
       prevurl = request.url
       case ctx.config.urimethodmap.findAndRewrite(request.url)
@@ -234,39 +229,24 @@ proc acceptConnection(ctx: LoaderContext) =
     # (TODO: this is probably not a very good idea.)
     stream.close()
 
-proc finishCurlTransfer(ctx: LoaderContext, handleData: CurlHandle, res: int) =
-  if res != int(CURLE_OK):
-    discard handleData.handle.sendResult(int(res))
-  if handleData.finish != nil:
-    handleData.finish(handleData)
-  discard curl_multi_remove_handle(ctx.curlm, handleData.curl)
-  handleData.cleanup()
-
 proc exitLoader(ctx: LoaderContext) =
-  for handleData in ctx.handleList:
-    ctx.finishCurlTransfer(handleData, ERROR_LOADER_KILLED)
-  discard curl_multi_cleanup(ctx.curlm)
-  curl_global_cleanup()
   ctx.ssock.close()
   quit(0)
 
 var gctx: LoaderContext
 proc initLoaderContext(fd: cint, config: LoaderConfig): LoaderContext =
-  if curl_global_init(CURL_GLOBAL_ALL) != CURLE_OK:
-    raise newException(Defect, "Failed to initialize libcurl.")
-  let curlm = curl_multi_init()
-  if curlm == nil:
-    raise newException(Defect, "Failed to initialize multi handle.")
   var ctx = LoaderContext(
     alive: true,
-    curlm: curlm,
     config: config,
-    refcount: 1
+    refcount: 1,
+    selector: newSelector[int]()
   )
   gctx = ctx
   #TODO ideally, buffered would be true. Unfortunately this conflicts with
   # sendFileHandle/recvFileHandle.
   ctx.ssock = initServerSocket(buffered = false)
+  ctx.fd = int(ctx.ssock.sock.getFd())
+  ctx.selector.registerHandle(ctx.fd, {Read}, 0)
   # The server has been initialized, so the main process can resume execution.
   var writef: File
   if not open(writef, FileHandle(fd), fmWrite):
@@ -278,7 +258,6 @@ proc initLoaderContext(fd: cint, config: LoaderConfig): LoaderContext =
   onSignal SIGTERM, SIGINT:
     discard sig
     gctx.exitLoader()
-  ctx.addFd(int(ctx.ssock.sock.getFd()), CURL_WAIT_POLLIN)
   for dir in ctx.config.cgiDir.mitems:
     if dir.len > 0 and dir[^1] != '/':
       dir &= '/'
@@ -286,31 +265,40 @@ proc initLoaderContext(fd: cint, config: LoaderConfig): LoaderContext =
 
 proc runFileLoader*(fd: cint, config: LoaderConfig) =
   var ctx = initLoaderContext(fd, config)
+  var buffer {.noInit.}: array[16384, uint8]
   while ctx.alive:
-    var numfds: cint = 0
-    #TODO do not discard
-    discard curl_multi_poll(ctx.curlm, addr ctx.extra_fds[0],
-      cuint(ctx.extra_fds.len), 30_000, addr numfds)
-    discard curl_multi_perform(ctx.curlm, addr numfds)
-    for extra_fd in ctx.extra_fds.mitems:
-      # For now, this is always ssock.sock.getFd().
-      if extra_fd.events == extra_fd.revents:
-        ctx.acceptConnection()
-        extra_fd.revents = 0
-    var msgs_left: cint = 1
-    while msgs_left > 0:
-      let msg = curl_multi_info_read(ctx.curlm, addr msgs_left)
-      if msg == nil:
-        break
-      if msg.msg == CURLMSG_DONE: # the only possible value atm
-        var idx = -1
-        for i in 0 ..< ctx.handleList.len:
-          if ctx.handleList[i].curl == msg.easy_handle:
-            idx = i
-            break
-        assert idx != -1
-        ctx.finishCurlTransfer(ctx.handleList[idx], int(msg.data.result))
-        ctx.handleList.del(idx)
+    let events = ctx.selector.select(-1)
+    var unreg: seq[int]
+    for event in events:
+      if Read in event.events:
+        if event.fd == ctx.fd: # incoming connection
+          ctx.acceptConnection()
+        else:
+          let handle = ctx.handleMap[event.fd]
+          while not handle.istream.atEnd:
+            try:
+              let n = handle.istream.readData(addr buffer[0], buffer.len)
+              if not handle.sendData(addr buffer[0], n):
+                unreg.add(event.fd)
+                break
+            except ErrorAgain, ErrorWouldBlock:
+              break
+      if Error in event.events:
+        assert event.fd != ctx.fd
+        when defined(debug):
+          # sanity check
+          let handle = ctx.handleMap[event.fd]
+          if not handle.istream.atEnd():
+            let n = handle.istream.readData(addr buffer[0], buffer.len)
+            assert n == 0
+            assert handle.istream.atEnd()
+        unreg.add(event.fd)
+    for fd in unreg:
+      ctx.selector.unregister(fd)
+      let handle = ctx.handleMap[fd]
+      ctx.handleMap.del(fd)
+      ctx.handleMap.del(handle.getFd())
+      handle.close()
   ctx.exitLoader()
 
 proc getAttribute(contentType, attrname: string): string =
diff --git a/src/loader/loaderhandle.nim b/src/loader/loaderhandle.nim
index d8d01bb2..93367607 100644
--- a/src/loader/loaderhandle.nim
+++ b/src/loader/loaderhandle.nim
@@ -9,6 +9,8 @@ import loader/headers
 
 type LoaderHandle* = ref object
   ostream: Stream
+  # Stream for taking input
+  istream*: PosixStream
   # Only the first handle can be redirected, because a) mailcap can only
   # redirect the first handle and b) async redirects would result in race
   # conditions that would be difficult to untangle.
@@ -100,3 +102,5 @@ proc close*(handle: LoaderHandle) =
       discard
     handle.sostream.close()
   handle.ostream.close()
+  if handle.istream != nil:
+    handle.istream.close()
diff --git a/src/server/buffer.nim b/src/server/buffer.nim
index cdbfc16b..77404c7c 100644
--- a/src/server/buffer.nim
+++ b/src/server/buffer.nim
@@ -315,7 +315,7 @@ func getClickable(styledNode: StyledNode): Element =
       return Element(styledNode.node)
     styledNode = stylednode.parent
 
-func submitForm(form: HTMLFormElement, submitter: Element): Option[Request]
+proc submitForm(form: HTMLFormElement, submitter: Element): Option[Request]
 
 func canSubmitOnClick(fae: FormAssociatedElement): bool =
   if fae.form == nil:
@@ -330,7 +330,7 @@ func canSubmitOnClick(fae: FormAssociatedElement): bool =
       return true
   return false
 
-func getClickHover(styledNode: StyledNode): string =
+proc getClickHover(styledNode: StyledNode): string =
   let clickable = styledNode.getClickable()
   if clickable != nil:
     case clickable.tagType
@@ -1084,7 +1084,7 @@ proc dispatchEvent(buffer: Buffer, ctype: string, elem: Element): tuple[
       break
   return (called, canceled)
 
-const BufferSize = 4096
+const BufferSize = 16384
 
 proc finishLoad(buffer: Buffer): EmptyPromise =
   if buffer.state != LOADING_PAGE:
@@ -1148,7 +1148,7 @@ proc onload(buffer: Buffer) =
   of LOADING_PAGE:
     discard
   let op = buffer.sstream.getPosition()
-  var s = newSeqUninitialized[uint8](buffer.readbufsize)
+  var s {.noInit.}: array[16384, uint8]
   try:
     buffer.sstream.setPosition(op + buffer.available)
     let n = buffer.istream.readData(addr s[0], buffer.readbufsize)
@@ -1222,7 +1222,7 @@ proc serializePlainTextFormData(kvs: seq[(string, string)]): string =
     result &= "\r\n"
 
 # https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#form-submission-algorithm
-func submitForm(form: HTMLFormElement, submitter: Element): Option[Request] =
+proc submitForm(form: HTMLFormElement, submitter: Element): Option[Request] =
   if form.constructingEntryList:
     return
   let entrylist = form.constructEntryList(submitter).get(@[])
@@ -1352,7 +1352,7 @@ type ReadSuccessResult* = object
   open*: Option[Request]
   repaint*: bool
 
-func implicitSubmit(input: HTMLInputElement): Option[Request] =
+proc implicitSubmit(input: HTMLInputElement): Option[Request] =
   let form = input.form
   if form != nil and form.canSubmitImplicitly():
     var defaultButton: Element
diff --git a/src/types/blob.nim b/src/types/blob.nim
index 9ddca2b5..5da7317d 100644
--- a/src/types/blob.nim
+++ b/src/types/blob.nim
@@ -1,5 +1,6 @@
-import options
-import strutils
+import std/options
+import std/os
+import std/strutils
 
 import js/dict
 import js/fromjs
@@ -92,12 +93,14 @@ proc newWebFile(ctx: JSContext, fileBits: seq[string], fileName: string,
 
 #TODO File, Blob constructors
 
-func size*(this: WebFile): uint64 {.jsfget.} =
-  #TODO use stat instead
+proc getSize*(this: Blob): uint64 =
   if this.isfile:
-    return uint64(this.file.getFileSize())
+    return uint64(WebFile(this).path.getFileSize())
   return this.size
 
+proc size*(this: WebFile): uint64 {.jsfget.} =
+  return this.getSize()
+
 func name*(this: WebFile): string {.jsfget.} =
   if this.path.len > 0 and this.path[^1] != '/':
     return this.path.afterLast('/')
diff --git a/src/types/formdata.nim b/src/types/formdata.nim
index 2bc26e22..b1957998 100644
--- a/src/types/formdata.nim
+++ b/src/types/formdata.nim
@@ -1,5 +1,9 @@
+import std/streams
+import std/strutils
+
 import js/javascript
 import types/blob
+import utils/twtstr
 
 type
   FormDataEntry* = object
@@ -13,9 +17,71 @@ type
 
   FormData* = ref object
     entries*: seq[FormDataEntry]
+    boundary*: string
 
 jsDestructor(FormData)
 
 iterator items*(this: FormData): FormDataEntry {.inline.} =
   for entry in this.entries:
     yield entry
+
+proc calcLength*(this: FormData): int =
+  result = 0
+  for entry in this.entries:
+    result += "--\r\n".len + this.boundary.len # always have boundary
+    #TODO maybe make CRLF for name first?
+    result += entry.name.len # always have name
+    # these must be percent-encoded, with 2 char overhead:
+    result += entry.name.count({'\r', '\n', '"'}) * 2
+    if entry.isstr:
+      result += "Content-Disposition: form-data; name=\"\"\r\n".len
+      result += entry.svalue.len
+    else:
+      result += "Content-Disposition: form-data; name=\"\";".len
+      # file name
+      result += " filename=\"\"\r\n".len
+      result += entry.filename.len
+      # dquot must be quoted with 2 char overhead
+      result += entry.filename.count('"') * 2
+      # content type
+      result += "Content-Type: \r\n".len
+      result += entry.value.ctype.len
+      if entry.value.isfile:
+        result += int(WebFile(entry.value).getSize())
+      else:
+        result += int(entry.value.size)
+    result += "\r\n".len # header is always followed by \r\n
+    result += "\r\n".len # value is always followed by \r\n
+
+proc getContentType*(this: FormData): string =
+  return "multipart/form-data; boundary=" & this.boundary
+
+proc writeEntry*(stream: Stream, entry: FormDataEntry, boundary: string) =
+  stream.write("--" & boundary & "\r\n")
+  let name = percentEncode(entry.name, {'"', '\r', '\n'})
+  if entry.isstr:
+    stream.write("Content-Disposition: form-data; name=\"" & name & "\"\r\n")
+    stream.write("\r\n")
+    stream.write(entry.svalue)
+  else:
+    stream.write("Content-Disposition: form-data; name=\"" & name & "\";")
+    let filename = percentEncode(entry.filename, {'"', '\r', '\n'})
+    stream.write(" filename=\"" & filename & "\"\r\n")
+    let blob = entry.value
+    let ctype = if blob.ctype == "":
+      "application/octet-stream"
+    else:
+      blob.ctype
+    stream.write("Content-Type: " & ctype & "\r\n")
+    if blob.isfile:
+      let fs = newFileStream(WebFile(blob).path)
+      if fs != nil:
+        var buf {.noInit.}: array[4096, uint8]
+        while true:
+          let n = fs.readData(addr buf[0], 4096)
+          stream.writeData(addr buf[0], n)
+          if n != 4096: break
+    else:
+      stream.writeData(blob.buffer, int(blob.size))
+    stream.write("\r\n")
+  stream.write("\r\n")
diff --git a/src/xhr/formdata.nim b/src/xhr/formdata.nim
index 84c13402..98c96b54 100644
--- a/src/xhr/formdata.nim
+++ b/src/xhr/formdata.nim
@@ -1,3 +1,6 @@
+import std/base64
+import std/streams
+
 import html/dom
 import html/enums
 import js/domexception
@@ -12,12 +15,20 @@ import chame/tags
 proc constructEntryList*(form: HTMLFormElement, submitter: Element = nil,
     encoding: string = ""): Option[seq[FormDataEntry]]
 
+
+proc generateBoundary(): string =
+  let urandom = newFileStream("/dev/urandom")
+  let s = urandom.readStr(32)
+  urandom.close()
+  # 32 * 4 / 3 (padded) = 44 + prefix string is 22 bytes = 66 bytes
+  return "----WebKitFormBoundary" & base64.encode(s)
+
 proc newFormData0*(): FormData =
-  return FormData()
+  return FormData(boundary: generateBoundary())
 
 proc newFormData*(form: HTMLFormElement = nil,
     submitter: HTMLElement = nil): DOMResult[FormData] {.jsctor.} =
-  let this = FormData()
+  let this = newFormData0()
   if form != nil:
     if submitter != nil:
       if not submitter.isSubmitButton():