diff options
author | bptato <nincsnevem662@gmail.com> | 2024-08-13 22:48:12 +0200 |
---|---|---|
committer | bptato <nincsnevem662@gmail.com> | 2024-08-13 23:03:41 +0200 |
commit | 885a3493b6cad4b4247a200928fe61e41883aaba (patch) | |
tree | 2b823ef18043c775f21b8ad723c826ffdc6b2663 | |
parent | 968de41082280dde47bac7c2bb59522284b4c672 (diff) | |
download | chawan-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.nim | 148 | ||||
-rw-r--r-- | src/io/promise.nim | 11 | ||||
-rw-r--r-- | src/loader/headers.nim | 28 | ||||
-rw-r--r-- | src/loader/loader.nim | 16 | ||||
-rw-r--r-- | src/loader/response.nim | 90 | ||||
-rw-r--r-- | src/types/url.nim | 4 | ||||
-rw-r--r-- | src/utils/twtstr.nim | 2 | ||||
-rw-r--r-- | test/js/headers.html | 12 |
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> |