about summary refs log tree commit diff stats
diff options
context:
space:
mode:
authorbptato <nincsnevem662@gmail.com>2024-08-13 22:48:12 +0200
committerbptato <nincsnevem662@gmail.com>2024-08-13 23:03:41 +0200
commit885a3493b6cad4b4247a200928fe61e41883aaba (patch)
tree2b823ef18043c775f21b8ad723c826ffdc6b2663
parent968de41082280dde47bac7c2bb59522284b4c672 (diff)
downloadchawan-885a3493b6cad4b4247a200928fe61e41883aaba.tar.gz
xhr: progress
* fix header case sensitivity issues
	-> probably still wrong as it discards the original
	  casing. better than nothing, anyway
* fix fulfill on generic promises
* support standard open() async parameter weirdness
* refactor loader response body reading (so bodyRead is no longer
  mandatory)
* actually read response body

still missing: response body getters
-rw-r--r--src/html/xmlhttprequest.nim148
-rw-r--r--src/io/promise.nim11
-rw-r--r--src/loader/headers.nim28
-rw-r--r--src/loader/loader.nim16
-rw-r--r--src/loader/response.nim90
-rw-r--r--src/types/url.nim4
-rw-r--r--src/utils/twtstr.nim2
-rw-r--r--test/js/headers.html12
8 files changed, 226 insertions, 85 deletions
diff --git a/src/html/xmlhttprequest.nim b/src/html/xmlhttprequest.nim
index 16b6e012..ed1b7035 100644
--- a/src/html/xmlhttprequest.nim
+++ b/src/html/xmlhttprequest.nim
@@ -7,6 +7,7 @@ import html/catom
 import html/dom
 import html/event
 import html/script
+import io/dynstream
 import io/promise
 import js/domexception
 import loader/headers
@@ -55,16 +56,17 @@ type
     response: Response
     responseType {.jsget.}: XMLHttpRequestResponseType
     timeout {.jsget.}: uint32
+    received: string
 
   ProgressEvent = ref object of Event
     lengthComputable {.jsget.}: bool
-    loaded {.jsget.}: uint32
-    total {.jsget.}: uint32
+    loaded {.jsget.}: int64 #TODO should be uint64
+    total {.jsget.}: int64 #TODO ditto
 
   ProgressEventInit = object of EventInit
     lengthComputable: bool
-    loaded: uint32
-    total: uint32
+    loaded: int64
+    total: int64
 
 jsDestructor(XMLHttpRequestEventTarget)
 jsDestructor(XMLHttpRequestUpload)
@@ -106,16 +108,25 @@ proc parseMethod(s: string): DOMResult[HttpMethod] =
   else:
     errDOMException("Invalid method", "SyntaxError")
 
-#TODO the standard says that no async should be treated differently from
-# undefined. idk if (and where) this actually matters.
 proc open(ctx: JSContext; this: XMLHttpRequest; httpMethod, url: string;
-    async = true; username = ""; password = ""): Err[DOMException] {.jsfunc.} =
+    misc: varargs[JSValue]): Err[DOMException] {.jsfunc.} =
   let httpMethod = ?parseMethod(httpMethod)
   let global = ctx.getGlobal()
   let x = parseURL(url, some(global.document.baseURL))
   if x.isNone:
     return errDOMException("Invalid URL", "SyntaxError")
   let parsedURL = x.get
+  var async = true
+  if misc.len > 0: # standard weirdness
+    ?ctx.fromJS(misc[0], async)
+    if misc.len > 1 and not JS_IsNull(misc[1]):
+      var username: string
+      ?ctx.fromJS(misc[1], username)
+      parsedURL.setUsername(username)
+    if misc.len > 2 and not JS_IsNull(misc[2]):
+      var password: string
+      ?ctx.fromJS(misc[2], password)
+      parsedURL.setPassword(password)
   if not async and ctx.getWindow() != nil and
       (this.timeout != 0 or this.responseType != xhrtUnknown):
     return errDOMException("Today's horoscope: don't go outside",
@@ -160,7 +171,7 @@ proc setRequestHeader(this: XMLHttpRequest; name, value: string):
   ok()
 
 proc fireProgressEvent(window: Window; target: EventTarget; name: StaticAtom;
-    loaded, length: uint32) =
+    loaded, length: int64) =
   let event = newProgressEvent(window.factory.toAtom(name), ProgressEventInit(
     loaded: loaded,
     total: length,
@@ -177,15 +188,15 @@ proc errorSteps(window: Window; this: XMLHttpRequest; name: StaticAtom) =
   this.readyState = xhrsDone
   this.response = makeNetworkError()
   this.flags.excl(xhrfSend)
-  #TODO sync?
-  window.fireEvent(satReadystatechange, this)
-  if xhrfUploadComplete notin this.flags:
-    this.flags.incl(xhrfUploadComplete)
-    if xhrfUploadListener in this.flags:
-      window.fireProgressEvent(this.upload, name, 0, 0)
-      window.fireProgressEvent(this.upload, satLoadend, 0, 0)
-  window.fireProgressEvent(this, name, 0, 0)
-  window.fireProgressEvent(this, satLoadend, 0, 0)
+  if xhrfSync notin this.flags:
+    window.fireEvent(satReadystatechange, this)
+    if xhrfUploadComplete notin this.flags:
+      this.flags.incl(xhrfUploadComplete)
+      if xhrfUploadListener in this.flags:
+        window.fireProgressEvent(this.upload, name, 0, 0)
+        window.fireProgressEvent(this.upload, satLoadend, 0, 0)
+    window.fireProgressEvent(this, name, 0, 0)
+    window.fireProgressEvent(this, satLoadend, 0, 0)
 
 proc handleErrors(window: Window; this: XMLHttpRequest): DOMException =
   if xhrfSend notin this.flags:
@@ -204,7 +215,52 @@ proc handleErrors(window: Window; this: XMLHttpRequest): DOMException =
       return newDOMException("Network error in XHR", "NetworkError")
   return nil
 
-proc send(ctx: JSContext; this: XMLHttpRequest; body = JS_NULL): DOMResult[void]
+type XHROpaque = ref object of RootObj
+  this: XMLHttpRequest
+  window: Window
+  len: int64 #TODO should be uint64
+
+proc onReadXHR(response: Response) =
+  const BufferSize = 4096
+  let opaque = XHROpaque(response.opaque)
+  let this = opaque.this
+  let window = opaque.window
+  while true:
+    try:
+      let olen = this.received.len
+      this.received.setLen(olen + BufferSize)
+      let n = response.body.recvData(addr this.received[olen], BufferSize)
+      if n < BufferSize:
+        this.received.setLen(olen + n)
+      if n == 0:
+        break
+    except ErrorAgain:
+      break
+  if this.readyState == xhrsHeadersReceived:
+    this.readyState = xhrsLoading
+  window.fireEvent(satReadystatechange, this)
+  window.fireProgressEvent(this, satProgress, int64(this.received.len),
+    opaque.len)
+
+proc onFinishXHR(response: Response; success: bool) =
+  let opaque = XHROpaque(response.opaque)
+  let this = opaque.this
+  let window = opaque.window
+  if success:
+    discard window.handleErrors(this)
+    if response.responseType != rtError:
+      let recvLen = int64(this.received.len)
+      window.fireProgressEvent(this, satProgress, recvLen, opaque.len)
+      this.readyState = xhrsDone
+      this.flags.excl(xhrfSend)
+      window.fireEvent(satReadystatechange, this)
+      window.fireProgressEvent(this, satLoad, recvLen, opaque.len)
+      window.fireProgressEvent(this, satLoadend, recvLen, opaque.len)
+  else:
+    this.response = makeNetworkError()
+    discard window.handleErrors(this)
+
+proc send(ctx: JSContext; this: XMLHttpRequest; body = JS_NULL): JSResult[void]
     {.jsfunc.} =
   ?this.checkOpened()
   ?this.checkSendFlag()
@@ -243,19 +299,44 @@ proc send(ctx: JSContext; this: XMLHttpRequest; body = JS_NULL): DOMResult[void]
     let v = ctx.toJS(jsRequest)
     let p = window.windowFetch(v)
     JS_FreeValue(ctx, v)
-    if p.isSome:
-      p.get.then(proc(res: JSResult[Response]) =
-        if res.isNone:
-          this.response = makeNetworkError()
-          discard window.handleErrors(this)
-          return
-        let response = res.get
-        this.response = response
-        this.readyState = xhrsHeadersReceived
-        window.fireEvent(satReadystatechange, this)
-      )
+    if p.isNone:
+      return err(p.error)
+    p.get.then(proc(res: JSResult[Response]) =
+      if res.isNone:
+        this.response = makeNetworkError()
+        discard window.handleErrors(this)
+        return
+      let response = res.get
+      this.response = response
+      this.readyState = xhrsHeadersReceived
+      window.fireEvent(satReadystatechange, this)
+      if this.readyState != xhrsHeadersReceived:
+        return
+      let len = max(response.getContentLength(), 0)
+      response.opaque = XHROpaque(this: this, window: window, len: len)
+      response.onRead = onReadXHR
+      response.onFinish = onFinishXHR
+      #TODO timeout
+    )
   else: # sync
-    discard #TODO
+    #TODO cors requests?
+    if window.settings.origin.isSameOrigin(request.url.origin):
+      let response = window.loader.doRequest(request)
+      if response.res == 0:
+        #TODO timeout
+        try:
+          this.received = response.body.recvAll()
+          #TODO report timing
+          let len = max(response.getContentLength(), 0)
+          response.opaque = XHROpaque(this: this, window: window, len: len)
+          response.onFinishXHR(true)
+          return ok()
+        except IOError:
+          discard
+    let ex = window.handleErrors(this)
+    this.response = makeNetworkError()
+    if ex != nil:
+      return err(ex)
   ok()
 
 #TODO abort
@@ -269,9 +350,10 @@ proc status(this: XMLHttpRequest): uint16 {.jsfget.} =
 proc statusText(this: XMLHttpRequest): string {.jsfget.} =
   return ""
 
-proc getResponseHeader(this: XMLHttpRequest; name: string): string {.jsfunc.} =
-  #TODO ?
-  return this.response.headers.table.getOrDefault(name)[0]
+proc getResponseHeader(ctx: JSContext; this: XMLHttpRequest; name: string):
+    JSValue {.jsfunc.} =
+  #TODO this can throw, but I don't think the standard allows that?
+  return ctx.get(this.response.headers, name)
 
 #TODO getAllResponseHeaders
 
diff --git a/src/io/promise.nim b/src/io/promise.nim
index 3c01e214..55dcfbf0 100644
--- a/src/io/promise.nim
+++ b/src/io/promise.nim
@@ -59,12 +59,11 @@ proc resolve*(promise: EmptyPromise) =
     promise.next = nil
 
 proc resolve*[T](promise: Promise[T]; res: T) =
-  if promise.cb != nil:
-    if promise.get != nil:
-      promise.get(promise.opaque, promise.res)
-      promise.get = nil
-    promise.res = res
-    promise.resolve()
+  if promise.get != nil:
+    promise.get(promise.opaque, promise.res)
+    promise.get = nil
+  promise.res = res
+  promise.resolve()
 
 proc resolve*(map: var PromiseMap; promiseid: int) =
   var promise: EmptyPromise
diff --git a/src/loader/headers.nim b/src/loader/headers.nim
index 6b598cc2..e09d2267 100644
--- a/src/loader/headers.nim
+++ b/src/loader/headers.nim
@@ -5,6 +5,7 @@ import monoucha/fromjs
 import monoucha/javascript
 import monoucha/jserror
 import monoucha/quickjs
+import monoucha/tojs
 import types/opt
 import utils/twtstr
 
@@ -99,7 +100,8 @@ func isForbiddenRequestHeader*(name, value: string): bool =
   return false
 
 func isForbiddenResponseHeaderName*(name: string): bool =
-  return name in ["Set-Cookie", "Set-Cookie2"]
+  return name.equalsIgnoreCase("Set-Cookie") or
+    name.equalsIgnoreCase("Set-Cookie2")
 
 proc validate(this: Headers; name, value: string): JSResult[bool] =
   if not name.isValidHeaderName() or not value.isValidHeaderValue():
@@ -118,7 +120,6 @@ func isNoCorsSafelistedName(name: string): bool =
     name.equalsIgnoreCase("Content-Language") or
     name.equalsIgnoreCase("Content-Type")
 
-
 const CorsUnsafeRequestByte = {
   char(0x00)..char(0x08), char(0x10)..char(0x1F), '"', '(', ')', ':', '<', '>',
   '?', '@', '[', '\\', ']', '{', '}', '\e'
@@ -145,12 +146,14 @@ func isNoCorsSafelisted(name, value: string): bool =
 func get0(this: Headers; name: string): string =
   return this.table[name].join(", ")
 
-proc get(this: Headers; name: string): JSResult[Option[string]] {.jsfunc.} =
+proc get*(ctx: JSContext; this: Headers; name: string): JSValue {.jsfunc.} =
   if not name.isValidHeaderName():
-    return errTypeError("Invalid header name")
+    JS_ThrowTypeError(ctx, "Invalid header name")
+    return JS_EXCEPTION
+  let name = name.toHeaderCase()
   if name notin this.table:
-    return ok(none(string))
-  return ok(some(this.get0(name)))
+    return JS_NULL
+  return ctx.toJS(this.get0(name))
 
 proc removeRange(this: Headers) =
   if this.guard == hgRequestNoCors:
@@ -162,18 +165,21 @@ proc append(this: Headers; name, value: string): JSResult[void] {.jsfunc.} =
   let value = value.strip(chars = HTTPWhitespace)
   if not ?this.validate(name, value):
     return ok()
+  let name = name.toHeaderCase()
   if this.guard == hgRequestNoCors:
     if name in this.table:
       let tmp = this.get0(name) & ", " & value
       if not name.isNoCorsSafelisted(tmp):
         return ok()
-      this.table[name].add(value)
-    else:
-      this.table[name] = @[value]
+  if name in this.table:
+    this.table[name].add(value)
+  else:
+    this.table[name] = @[value]
   this.removeRange()
   ok()
 
 proc delete(this: Headers; name: string): JSResult[void] {.jsfunc.} =
+  let name = name.toHeaderCase()
   if not ?this.validate(name, "") or name notin this.table:
     return ok()
   if not name.isNoCorsSafelistedName() and not name.equalsIgnoreCase("Range"):
@@ -185,6 +191,7 @@ proc delete(this: Headers; name: string): JSResult[void] {.jsfunc.} =
 proc has(this: Headers; name: string): JSResult[bool] {.jsfunc.} =
   if not name.isValidHeaderName():
     return errTypeError("Invalid header name")
+  let name = name.toHeaderCase()
   return ok(name in this.table)
 
 proc set(this: Headers; name, value: string): JSResult[void] {.jsfunc.} =
@@ -193,8 +200,7 @@ proc set(this: Headers; name, value: string): JSResult[void] {.jsfunc.} =
     return
   if this.guard == hgRequestNoCors and not name.isNoCorsSafelisted(value):
     return
-  #TODO do this case insensitively
-  this.table[name] = @[value]
+  this.table[name.toHeaderCase()] = @[value]
   this.removeRange()
 
 proc fill(headers: Headers; s: seq[(string, string)]): JSResult[void] =
diff --git a/src/loader/loader.nim b/src/loader/loader.nim
index 91212e24..dfc95b8b 100644
--- a/src/loader/loader.nim
+++ b/src/loader/loader.nim
@@ -1140,21 +1140,17 @@ proc onRead*(loader: FileLoader; fd: int) =
   if response != nil:
     response.onRead(response)
     if response.body.isend:
-      response.bodyRead.resolve()
-      response.bodyRead = nil
+      if response.onFinish != nil:
+        response.onFinish(response, true)
+      response.onFinish = nil
       response.unregisterFun()
 
 proc onError*(loader: FileLoader; fd: int) =
   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(lbuf)
-        assert n == 0
-      assert response.body.isend
-    response.bodyRead.resolve()
-    response.bodyRead = nil
+    if response.onFinish != nil:
+      response.onFinish(response, false)
+    response.onFinish = nil
     response.unregisterFun()
 
 # Note: this blocks until headers are received.
diff --git a/src/loader/response.nim b/src/loader/response.nim
index a3b7e3e4..8143ccbf 100644
--- a/src/loader/response.nim
+++ b/src/loader/response.nim
@@ -11,6 +11,7 @@ import loader/request
 import monoucha/javascript
 import monoucha/jserror
 import monoucha/quickjs
+import monoucha/tojs
 import types/blob
 import types/color
 import types/opt
@@ -40,10 +41,10 @@ type
     url*: URL #TODO should be urllist?
     unregisterFun*: proc()
     resumeFun*: proc(outputId: int)
-    bodyRead*: EmptyPromise
     internalMessage*: string # should NOT be exposed to JS!
     outputId*: int
     onRead*: proc(response: Response) {.nimcall.}
+    onFinish*: proc(response: Response; success: bool) {.nimcall.}
     opaque*: RootRef
     flags*: set[ResponseFlag]
 
@@ -55,7 +56,6 @@ proc newResponse*(res: int; request: Request; stream: SocketStream;
     res: res,
     url: request.url,
     body: stream,
-    bodyRead: EmptyPromise(),
     outputId: outputId,
     status: status
   )
@@ -83,8 +83,10 @@ proc close*(response: Response) {.jsfunc.} =
   response.bodyUsed = true
   if response.unregisterFun != nil:
     response.unregisterFun()
+    response.unregisterFun = nil
   if response.body != nil:
     response.body.sclose()
+    response.body = nil
 
 func getCharset*(this: Response; fallback: Charset): Charset =
   if "Content-Type" notin this.headers.table:
@@ -103,8 +105,17 @@ func getContentType*(this: Response; fallback = "application/octet-stream"):
   # override buffer mime.types
   return DefaultGuess.guessContentType(this.url.pathname, fallback)
 
+func getContentLength*(this: Response): int64 =
+  this.headers.table.withValue("Content-Length", p):
+    for x in p[]:
+      let u = parseUInt64(x.strip(), allowSign = false)
+      if u.isSome and u.get <= uint64(int64.high):
+        return int64(u.get)
+  return -1
+
 type TextOpaque = ref object of RootObj
   buf: string
+  bodyRead: Promise[JSResult[string]]
 
 const BufferSize = 4096
 
@@ -122,6 +133,16 @@ proc onReadText(response: Response) =
       opaque.buf.setLen(olen)
       break
 
+proc onFinishText(response: Response; success: bool) =
+  let opaque = TextOpaque(response.opaque)
+  let bodyRead = opaque.bodyRead
+  if success:
+    let charset = response.getCharset(CHARSET_UTF_8)
+    bodyRead.resolve(JSResult[string].ok(opaque.buf.decodeAll(charset)))
+  else:
+    let err = newTypeError("NetworkError when attempting to fetch resource")
+    bodyRead.resolve(JSResult[string].err(err))
+
 proc resume*(response: Response) =
   response.resumeFun(response.outputId)
   response.resumeFun = nil
@@ -137,20 +158,20 @@ proc text*(response: Response): Promise[JSResult[string]] {.jsfunc.} =
       .err(newTypeError("Body has already been consumed"))
     p.resolve(err)
     return p
-  let opaque = TextOpaque()
+  let opaque = TextOpaque(bodyRead: newPromise[JSResult[string]]())
   response.opaque = opaque
   response.onRead = onReadText
+  response.onFinish = onFinishText
   response.bodyUsed = true
   response.resume()
-  return response.bodyRead.then(proc(): JSResult[string] =
-    let charset = response.getCharset(CHARSET_UTF_8)
-    ok(opaque.buf.decodeAll(charset))
-  )
+  return opaque.bodyRead
 
 type BlobOpaque = ref object of RootObj
   p: pointer
   len: int
   size: int
+  bodyRead: Promise[JSResult[Blob]]
+  contentType: string
 
 proc onReadBlob(response: Response) =
   let opaque = BlobOpaque(response.opaque)
@@ -168,29 +189,45 @@ proc onReadBlob(response: Response) =
     except ErrorAgain:
       break
 
+proc onFinishBlob(response: Response; success: bool) =
+  let opaque = BlobOpaque(response.opaque)
+  let bodyRead = opaque.bodyRead
+  if success:
+    let p = realloc(opaque.p, opaque.len)
+    opaque.p = nil
+    let blob = if p == nil:
+      newBlob(nil, 0, opaque.contentType, nil)
+    else:
+      newBlob(p, opaque.len, opaque.contentType, deallocBlob)
+    bodyRead.resolve(JSResult[Blob].ok(blob))
+  else:
+    if opaque.p != nil:
+      dealloc(opaque.p)
+      opaque.p = nil
+    let res = newTypeError("Error reading response")
+    bodyRead.resolve(JSResult[Blob].err(res))
+
 proc blob*(response: Response): Promise[JSResult[Blob]] {.jsfunc.} =
   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 opaque = BlobOpaque()
+  let opaque = BlobOpaque(
+    bodyRead: newPromise[JSResult[Blob]](),
+    contentType: response.getContentType()
+  )
   response.opaque = opaque
   response.onRead = onReadBlob
+  response.onFinish = onFinishBlob
   response.bodyUsed = true
   response.resume()
-  let contentType = response.getContentType()
-  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))
-    ok(newBlob(p, opaque.len, contentType, deallocBlob))
-  )
+  return opaque.bodyRead
 
 type BitmapOpaque = ref object of RootObj
   bmp: Bitmap
   idx: int
+  bodyRead: EmptyPromise
 
 proc onReadBitmap(response: Response) =
   let opaque = BitmapOpaque(response.opaque)
@@ -206,26 +243,33 @@ proc onReadBitmap(response: Response) =
     except ErrorAgain:
       break
 
+proc onFinishBitmap(response: Response; success: bool) =
+  let opaque = BitmapOpaque(response.opaque)
+  opaque.bodyRead.resolve()
+
 proc saveToBitmap*(response: Response; bmp: Bitmap): EmptyPromise =
   assert not response.bodyUsed
-  let opaque = BitmapOpaque(bmp: bmp, idx: 0)
+  let opaque = BitmapOpaque(bmp: bmp, idx: 0, bodyRead: EmptyPromise())
   let size = bmp.width * bmp.height
   bmp.px = cast[seq[RGBAColorBE]](newSeqUninitialized[uint32](size))
   response.opaque = opaque
   if size > 0:
     response.onRead = onReadBitmap
+    response.onFinish = onFinishBitmap
   else:
     response.unregisterFun()
     response.body.sclose()
+    opaque.bodyRead.resolve()
   response.bodyUsed = true
   response.resume()
-  return response.bodyRead
+  return opaque.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), csize_t(s.len), cstring"<input>"))
+proc json(ctx: JSContext; this: Response): Promise[JSValue] {.jsfunc.} =
+  return this.text().then(proc(s: JSResult[string]): JSValue =
+    if s.isNone:
+      return ctx.toJS(s.error)
+    return JS_ParseJSON(ctx, cstring(s.get), csize_t(s.get.len),
+      cstring"<input>")
   )
 
 proc addResponseModule*(ctx: JSContext) =
diff --git a/src/types/url.nim b/src/types/url.nim
index deeea08b..abb304b3 100644
--- a/src/types/url.nim
+++ b/src/types/url.nim
@@ -1246,12 +1246,12 @@ proc setProtocol*(url: URL; s: string) {.jsfset: "protocol".} =
   discard basicParseURL(s & ':', url = url,
     stateOverride = some(usSchemeStart))
 
-proc setUsername(url: URL; username: string) {.jsfset: "username".} =
+proc setUsername*(url: URL; username: string) {.jsfset: "username".} =
   if not url.canHaveUsernamePasswordPort:
     return
   url.username = username.percentEncode(UserInfoPercentEncodeSet)
 
-proc setPassword(url: URL; password: string) {.jsfset: "password".} =
+proc setPassword*(url: URL; password: string) {.jsfset: "password".} =
   if not url.canHaveUsernamePasswordPort:
     return
   url.password = password.percentEncode(UserInfoPercentEncodeSet)
diff --git a/src/utils/twtstr.nim b/src/utils/twtstr.nim
index c41b545f..75f70ad3 100644
--- a/src/utils/twtstr.nim
+++ b/src/utils/twtstr.nim
@@ -36,6 +36,8 @@ func toHeaderCase*(s: string): string =
   for c in result.mitems:
     if flip:
       c = c.toUpperAscii()
+    else:
+      c = c.toLowerAscii()
     flip = c == '-'
 
 func snakeToKebabCase*(s: string): string =
diff --git a/test/js/headers.html b/test/js/headers.html
new file mode 100644
index 00000000..ef836f40
--- /dev/null
+++ b/test/js/headers.html
@@ -0,0 +1,12 @@
+<!doctype html>
+<title>Headers object test</title>
+<div id=x>Fail</div>
+<script src=asserts.js></script>
+<script>
+const x = new Headers();
+x.append("hi", "world");
+assert_equals(x.get("hi"), "world");
+assert_equals(x.get("Hi"), "world");
+assert_equals(x.get("hI"), "world");
+document.getElementById("x").textContent = "Success";
+</script>