about summary refs log tree commit diff stats
diff options
context:
space:
mode:
authorbptato <nincsnevem662@gmail.com>2024-08-15 19:11:49 +0200
committerbptato <nincsnevem662@gmail.com>2024-08-15 19:23:55 +0200
commit4bf895db711f3d4d229d3f18fbb2145cce2a73af (patch)
tree2e81c7399de03aebb9dfa166eba6ee809a75cd2e
parent885a3493b6cad4b4247a200928fe61e41883aaba (diff)
downloadchawan-4bf895db711f3d4d229d3f18fbb2145cce2a73af.tar.gz
xhr: more progress
* add responseText, response
* add net tests
	-> currently sync XHR only; should find a way to do async
	   tests...
* update monoucha
	-> simplified & updated some related code that no longer worked
	   properly
-rw-r--r--.gitignore9
-rw-r--r--Makefile15
m---------lib/monoucha0
-rw-r--r--src/html/chadombuilder.nim5
-rw-r--r--src/html/dom.nim72
-rw-r--r--src/html/env.nim12
-rw-r--r--src/html/xmlhttprequest.nim115
-rw-r--r--src/js/encoding.nim28
-rw-r--r--src/loader/loader.nim2
-rw-r--r--src/loader/response.nim13
-rw-r--r--src/local/pager.nim11
-rw-r--r--src/types/blob.nim21
-rw-r--r--src/version.nim2
-rw-r--r--test/asserts.js23
l---------[-rw-r--r--]test/js/asserts.js20
-rw-r--r--test/js/console.html5
-rwxr-xr-xtest/js/run_js_tests.sh6
-rwxr-xr-xtest/layout/run_layout_tests.sh8
-rw-r--r--test/net/all.expected1
l---------test/net/asserts.js1
-rw-r--r--test/net/config.toml12
-rw-r--r--test/net/ping1
-rw-r--r--test/net/run.nim47
-rwxr-xr-xtest/net/run.sh17
-rw-r--r--test/net/xhr.html (renamed from test/js/xhr.html)9
25 files changed, 328 insertions, 127 deletions
diff --git a/.gitignore b/.gitignore
index 070630d8..da0eff4f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,4 +1,5 @@
-a
-cha
-target/
-.obj/
+/a
+/cha
+/target/
+/.obj/
+/test/net/run
diff --git a/Makefile b/Makefile
index cfc1b71b..e5b94be4 100644
--- a/Makefile
+++ b/Makefile
@@ -208,7 +208,18 @@ uninstall:
 submodule:
 	git submodule update --init
 
-.PHONY: test
-test:
+test/net/run: test/net/run.nim
+	$(NIMC) test/net/run.nim
+
+.PHONY: test_js
+test_js:
 	(cd test/js; ./run_js_tests.sh)
+
+test_layout:
 	(cd test/layout; ./run_layout_tests.sh)
+
+test_net: test/net/run
+	(cd test/net; ./run)
+
+.PHONY: test
+test: test_js test_layout test_net
diff --git a/lib/monoucha b/lib/monoucha
-Subproject 20cef6c267447ff389262dbd555fa44f7576c4e
+Subproject cf86be35a5e6a0dce00d39a1c9ea57e0b7aa69e
diff --git a/src/html/chadombuilder.nim b/src/html/chadombuilder.nim
index 5470deb2..bedd0d66 100644
--- a/src/html/chadombuilder.nim
+++ b/src/html/chadombuilder.nim
@@ -349,10 +349,10 @@ proc finish*(wrapper: HTML5ParserWrapper) =
     r.parser = nil
   wrapper.refs.setLen(0)
 
-proc newDOMParser(): DOMParser {.jsctor.} =
+proc newDOMParser*(): DOMParser {.jsctor.} =
   return DOMParser()
 
-proc parseFromString(ctx: JSContext; parser: DOMParser; str, t: string):
+proc parseFromString*(ctx: JSContext; parser: DOMParser; str, t: string):
     JSResult[Document] {.jsfunc.} =
   case t
   of "text/html":
@@ -361,7 +361,6 @@ proc parseFromString(ctx: JSContext; parser: DOMParser; str, t: string):
       window.document.url
     else:
       newURL("about:blank").get
-    #TODO this is probably broken in client (or at least sub-optimal)
     let builder = newChaDOMBuilder(url, window, window.factory, ccIrrelevant)
     var parser = initHTML5Parser(builder, HTML5ParserOpts[Node, CAtom]())
     let res = parser.parseChunk(str)
diff --git a/src/html/dom.nim b/src/html/dom.nim
index 208f8168..7bd3ff9a 100644
--- a/src/html/dom.nim
+++ b/src/html/dom.nim
@@ -464,7 +464,7 @@ proc resetState(state: var DrawingState) =
   state.path = newPath()
 
 proc create2DContext*(jctx: JSContext; target: HTMLCanvasElement;
-    options: Option[JSValue]): CanvasRenderingContext2D =
+    options = JS_UNDEFINED): CanvasRenderingContext2D =
   let ctx = CanvasRenderingContext2D(
     bitmap: target.bitmap,
     canvas: target
@@ -1273,10 +1273,10 @@ func childNodes(node: Node): NodeList {.jsfget.} =
 func length(tokenList: DOMTokenList): uint32 {.jsfget.} =
   return uint32(tokenList.toks.len)
 
-func item(tokenList: DOMTokenList; i: int): Option[string] {.jsfunc.} =
+proc item(ctx: JSContext; tokenList: DOMTokenList; i: int): JSValue {.jsfunc.} =
   if i < tokenList.toks.len:
-    return some(tokenList.element.document.toStr(tokenList.toks[i]))
-  return none(string)
+    return ctx.toJS(tokenList.toks[i])
+  return JS_NULL
 
 func contains*(tokenList: DOMTokenList; a: CAtom): bool =
   return a in tokenList.toks
@@ -1381,8 +1381,12 @@ func supports(tokenList: DOMTokenList; token: string):
 func value(tokenList: DOMTokenList): string {.jsfget.} =
   return $tokenList
 
-func getter(tokenList: DOMTokenList; i: uint32): Option[string] {.jsgetprop.} =
-  return tokenList.item(int(i))
+proc getter(ctx: JSContext; this: DOMTokenList; atom: JSAtom): JSValue
+    {.jsgetprop.} =
+  var u: uint32
+  if ctx.fromJS(atom, u).isSome:
+    return ctx.item(this, int(u))
+  return JS_NULL
 
 # DOMStringMap
 func validateAttributeName(name: string): Err[DOMException] =
@@ -1450,9 +1454,14 @@ func hasprop(nodeList: NodeList; i: uint32): bool {.jshasprop.} =
 func item(nodeList: NodeList; i: int): Node {.jsfunc.} =
   if i < nodeList.len:
     return nodeList.snapshot[i]
+  return nil
 
-func getter(nodeList: NodeList; i: uint32): Option[Node] {.jsgetprop.} =
-  return option(nodeList.item(int(i)))
+func getter(ctx: JSContext; nodeList: NodeList; atom: JSAtom): Node
+    {.jsgetprop.} =
+  var u: uint32
+  if ctx.fromJS(atom, u).isSome:
+    return nodeList.item(int(u))
+  return nil
 
 func names(ctx: JSContext; nodeList: NodeList): JSPropertyEnumList
     {.jspropnames.} =
@@ -1482,21 +1491,21 @@ func namedItem(collection: HTMLCollection; s: string): Element {.jsfunc.} =
       return it
   return nil
 
-func getter(ctx: JSContext; collection: HTMLCollection; atom: JSAtom):
-    Opt[Option[Element]] {.jsgetprop.} =
+func getter(ctx: JSContext; collection: HTMLCollection; atom: JSAtom): Element
+    {.jsgetprop.} =
   var u: uint32
   if ctx.fromJS(atom, u).isSome:
-    return ok(option(collection.item(u)))
+    return collection.item(u)
   var s: string
   if ctx.fromJS(atom, s).isSome:
-    return ok(option(collection.namedItem(s)))
-  return err()
+    return collection.namedItem(s)
+  return nil
 
 func names(ctx: JSContext; collection: HTMLCollection): JSPropertyEnumList
     {.jspropnames.} =
   let L = collection.length
   var list = newJSPropertyEnumList(ctx, L)
-  var ids: OrderedSet[CAtom]
+  var ids = initOrderedSet[CAtom]()
   for u in 0 ..< L:
     list.add(u)
     let elem = collection.item(u)
@@ -1509,25 +1518,32 @@ func names(ctx: JSContext; collection: HTMLCollection): JSPropertyEnumList
   return list
 
 # HTMLAllCollection
-proc length(collection: HTMLAllCollection): uint32 {.jsfget.} =
-  return uint32(collection.len)
+proc length(this: HTMLAllCollection): uint32 {.jsfget.} =
+  return uint32(this.len)
 
-func hasprop(collection: HTMLAllCollection; i: uint32): bool {.jshasprop.} =
-  return int(i) < collection.len
+func hasprop(ctx: JSContext; this: HTMLAllCollection; atom: JSAtom): bool
+    {.jshasprop.} =
+  var u: uint32
+  if ctx.fromJS(atom, u).isSome:
+    return int(u) < this.len
+  return false
 
-func item(collection: HTMLAllCollection; i: uint32): Element {.jsfunc.} =
-  let i = int(i)
-  if i < collection.len:
-    return Element(collection.snapshot[i])
+func item(this: HTMLAllCollection; u: uint32): Element {.jsfunc.} =
+  let i = int(u)
+  if i < this.len:
+    return Element(this.snapshot[i])
   return nil
 
-func getter(collection: HTMLAllCollection; i: uint32): Option[Element]
+func getter(ctx: JSContext; this: HTMLAllCollection; atom: JSAtom): Element
     {.jsgetprop.} =
-  return option(collection.item(i))
+  var u: uint32
+  if ctx.fromJS(atom, u).isSome:
+    return this.item(u)
+  return nil
 
-func names(ctx: JSContext; collection: HTMLAllCollection): JSPropertyEnumList
+func names(ctx: JSContext; this: HTMLAllCollection): JSPropertyEnumList
     {.jspropnames.} =
-  let L = collection.length
+  let L = this.length
   var list = newJSPropertyEnumList(ctx, L)
   for u in 0 ..< L:
     list.add(u)
@@ -3822,7 +3838,7 @@ proc fetchClassicScript(element: HTMLScriptElement; url: URL;
   if response.res != 0:
     element.onComplete(ScriptResult(t: srtNull))
     return
-  window.loader.resume(response.outputId)
+  response.resume()
   let s = response.body.recvAll()
   let cs = if cs == CHARSET_UNKNOWN: CHARSET_UTF_8 else: cs
   let source = s.decodeAll(cs)
@@ -4409,7 +4425,7 @@ func getElementReflectFunctions(): seq[TabGetSet] =
     ))
 
 proc getContext*(jctx: JSContext; this: HTMLCanvasElement; contextId: string;
-    options = none(JSValue)): RenderingContext {.jsfunc.} =
+    options = JS_UNDEFINED): RenderingContext {.jsfunc.} =
   if contextId == "2d":
     if this.ctx2d != nil:
       return this.ctx2d
diff --git a/src/html/env.nim b/src/html/env.nim
index 4ecdc08b..ea55dcbc 100644
--- a/src/html/env.nim
+++ b/src/html/env.nim
@@ -71,12 +71,11 @@ proc item(pluginArray: var PluginArray): JSValue {.jsfunc.} = JS_NULL
 proc length(pluginArray: var PluginArray): uint32 {.jsfget.} = 0
 proc item(mimeTypeArray: var MimeTypeArray): JSValue {.jsfunc.} = JS_NULL
 proc length(mimeTypeArray: var MimeTypeArray): uint32 {.jsfget.} = 0
-proc getter(pluginArray: var PluginArray; i: uint32): Option[JSValue]
+proc getter(pluginArray: var PluginArray; atom: JSAtom): JSValue {.jsgetprop.} =
+  return JS_NULL
+proc getter(mimeTypeArray: var MimeTypeArray; atom: JSAtom): JSValue
     {.jsgetprop.} =
-  discard
-proc getter(mimeTypeArray: var MimeTypeArray; i: uint32): Option[JSValue]
-    {.jsgetprop.} =
-  discard
+  return JS_NULL
 
 # Screen
 proc availWidth(screen: var Screen): int64 {.jsfget.} =
@@ -116,8 +115,7 @@ func key(this: var Storage; i: uint32): Option[string] {.jsfunc.} =
     return some(this.map[int(i)].value)
   return none(string)
 
-func getItem(this: var Storage; s: string): Option[string]
-    {.jsfunc.} =
+func getItem(this: var Storage; s: string): Option[string] {.jsfunc.} =
   let i = this.find(s)
   if i != -1:
     return some(this.map[i].value)
diff --git a/src/html/xmlhttprequest.nim b/src/html/xmlhttprequest.nim
index ed1b7035..68039978 100644
--- a/src/html/xmlhttprequest.nim
+++ b/src/html/xmlhttprequest.nim
@@ -2,8 +2,10 @@ import std/options
 import std/strutils
 import std/tables
 
+import chagashi/charset
 import chagashi/decoder
 import html/catom
+import html/chadombuilder
 import html/dom
 import html/event
 import html/script
@@ -19,6 +21,7 @@ import monoucha/javascript
 import monoucha/jserror
 import monoucha/quickjs
 import monoucha/tojs
+import types/blob
 import types/opt
 import types/url
 import utils/twtstr
@@ -56,7 +59,9 @@ type
     response: Response
     responseType {.jsget.}: XMLHttpRequestResponseType
     timeout {.jsget.}: uint32
+    responseObject: JSValue
     received: string
+    contentTypeOverride: string
 
   ProgressEvent = ref object of Event
     lengthComputable {.jsget.}: bool
@@ -77,9 +82,13 @@ func newXMLHttpRequest(): XMLHttpRequest {.jsctor.} =
   let upload = XMLHttpRequestUpload()
   return XMLHttpRequest(
     upload: upload,
-    headers: newHeaders()
+    headers: newHeaders(),
+    responseObject: JS_UNDEFINED
   )
 
+proc finalize(rt: JSRuntime; this: XMLHttpRequest) {.jsfin.} =
+  JS_FreeValueRT(rt, this.responseObject)
+
 proc newProgressEvent(ctype: CAtom; init = ProgressEventInit()): ProgressEvent
     {.jsctor.} =
   let event = ProgressEvent(
@@ -170,6 +179,14 @@ proc setRequestHeader(this: XMLHttpRequest; name, value: string):
   this.headers.table[name.toHeaderCase()] = @[value]
   ok()
 
+proc setTimeout(ctx: JSContext; this: XMLHttpRequest; value: uint32):
+    Err[DOMException] {.jsfset: "timeout".} =
+  if ctx.getWindow() != nil and xhrfSync in this.flags:
+    return errDOMException("timeout may not be set on synchronous XHR",
+      "InvalidAccessError")
+  this.timeout = value
+  ok()
+
 proc fireProgressEvent(window: Window; target: EventTarget; name: StaticAtom;
     loaded, length: int64) =
   let event = newProgressEvent(window.factory.toAtom(name), ProgressEventInit(
@@ -316,6 +333,7 @@ proc send(ctx: JSContext; this: XMLHttpRequest; body = JS_NULL): JSResult[void]
       response.opaque = XHROpaque(this: this, window: window, len: len)
       response.onRead = onReadXHR
       response.onFinish = onFinishXHR
+      response.resume()
       #TODO timeout
     )
   else: # sync
@@ -324,6 +342,7 @@ proc send(ctx: JSContext; this: XMLHttpRequest; body = JS_NULL): JSResult[void]
       let response = window.loader.doRequest(request)
       if response.res == 0:
         #TODO timeout
+        response.resume()
         try:
           this.received = response.body.recvAll()
           #TODO report timing
@@ -352,11 +371,40 @@ proc statusText(this: XMLHttpRequest): string {.jsfget.} =
 
 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)
+  let res = ctx.get(this.response.headers, name)
+  if JS_IsException(res):
+    return JS_NULL
+  return res
 
 #TODO getAllResponseHeaders
 
+func getCharset(this: XMLHttpRequest): Charset =
+  let override = this.contentTypeOverride.toLowerAscii()
+  let cs = override.getContentTypeAttr("charset").getCharset()
+  if cs != CHARSET_UNKNOWN:
+    return cs
+  return this.response.getCharset(CHARSET_UTF_8)
+
+proc responseText(ctx: JSContext; this: XMLHttpRequest): JSValue {.jsfget.} =
+  if this.responseType notin {xhrtUnknown, xhrtText}:
+    let ex = newDOMException("response type was expected to be '' or 'text'",
+      "InvalidStateError")
+    return JS_Throw(ctx, ctx.toJS(ex))
+  if this.readyState notin {xhrsLoading, xhrsDone}:
+    return ctx.toJS("")
+  let charset = this.getCharset()
+  #TODO XML encoding stuff?
+  return ctx.toJS(this.received.decodeAll(charset))
+
+proc overrideMimeType(this: XMLHttpRequest; s: string): DOMResult[void]
+    {.jsfunc.} =
+  if this.readyState notin {xhrsLoading, xhrsDone}:
+    return errDOMException("readyState must not be loading or done",
+      "InvalidStateError")
+  #TODO parse
+  this.contentTypeOverride = s
+  return ok()
+
 proc setResponseType(ctx: JSContext; this: XMLHttpRequest;
     value: XMLHttpRequestResponseType): Err[DOMException]
     {.jsfset: "responseType".} =
@@ -372,13 +420,60 @@ proc setResponseType(ctx: JSContext; this: XMLHttpRequest;
   this.responseType = value
   ok()
 
-proc setTimeout(ctx: JSContext; this: XMLHttpRequest; value: uint32):
-    Err[DOMException] {.jsfset: "timeout".} =
-  if ctx.getWindow() != nil and xhrfSync in this.flags:
-    return errDOMException("timeout may not be set on synchronous XHR",
-      "InvalidAccessError")
-  this.timeout = value
-  ok()
+func getContentType(this: XMLHttpRequest): string =
+  if this.contentTypeOverride != "":
+    return this.contentTypeOverride
+  return this.response.getContentType()
+
+proc ptrify(s: var string): pointer =
+  if s.len == 0:
+    return nil
+  var sr = new(string)
+  sr[] = move(s)
+  GC_ref(sr)
+  return cast[pointer](sr)
+
+proc deallocPtrified(p: pointer) =
+  if p != nil:
+    let sr = cast[ref string](p)
+    GC_unref(sr)
+
+proc abufFree(rt: JSRuntime; opaque, p: pointer) {.cdecl.} =
+  deallocPtrified(opaque)
+
+proc blobFree(opaque, p: pointer) {.nimcall.} =
+  deallocPtrified(opaque)
+
+proc response(ctx: JSContext; this: XMLHttpRequest): JSValue {.jsfget.} =
+  if this.responseType in {xhrtText, xhrtUnknown}:
+    return ctx.responseText(this)
+  if this.readyState != xhrsDone:
+    return JS_NULL
+  if JS_IsUndefined(this.responseObject):
+    case this.responseType
+    of xhrtArraybuffer:
+      let len = csize_t(this.received.len)
+      let p = cast[ptr UncheckedArray[uint8]](cstring(this.received))
+      let opaque = this.received.ptrify()
+      this.responseObject = JS_NewArrayBuffer(ctx, p, len, abufFree, opaque,
+        false)
+    of xhrtBlob:
+      let len = this.received.len
+      let p = cast[ptr UncheckedArray[uint8]](cstring(this.received))
+      let opaque = this.received.ptrify()
+      let blob = newBlob(p, len, this.getContentType(), blobFree, opaque)
+      this.responseObject = ctx.toJS(blob)
+    of xhrtDocument:
+      #TODO this is certainly not compliant
+      let res = ctx.parseFromString(newDOMParser(), this.received, "text/html")
+      this.responseObject = ctx.toJS(res)
+    of xhrtJSON:
+      this.responseObject = JS_ParseJSON(ctx, cstring(this.received),
+        csize_t(this.received.len), cstring"<input>")
+    else: discard
+  if JS_IsException(this.responseObject):
+    this.responseObject = JS_UNDEFINED
+  return this.responseObject
 
 # Event reflection
 
diff --git a/src/js/encoding.nim b/src/js/encoding.nim
index 7d1bd126..7aae5eb2 100644
--- a/src/js/encoding.nim
+++ b/src/js/encoding.nim
@@ -40,31 +40,29 @@ func newJSTextDecoder(label = "utf-8"; options = TextDecoderOptions()):
 func fatal(this: JSTextDecoder): bool {.jsfget.} =
   return this.errorMode == demFatal
 
-proc decode0(this: JSTextDecoder; ctx: JSContext; input: JSArrayBufferView;
-    stream: bool): JSResult[JSValue] =
-  let H = int(input.abuf.len) - 1
-  var oq = ""
-  for chunk in this.tdctx.decode(input.abuf.p.toOpenArray(0, H), not stream):
-    oq &= chunk
-  if this.tdctx.failed:
-    this.tdctx.failed = false
-    return errTypeError("Failed to decode string")
-  return ok(JS_NewStringLen(ctx, cstring(oq), csize_t(oq.len)))
-
 type TextDecodeOptions = object of JSDict
   stream {.jsdefault.}: bool
 
 #TODO AllowSharedBufferSource
 proc decode(ctx: JSContext; this: JSTextDecoder;
-    input = none(JSArrayBufferView); options = TextDecodeOptions()):
-    JSResult[JSValue] {.jsfunc.} =
+    input = none(JSArrayBufferView); options = TextDecodeOptions()): JSValue
+    {.jsfunc.} =
   if not this.stream:
     this.tdctx = initTextDecoderContext(this.encoding, this.errorMode)
     this.bomSeen = false
   this.stream = options.stream
   if input.isSome:
-    return this.decode0(ctx, input.get, options.stream)
-  return ok(JS_NewString(ctx, ""))
+    let input = input.get
+    let H = int(input.abuf.len) - 1
+    var oq = ""
+    let stream = this.stream
+    for chunk in this.tdctx.decode(input.abuf.p.toOpenArray(0, H), not stream):
+      oq &= chunk
+    if this.tdctx.failed:
+      this.tdctx.failed = false
+      return JS_ThrowTypeError(ctx, "failed to decode string")
+    return JS_NewStringLen(ctx, cstring(oq), csize_t(oq.len))
+  return JS_NewString(ctx, "")
 
 func jencoding(this: JSTextDecoder): string {.jsfget: "encoding".} =
   return $this.encoding
diff --git a/src/loader/loader.nim b/src/loader/loader.nim
index dfc95b8b..c9025153 100644
--- a/src/loader/loader.nim
+++ b/src/loader/loader.nim
@@ -1167,6 +1167,8 @@ proc doRequest*(loader: FileLoader; request: Request): Response =
     r.sread(response.headers) # packet 3
     # Only a stream of the response body may arrive after this point.
     response.body = stream
+    response.resumeFun = proc(outputId: int) =
+      loader.resume(outputId)
   else:
     var msg: string
     r.sread(msg) # packet 1
diff --git a/src/loader/response.nim b/src/loader/response.nim
index 8143ccbf..6463080a 100644
--- a/src/loader/response.nim
+++ b/src/loader/response.nim
@@ -89,13 +89,12 @@ proc close*(response: Response) {.jsfunc.} =
     response.body = nil
 
 func getCharset*(this: Response; fallback: Charset): Charset =
-  if "Content-Type" notin this.headers.table:
-    return fallback
-  let header = this.headers.table["Content-Type"][0].toLowerAscii()
-  let cs = header.getContentTypeAttr("charset").getCharset()
-  if cs == CHARSET_UNKNOWN:
-    return fallback
-  return cs
+  this.headers.table.withValue("Content-Type", p):
+    let header = p[][0].toLowerAscii()
+    let cs = header.getContentTypeAttr("charset").getCharset()
+    if cs != CHARSET_UNKNOWN:
+      return cs
+  return fallback
 
 func getContentType*(this: Response; fallback = "application/octet-stream"):
     string =
diff --git a/src/local/pager.nim b/src/local/pager.nim
index a0c2d221..43f0d8c4 100644
--- a/src/local/pager.nim
+++ b/src/local/pager.nim
@@ -234,10 +234,9 @@ proc reflect(ctx: JSContext; this_val: JSValue; argc: cint;
   let fun = func_data[1]
   return JS_Call(ctx, fun, obj, argc, argv)
 
-proc getter(ctx: JSContext; pager: Pager; a: JSAtom): Option[JSValue]
-    {.jsgetprop.} =
+proc getter(ctx: JSContext; pager: Pager; a: JSAtom): JSValue {.jsgetprop.} =
   if pager.container != nil:
-    let cval = toJS(ctx, pager.container)
+    let cval = ctx.toJS(pager.container)
     let val = JS_GetProperty(ctx, cval, a)
     if JS_IsFunction(ctx, val):
       let func_data = @[cval, val]
@@ -245,11 +244,11 @@ proc getter(ctx: JSContext; pager: Pager; a: JSAtom): Option[JSValue]
         func_data.toJSValueArray())
       JS_FreeValue(ctx, cval)
       JS_FreeValue(ctx, val)
-      return some(fun)
+      return fun
     JS_FreeValue(ctx, cval)
     if not JS_IsUndefined(val):
-      return some(val)
-  return none(JSValue)
+      return val
+  return JS_NULL
 
 proc searchNext(pager: Pager; n = 1) {.jsfunc.} =
   if pager.regex.isSome:
diff --git a/src/types/blob.nim b/src/types/blob.nim
index acffedf8..c0278bde 100644
--- a/src/types/blob.nim
+++ b/src/types/blob.nim
@@ -8,12 +8,13 @@ import monoucha/jstypes
 import utils/mimeguess
 
 type
-  DeallocFun = proc(blob: Blob) {.closure, raises: [].}
+  DeallocFun = proc(opaque, p: pointer) {.nimcall, raises: [].}
 
   Blob* = ref object of RootObj
     size* {.jsget.}: uint64
     ctype* {.jsget: "type".}: string
     buffer*: pointer
+    opaque*: pointer
     deallocFun*: DeallocFun
     fd*: Option[FileHandle]
 
@@ -25,19 +26,24 @@ jsDestructor(Blob)
 jsDestructor(WebFile)
 
 proc newBlob*(buffer: pointer; size: int; ctype: string;
-    deallocFun: DeallocFun): Blob =
+    deallocFun: DeallocFun; opaque: pointer = nil): Blob =
   return Blob(
     buffer: buffer,
     size: uint64(size),
     ctype: ctype,
-    deallocFun: deallocFun
+    deallocFun: deallocFun,
+    opaque: opaque
   )
 
+proc deallocBlob*(opaque, p: pointer) =
+  if p != nil:
+    dealloc(p)
+
 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)
+  if blob.deallocFun != nil:
+    blob.deallocFun(blob.opaque, blob.buffer)
     blob.buffer = nil
 
 proc finalize(file: WebFile) {.jsfin.} =
@@ -58,11 +64,6 @@ 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(
diff --git a/src/version.nim b/src/version.nim
index fe9d331a..fc8d4947 100644
--- a/src/version.nim
+++ b/src/version.nim
@@ -29,4 +29,4 @@ tryImport monoucha/version, "monoucha"
 static:
   checkVersion("chagashi", 0, 5, 4)
   checkVersion("chame", 1, 0, 1)
-  checkVersion("monoucha", 0, 3, 1)
+  checkVersion("monoucha", 0, 4, 0)
diff --git a/test/asserts.js b/test/asserts.js
new file mode 100644
index 00000000..6ef3033a
--- /dev/null
+++ b/test/asserts.js
@@ -0,0 +1,23 @@
+function assert(x, msg) {
+	const mymsg = msg ? ": " + msg : "";
+	if (!x)
+		throw new TypeError("Assertion failed" + mymsg);
+}
+
+function assert_throws(expr, error) {
+	try {
+		eval(expr);
+	} catch (e) {
+		if (e instanceof Error)
+			return;
+	}
+	throw new TypeError("Assertion failed");
+}
+
+function assert_equals(a, b) {
+	assert(a === b, "Expected " + b + " but got " + a);
+}
+
+function assert_instanceof(a, b) {
+	assert(a instanceof b, a + " not an instance of " + b);
+}
diff --git a/test/js/asserts.js b/test/js/asserts.js
index e84f2d71..8471bc87 100644..120000
--- a/test/js/asserts.js
+++ b/test/js/asserts.js
@@ -1,19 +1 @@
-function assert(x, msg) {
-	const mymsg = msg ? ": " + msg : "";
-	if (!x)
-		throw new TypeError("Assertion failed" + mymsg);
-}
-
-function assert_throws(expr, error) {
-	try {
-		eval(expr);
-	} catch (e) {
-		if (e instanceof Error)
-			return;
-	}
-	throw new TypeError("Assertion failed");
-}
-
-function assert_equals(a, b) {
-	assert(a === b, "Expected " + b + " but got " + a);
-}
+../asserts.js
\ No newline at end of file
diff --git a/test/js/console.html b/test/js/console.html
deleted file mode 100644
index 67b244a7..00000000
--- a/test/js/console.html
+++ /dev/null
@@ -1,5 +0,0 @@
-<!doctype html>
-<div>Success</div>
-<script>
-console.log("Hello, world!")
-</script>
diff --git a/test/js/run_js_tests.sh b/test/js/run_js_tests.sh
index ac531a67..3f484449 100755
--- a/test/js/run_js_tests.sh
+++ b/test/js/run_js_tests.sh
@@ -1,11 +1,11 @@
 #!/bin/sh
-if ! test "$CHA_TEST_BIN"
-then	test -f ../../cha && CHA_TEST_BIN=../../cha || CHA_TEST_BIN=cha
+if ! test "$CHA"
+then	test -f ../../cha && CHA=../../cha || CHA=cha
 fi
 failed=0
 for h in *.html
 do	printf '%s\r' "$h"
-	if ! "$CHA_TEST_BIN" -C config.toml "$h" | diff all.expected -
+	if ! "$CHA" -dC config.toml "$h" | diff all.expected -
 	then	failed=$(($failed+1))
 		printf 'FAIL: %s\n' "$h"
 	fi
diff --git a/test/layout/run_layout_tests.sh b/test/layout/run_layout_tests.sh
index 20b85ce1..429d2ba1 100755
--- a/test/layout/run_layout_tests.sh
+++ b/test/layout/run_layout_tests.sh
@@ -1,6 +1,6 @@
 #!/bin/sh
-if test -z "$CHA_TEST_BIN"
-then	test -f ../../cha && CHA_TEST_BIN=../../cha || CHA_TEST_BIN=cha
+if test -z "$CHA"
+then	test -f ../../cha && CHA=../../cha || CHA=cha
 fi
 failed=0
 for h in *.html
@@ -8,12 +8,12 @@ do	printf '%s\r' "$h"
 	expected="$(basename "$h" .html).expected"
 	color_expected="$(basename "$h" .html).color.expected"
 	if test -f "$expected"
-	then	if ! "$CHA_TEST_BIN" -C config.toml "$h" | diff "$expected" -
+	then	if ! "$CHA" -C config.toml "$h" | diff "$expected" -
 		then	failed=$(($failed+1))
 			printf 'FAIL: %s\n' "$h"
 		fi
 	elif test -f "$color_expected"
-	then	if ! "$CHA_TEST_BIN" -C config.color.toml "$h" | diff "$color_expected" -
+	then	if ! "$CHA" -C config.color.toml "$h" | diff "$color_expected" -
 		then	failed=$(($failed+1))
 			printf 'FAIL: %s\n' "$h"
 		fi
diff --git a/test/net/all.expected b/test/net/all.expected
new file mode 100644
index 00000000..35821117
--- /dev/null
+++ b/test/net/all.expected
@@ -0,0 +1 @@
+Success
diff --git a/test/net/asserts.js b/test/net/asserts.js
new file mode 120000
index 00000000..8471bc87
--- /dev/null
+++ b/test/net/asserts.js
@@ -0,0 +1 @@
+../asserts.js
\ No newline at end of file
diff --git a/test/net/config.toml b/test/net/config.toml
new file mode 100644
index 00000000..ce723641
--- /dev/null
+++ b/test/net/config.toml
@@ -0,0 +1,12 @@
+[buffer]
+scripting = true
+
+[display]
+columns = 80
+lines = 24
+pixels-per-column = 9
+pixels-per-line = 18
+force-columns = true
+force-lines = true
+force-pixels-per-column = true
+force-pixels-per-line = true
diff --git a/test/net/ping b/test/net/ping
new file mode 100644
index 00000000..8e554694
--- /dev/null
+++ b/test/net/ping
@@ -0,0 +1 @@
+pong
diff --git a/test/net/run.nim b/test/net/run.nim
new file mode 100644
index 00000000..d27bbef3
--- /dev/null
+++ b/test/net/run.nim
@@ -0,0 +1,47 @@
+import std/asyncdispatch
+import std/asynchttpserver
+import std/os
+import std/posix
+
+import utils/twtstr
+
+proc cb(req: Request) {.async.} =
+  const headers = {"Content-type": "text/html; charset=utf-8"}
+  if req.url.path == "/stop":
+    await req.respond(Http200, "", headers.newHttpHeaders())
+    quit(0)
+  let s = readFile(req.url.path.after('/'))
+  #echo (req.reqMethod, req.url.path, req.headers)
+  await req.respond(Http200, s, headers.newHttpHeaders())
+
+proc runServer(server: AsyncHttpServer) {.async.} =
+  while true:
+    if server.shouldAcceptRequest():
+      await server.acceptRequest(cb)
+    else:
+      # too many concurrent connections, `maxFDs` exceeded
+      # wait 500ms for FDs to be closed
+      await sleepAsync(500)
+
+proc main() {.async.} =
+  var server = newAsyncHttpServer()
+  if paramCount() >= 1 and paramStr(1) == "-x":
+    server.listen(Port(8000))
+    await server.runServer()
+    quit(0)
+  server.listen(Port(0))
+  let port = server.getPort()
+  case fork()
+  of 0:
+    let cmd = getAppFileName().beforeLast('/') & "/run.sh " & $uint16(port)
+    discard execl("/bin/sh", "sh", "-c", cstring(cmd), nil)
+    quit(1)
+  of -1:
+    stderr.write("Failed to start run.sh")
+    quit(1)
+  else:
+    await server.runServer()
+    var x: cint
+    quit(WEXITSTATUS(wait(addr x)))
+
+waitFor main()
diff --git a/test/net/run.sh b/test/net/run.sh
new file mode 100755
index 00000000..1ea90e3a
--- /dev/null
+++ b/test/net/run.sh
@@ -0,0 +1,17 @@
+#!/bin/sh
+
+if ! test "$CHA"
+then	test -f ../../cha && CHA=../../cha || CHA=cha
+fi
+
+failed=0
+for h in *.html
+do	printf '%s\r' "$h"
+	if ! "$CHA" -dC config.toml "http://localhost:$1/$h" | diff all.expected -
+	then	failed=$(($failed+1))
+		printf 'FAIL: %s\n' "$h"
+	fi
+done
+printf '\n'
+$CHA -d "http://localhost:$1/stop" >/dev/null
+exit "$failed"
diff --git a/test/js/xhr.html b/test/net/xhr.html
index 4bd66b95..2d034772 100644
--- a/test/js/xhr.html
+++ b/test/net/xhr.html
@@ -6,12 +6,15 @@
 const x = new XMLHttpRequest();
 assert(x.onreadystatechange === null);
 function myFunction() {
-	console.log("change");
+	;
 }
 x.onreadystatechange = myFunction;
 assert(myFunction === x.onreadystatechange);
 assert(x.readyState === XMLHttpRequest.UNSENT);
-x.open("GET", "");
-document.getElementById("x").textContent = "Success";
+x.open("GET", "ping", false);
+assert_throws("x.responseType = 'document'");
 x.send();
+assert_equals(x.readyState, XMLHttpRequest.DONE);
+assert_equals(x.responseText.trim(), "pong");
+document.getElementById("x").textContent = "Success";
 </script>