From e293367dfc2fd7fc50c8610d411cac4a75d6f251 Mon Sep 17 00:00:00 2001 From: Stefan Andres Charsley Date: Wed, 29 Jan 2020 02:22:32 +1300 Subject: [PATCH] feat(http): better binary support & XHR support (#7707) * feat(http): binary upload support * feat(http): better binary support & XHR support * fix: linting issue * chore: moved files from old place to the new one * chore: Updated NativeScript.api.md * feat(http): support both ByteBuffer and String Co-authored-by: Vasil Trifonov --- api-reports/NativeScript.api.md | 4 +- nativescript-core/fetch/LICENSE | 2 +- nativescript-core/fetch/fetch.js | 659 ++++++++++++------ nativescript-core/globals/globals.ts | 1 + .../globals/polyfills/text/package.json | 5 + .../globals/polyfills/text/text.ts | 8 + .../globals/polyfills/xhr/xhr.ts | 2 +- .../http/http-request/http-request.android.ts | 10 +- .../http/http-request/http-request.ios.ts | 13 +- nativescript-core/http/http.d.ts | 19 +- nativescript-core/http/http.ts | 14 + nativescript-core/text/text-common.ts | 112 +++ nativescript-core/text/text.android.ts | 4 +- nativescript-core/text/text.d.ts | 2 + nativescript-core/text/text.ios.ts | 4 +- nativescript-core/xhr/xhr.ts | 655 ++++++++++++----- tests/app/fetch/fetch-tests.ts | 37 +- tests/app/http/http-string-worker.ts | 2 + tests/app/http/http-tests.ts | 42 +- tests/app/test-runner.ts | 2 + tests/app/xhr/xhr-tests.ts | 121 +++- .../java/org/nativescript/widgets/Async.java | 15 +- .../android/org.nativescript.widgets.d.ts | 2 +- 23 files changed, 1249 insertions(+), 486 deletions(-) create mode 100644 nativescript-core/globals/polyfills/text/package.json create mode 100644 nativescript-core/globals/polyfills/text/text.ts create mode 100644 nativescript-core/text/text-common.ts diff --git a/api-reports/NativeScript.api.md b/api-reports/NativeScript.api.md index be3a9b175..c26a8ac56 100644 --- a/api-reports/NativeScript.api.md +++ b/api-reports/NativeScript.api.md @@ -1010,6 +1010,8 @@ export const Http: { export interface HttpContent { raw: any; + toArrayBuffer: () => ArrayBuffer; + toFile: (destinationFilePath?: string) => File; toImage: () => Promise; @@ -1021,7 +1023,7 @@ export interface HttpContent { // @public export interface HttpRequestOptions { - content?: string | FormData; + content?: string | FormData | ArrayBuffer; dontFollowRedirects?: boolean; diff --git a/nativescript-core/fetch/LICENSE b/nativescript-core/fetch/LICENSE index 6065c75b7..5b271c027 100644 --- a/nativescript-core/fetch/LICENSE +++ b/nativescript-core/fetch/LICENSE @@ -1,4 +1,4 @@ -Copyright (c) 2014-2015 GitHub, Inc. +Copyright (c) 2014-2016 GitHub, Inc. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the diff --git a/nativescript-core/fetch/fetch.js b/nativescript-core/fetch/fetch.js index 1e59e4af8..47e9a624d 100644 --- a/nativescript-core/fetch/fetch.js +++ b/nativescript-core/fetch/fetch.js @@ -1,336 +1,539 @@ -(function () { - 'use strict'; +(function () { + "use strict"; - exports.XMLHttpRequest = global.XMLHttpRequest; - exports.FormData = global.FormData; + var support = { + searchParams: "URLSearchParams" in global, + iterable: "Symbol" in global && "iterator" in Symbol, + blob: + "FileReader" in global && + "Blob" in global && + (function () { + try { + new Blob(); + return true; + } catch (e) { + return false; + } + })(), + formData: "FormData" in global, + arrayBuffer: "ArrayBuffer" in global + }; - if (!exports.XMLHttpRequest) { - var xhr = require("../xhr"); - exports.XMLHttpRequest = xhr.XMLHttpRequest; - exports.FormData = xhr.FormData; + function isDataView(obj) { + return obj && DataView.prototype.isPrototypeOf(obj); + } + + if (support.arrayBuffer) { + var viewClasses = [ + "[object Int8Array]", + "[object Uint8Array]", + "[object Uint8ClampedArray]", + "[object Int16Array]", + "[object Uint16Array]", + "[object Int32Array]", + "[object Uint32Array]", + "[object Float32Array]", + "[object Float64Array]" + ]; + + var isArrayBufferView = + ArrayBuffer.isView || + function (obj) { + return ( + obj && viewClasses.indexOf(Object.prototype.toString.call(obj)) > -1 + ); + }; } function normalizeName(name) { - if (typeof name !== 'string') { - name = name.toString(); + if (typeof name !== "string") { + name = String(name); } - if (/[^a-z0-9\-#$%&'*+.\^_`|~]/i.test(name)) { - throw new TypeError('Invalid character in header field name') + if (/[^a-z0-9\-#$%&'*+.^_`|~]/i.test(name)) { + throw new TypeError("Invalid character in header field name"); } - return name.toLowerCase() + return name.toLowerCase(); } function normalizeValue(value) { - if (typeof value !== 'string') { - value = value.toString(); + if (typeof value !== "string") { + value = String(value); } - return value + return value; + } + + // Build a destructive iterator for the value list + function iteratorFor(items) { + var iterator = { + next: function () { + var value = items.shift(); + return { done: value === undefined, value: value }; + } + }; + + if (support.iterable) { + iterator[Symbol.iterator] = function () { + return iterator; + }; + } + + return iterator; } function Headers(headers) { - this.map = {} + this.map = {}; if (headers instanceof Headers) { headers.forEach(function (value, name) { - this.append(name, value) - }, this) - + this.append(name, value); + }, this); + } else if (Array.isArray(headers)) { + headers.forEach(function (header) { + this.append(header[0], header[1]); + }, this); } else if (headers) { Object.getOwnPropertyNames(headers).forEach(function (name) { - this.append(name, headers[name]) - }, this) + this.append(name, headers[name]); + }, this); } } Headers.prototype.append = function (name, value) { - name = normalizeName(name) - value = normalizeValue(value) - var list = this.map[name] - if (!list) { - list = [] - this.map[name] = list - } - list.push(value) - } + name = normalizeName(name); + value = normalizeValue(value); + var oldValue = this.map[name]; + this.map[name] = oldValue ? oldValue + ", " + value : value; + }; - Headers.prototype['delete'] = function (name) { - delete this.map[normalizeName(name)] - } + Headers.prototype["delete"] = function (name) { + delete this.map[normalizeName(name)]; + }; Headers.prototype.get = function (name) { - var values = this.map[normalizeName(name)] - return values ? values[0] : null - } - - Headers.prototype.getAll = function (name) { - return this.map[normalizeName(name)] || [] - } + name = normalizeName(name); + return this.has(name) ? this.map[name] : null; + }; Headers.prototype.has = function (name) { - return this.map.hasOwnProperty(normalizeName(name)) - } + return this.map.hasOwnProperty(normalizeName(name)); + }; Headers.prototype.set = function (name, value) { - this.map[normalizeName(name)] = [normalizeValue(value)] - } + this.map[normalizeName(name)] = normalizeValue(value); + }; Headers.prototype.forEach = function (callback, thisArg) { - Object.getOwnPropertyNames(this.map).forEach(function (name) { - this.map[name].forEach(function (value) { - callback.call(thisArg, value, name, this) - }, this) - }, this) + for (var name in this.map) { + if (this.map.hasOwnProperty(name)) { + callback.call(thisArg, this.map[name], name, this); + } + } + }; + + Headers.prototype.keys = function () { + var items = []; + this.forEach(function (value, name) { + items.push(name); + }); + return iteratorFor(items); + }; + + Headers.prototype.values = function () { + var items = []; + this.forEach(function (value) { + items.push(value); + }); + return iteratorFor(items); + }; + + Headers.prototype.entries = function () { + var items = []; + this.forEach(function (value, name) { + items.push([name, value]); + }); + return iteratorFor(items); + }; + + if (support.iterable) { + Headers.prototype[Symbol.iterator] = Headers.prototype.entries; } function consumed(body) { if (body.bodyUsed) { - return Promise.reject(new TypeError('Already read')) + return Promise.reject(new TypeError("Already read")); } - body.bodyUsed = true + body.bodyUsed = true; } function fileReaderReady(reader) { return new Promise(function (resolve, reject) { reader.onload = function () { - resolve(reader.result) - } + resolve(reader.result); + }; reader.onerror = function () { - reject(reader.error) - } - }) + reject(reader.error); + }; + }); } function readBlobAsArrayBuffer(blob) { - var reader = new FileReader() - reader.readAsArrayBuffer(blob) - return fileReaderReady(reader) + var reader = new FileReader(); + var promise = fileReaderReady(reader); + reader.readAsArrayBuffer(blob); + return promise; } function readBlobAsText(blob) { - var reader = new FileReader() - reader.readAsText(blob) - return fileReaderReady(reader) + var reader = new FileReader(); + var promise = fileReaderReady(reader); + reader.readAsText(blob); + return promise; } - var support = { - blob: 'FileReader' in exports && 'Blob' in exports && (function () { - try { - new Blob(); - return true - } catch (e) { - return false - } - })(), - formData: 'FormData' in exports + function readArrayBufferAsText(buf) { + var view = new Uint8Array(buf); + var chars = new Array(view.length); + + for (var i = 0; i < view.length; i++) { + chars[i] = String.fromCharCode(view[i]); + } + return chars.join(""); + } + + function bufferClone(buf) { + if (buf.slice) { + return buf.slice(0); + } else { + var view = new Uint8Array(buf.byteLength); + view.set(new Uint8Array(buf)); + return view.buffer; + } } function Body() { - this.bodyUsed = false - + this.bodyUsed = false; this._initBody = function (body) { - this._bodyInit = body - if (typeof body === 'string') { - this._bodyText = body + this._bodyInit = body; + if (!body) { + this._bodyText = ""; + } else if (typeof body === "string") { + this._bodyText = body; } else if (support.blob && Blob.prototype.isPrototypeOf(body)) { - this._bodyBlob = body - } else if (support.formData && exports.FormData.prototype.isPrototypeOf(body)) { - this._bodyFormData = body - } else if (!body) { - this._bodyText = '' + this._bodyBlob = body; + } else if (support.formData && FormData.prototype.isPrototypeOf(body)) { + this._bodyFormData = body; + } else if ( + support.searchParams && + URLSearchParams.prototype.isPrototypeOf(body) + ) { + this._bodyText = body.toString(); + } else if (support.arrayBuffer && support.blob && isDataView(body)) { + this._bodyArrayBuffer = bufferClone(body.buffer); + // IE 10-11 can't handle a DataView body. + this._bodyInit = new Blob([this._bodyArrayBuffer]); + } else if ( + support.arrayBuffer && + (ArrayBuffer.prototype.isPrototypeOf(body) || isArrayBufferView(body)) + ) { + this._bodyArrayBuffer = bufferClone(body); } else { - throw new Error('unsupported BodyInit type') + this._bodyText = body = Object.prototype.toString.call(body); } - } + + if (!this.headers.get("content-type")) { + if (typeof body === "string") { + this.headers.set("content-type", "text/plain;charset=UTF-8"); + } else if (this._bodyBlob && this._bodyBlob.type) { + this.headers.set("content-type", this._bodyBlob.type); + } else if ( + support.searchParams && + URLSearchParams.prototype.isPrototypeOf(body) + ) { + this.headers.set( + "content-type", + "application/x-www-form-urlencoded;charset=UTF-8" + ); + } + } + }; if (support.blob) { this.blob = function () { - var rejected = consumed(this) + var rejected = consumed(this); if (rejected) { - return rejected + return rejected; } if (this._bodyBlob) { - return Promise.resolve(this._bodyBlob) + return Promise.resolve(this._bodyBlob); + } else if (this._bodyArrayBuffer) { + return Promise.resolve(new Blob([this._bodyArrayBuffer])); } else if (this._bodyFormData) { - throw new Error('could not read FormData body as blob') + throw new Error("could not read FormData body as blob"); } else { - return Promise.resolve(new Blob([this._bodyText])) + return Promise.resolve(new Blob([this._bodyText])); } - } + }; this.arrayBuffer = function () { - return this.blob().then(readBlobAsArrayBuffer) - } - - this.text = function () { - var rejected = consumed(this) - if (rejected) { - return rejected - } - - if (this._bodyBlob) { - return readBlobAsText(this._bodyBlob) - } else if (this._bodyFormData) { - throw new Error('could not read FormData body as text') + if (this._bodyArrayBuffer) { + return consumed(this) || Promise.resolve(this._bodyArrayBuffer); } else { - return Promise.resolve(this._bodyText) + return this.blob().then(readBlobAsArrayBuffer); } - } - } else { - this.text = function () { - var rejected = consumed(this) - return rejected ? rejected : Promise.resolve(this._bodyText) - } + }; } + this.text = function () { + var rejected = consumed(this); + if (rejected) { + return rejected; + } + + if (this._bodyBlob) { + return readBlobAsText(this._bodyBlob); + } else if (this._bodyArrayBuffer) { + return Promise.resolve(readArrayBufferAsText(this._bodyArrayBuffer)); + } else if (this._bodyFormData) { + throw new Error("could not read FormData body as text"); + } else { + return Promise.resolve(this._bodyText); + } + }; + if (support.formData) { this.formData = function () { - return this.text().then(decode) - } + return this.text().then(decode); + }; } this.json = function () { - return this.text().then(JSON.parse) - } + return this.text().then(JSON.parse); + }; - return this + return this; } // HTTP methods whose capitalization should be normalized - var methods = ['DELETE', 'GET', 'HEAD', 'OPTIONS', 'POST', 'PUT'] + var methods = ["DELETE", "GET", "HEAD", "OPTIONS", "POST", "PUT"]; function normalizeMethod(method) { - var upcased = method.toUpperCase() - return (methods.indexOf(upcased) > -1) ? upcased : method + var upcased = method.toUpperCase(); + return methods.indexOf(upcased) > -1 ? upcased : method; } - function Request(url, options) { - options = options || {} - this.url = url + function Request(input, options) { + options = options || {}; + var body = options.body; - this.credentials = options.credentials || 'omit' - this.headers = new Headers(options.headers) - this.method = normalizeMethod(options.method || 'GET') - this.mode = options.mode || null - this.referrer = null - - if ((this.method === 'GET' || this.method === 'HEAD') && options.body) { - throw new TypeError('Body not allowed for GET or HEAD requests') + if (input instanceof Request) { + if (input.bodyUsed) { + throw new TypeError("Already read"); + } + this.url = input.url; + this.credentials = input.credentials; + if (!options.headers) { + this.headers = new Headers(input.headers); + } + this.method = input.method; + this.mode = input.mode; + this.signal = input.signal; + if (!body && input._bodyInit != null) { + body = input._bodyInit; + input.bodyUsed = true; + } + } else { + this.url = String(input); } - this._initBody(options.body) + + this.credentials = options.credentials || this.credentials || "same-origin"; + if (options.headers || !this.headers) { + this.headers = new Headers(options.headers); + } + this.method = normalizeMethod(options.method || this.method || "GET"); + this.mode = options.mode || this.mode || null; + this.signal = options.signal || this.signal; + this.referrer = null; + + if ((this.method === "GET" || this.method === "HEAD") && body) { + throw new TypeError("Body not allowed for GET or HEAD requests"); + } + this._initBody(body); } + Request.prototype.clone = function () { + return new Request(this, { body: this._bodyInit }); + }; + function decode(body) { - var form = new exports.FormData() - body.trim().split('&').forEach(function (bytes) { - if (bytes) { - var split = bytes.split('=') - var name = split.shift().replace(/\+/g, ' ') - var value = split.join('=').replace(/\+/g, ' ') - form.append(decodeURIComponent(name), decodeURIComponent(value)) + var form = new FormData(); + body + .trim() + .split("&") + .forEach(function (bytes) { + if (bytes) { + var split = bytes.split("="); + var name = split.shift().replace(/\+/g, " "); + var value = split.join("=").replace(/\+/g, " "); + form.append(decodeURIComponent(name), decodeURIComponent(value)); + } + }); + return form; + } + + function parseHeaders(rawHeaders) { + var headers = new Headers(); + // Replace instances of \r\n and \n followed by at least one space or horizontal tab with a space + // https://tools.ietf.org/html/rfc7230#section-3.2 + var preProcessedHeaders = rawHeaders.replace(/\r?\n[\t ]+/g, " "); + preProcessedHeaders.split(/\r?\n/).forEach(function (line) { + var parts = line.split(":"); + var key = parts.shift().trim(); + if (key) { + var value = parts.join(":").trim(); + headers.append(key, value); } - }) - return form + }); + return headers; } - function headers(xhr) { - var head = new Headers() - var pairs = xhr.getAllResponseHeaders().trim().split('\n') - pairs.forEach(function (header) { - var split = header.trim().split(':') - var key = split.shift().trim() - var value = split.join(':').trim() - head.append(key, value) - }) - return head - } - - Body.call(Request.prototype) + Body.call(Request.prototype); function Response(bodyInit, options) { if (!options) { - options = {} + options = {}; } - this._initBody(bodyInit) - this.type = 'default' - this.url = null - this.status = options.status - this.ok = this.status >= 200 && this.status < 300 - this.statusText = options.statusText - this.headers = options.headers instanceof Headers ? options.headers : new Headers(options.headers) - this.url = options.url || '' + this.type = "default"; + this.status = options.status === undefined ? 200 : options.status; + this.ok = this.status >= 200 && this.status < 300; + this.statusText = "statusText" in options ? options.statusText : "OK"; + this.headers = new Headers(options.headers); + this.url = options.url || ""; + this._initBody(bodyInit); } - Body.call(Response.prototype) + Body.call(Response.prototype); + + Response.prototype.clone = function () { + return new Response(this._bodyInit, { + status: this.status, + statusText: this.statusText, + headers: new Headers(this.headers), + url: this.url + }); + }; + + Response.error = function () { + var response = new Response(null, { status: 0, statusText: "" }); + response.type = "error"; + return response; + }; + + var redirectStatuses = [301, 302, 303, 307, 308]; + + Response.redirect = function (url, status) { + if (redirectStatuses.indexOf(status) === -1) { + throw new RangeError("Invalid status code"); + } + + return new Response(null, { status: status, headers: { location: url } }); + }; + + exports.DOMException = global.DOMException; + try { + new exports.DOMException(); + } catch (err) { + exports.DOMException = function (message, name) { + this.message = message; + this.name = name; + var error = Error(message); + this.stack = error.stack; + }; + exports.DOMException.prototype = Object.create(Error.prototype); + exports.DOMException.prototype.constructor = exports.DOMException; + } + + function fetch(input, init) { + return new Promise(function (resolve, reject) { + var request = new Request(input, init); + + if (request.signal && request.signal.aborted) { + return reject(new exports.DOMException("Aborted", "AbortError")); + } + + var xhr = new XMLHttpRequest(); + + function abortXhr() { + xhr.abort(); + } + + xhr.onload = function () { + var options = { + status: xhr.status, + statusText: xhr.statusText, + headers: parseHeaders(xhr.getAllResponseHeaders() || "") + }; + options.url = + "responseURL" in xhr + ? xhr.responseURL + : options.headers.get("X-Request-URL"); + var body = "response" in xhr ? xhr.response : xhr.responseText; + resolve(new Response(body, options)); + }; + + xhr.onerror = function () { + reject(new TypeError("Network request failed")); + }; + + xhr.ontimeout = function () { + reject(new TypeError("Network request failed")); + }; + + xhr.onabort = function () { + reject(new exports.DOMException("Aborted", "AbortError")); + }; + + xhr.open(request.method, request.url, true); + + if (request.credentials === "include") { + xhr.withCredentials = true; + } else if (request.credentials === "omit") { + xhr.withCredentials = false; + } + + if ("responseType" in xhr && support.blob) { + xhr.responseType = "blob"; + } + + request.headers.forEach(function (value, name) { + xhr.setRequestHeader(name, value); + }); + + if (request.signal) { + request.signal.addEventListener("abort", abortXhr); + + xhr.onreadystatechange = function () { + // DONE (success or failure) + if (xhr.readyState === 4) { + request.signal.removeEventListener("abort", abortXhr); + } + }; + } + + xhr.send( + typeof request._bodyInit === "undefined" ? null : request._bodyInit + ); + }); + } + + fetch.polyfill = true; exports.Headers = Headers; exports.Request = Request; exports.Response = Response; + exports.fetch = fetch; - exports.fetch = function (input, init) { - // TODO: Request constructor should accept input, init - var request - if (Request.prototype.isPrototypeOf(input) && !init) { - request = input - } else { - request = new Request(input, init) - } - - return new Promise(function (resolve, reject) { - var xhr = new exports.XMLHttpRequest() - - function responseURL() { - if ('responseURL' in xhr) { - return xhr.responseURL - } - - // Avoid security warnings on getResponseHeader when not allowed by CORS - if (/^X-Request-URL:/m.test(xhr.getAllResponseHeaders())) { - return xhr.getResponseHeader('X-Request-URL') - } - - return; - } - - xhr.onload = function () { - var status = (xhr.status === 1223) ? 204 : xhr.status - if (status < 100 || status > 599) { - reject(new TypeError('Network request failed')) - return - } - var options = { - status: status, - statusText: xhr.statusText, - headers: headers(xhr), - url: responseURL() - } - //var body = 'response' in xhr ? xhr.response : xhr.responseText; - resolve(new Response(xhr.responseText, options)) - } - - xhr.onerror = function (error) { - reject(new TypeError(['Network request failed:', error.message].join(' '))) - } - - xhr.open(request.method, request.url, true) - - if (request.credentials === 'include') { - xhr.withCredentials = true - } - - if ('responseType' in xhr && support.blob) { - xhr.responseType = 'blob' - } - - request.headers.forEach(function (value, name) { - xhr.setRequestHeader(name, value) - }) - - xhr.send(typeof request._bodyInit === 'undefined' ? null : request._bodyInit) - }) - } - exports.fetch.polyfill = true - -})(); \ No newline at end of file + Object.defineProperty(exports, "__esModule", { value: true }); +})(); diff --git a/nativescript-core/globals/globals.ts b/nativescript-core/globals/globals.ts index 005216cfb..55957821c 100644 --- a/nativescript-core/globals/globals.ts +++ b/nativescript-core/globals/globals.ts @@ -3,6 +3,7 @@ import "./core"; import "./polyfills/timers"; import "./polyfills/animation"; import "./polyfills/dialogs"; +import "./polyfills/text"; import "./polyfills/xhr"; import "./polyfills/fetch"; diff --git a/nativescript-core/globals/polyfills/text/package.json b/nativescript-core/globals/polyfills/text/package.json new file mode 100644 index 000000000..92a163063 --- /dev/null +++ b/nativescript-core/globals/polyfills/text/package.json @@ -0,0 +1,5 @@ +{ + "name" : "text", + "main" : "text", + "nativescript": {} +} diff --git a/nativescript-core/globals/polyfills/text/text.ts b/nativescript-core/globals/polyfills/text/text.ts new file mode 100644 index 000000000..61c2bfc2a --- /dev/null +++ b/nativescript-core/globals/polyfills/text/text.ts @@ -0,0 +1,8 @@ +import "../../core"; +import "../../polyfills/xhr"; + +import { installPolyfills } from "../polyfill-helpers"; + +global.registerModule("text", () => require("../../../text")); + +installPolyfills("text", ["TextDecoder", "TextEncoder"]); diff --git a/nativescript-core/globals/polyfills/xhr/xhr.ts b/nativescript-core/globals/polyfills/xhr/xhr.ts index b26133b2a..64fe1294a 100644 --- a/nativescript-core/globals/polyfills/xhr/xhr.ts +++ b/nativescript-core/globals/polyfills/xhr/xhr.ts @@ -3,4 +3,4 @@ import { installPolyfills } from "../polyfill-helpers"; global.registerModule("xhr", () => require("../../../xhr")); -installPolyfills("xhr", ["XMLHttpRequest", "FormData"]); +installPolyfills("xhr", ["XMLHttpRequest", "FormData", "Blob", "File", "FileReader"]); diff --git a/nativescript-core/http/http-request/http-request.android.ts b/nativescript-core/http/http-request/http-request.android.ts index 350a1d432..b9be9098b 100644 --- a/nativescript-core/http/http-request/http-request.android.ts +++ b/nativescript-core/http/http-request/http-request.android.ts @@ -92,6 +92,7 @@ function onRequestComplete(requestId: number, result: org.nativescript.widgets.A callbacks.resolveCallback({ content: { raw: result.raw, + toArrayBuffer: () => Uint8Array.from(result.raw.toByteArray()).buffer, toString: (encoding?: HttpResponseEncoding) => { let str: string; if (encoding) { @@ -180,7 +181,14 @@ function buildJavaOptions(options: httpModule.HttpRequestOptions) { javaOptions.method = options.method; } if (typeof options.content === "string" || options.content instanceof FormData) { - javaOptions.content = options.content.toString(); + const nativeString = new java.lang.String(options.content.toString()); + const nativeBytes = nativeString.getBytes("UTF-8"); + const nativeBuffer = java.nio.ByteBuffer.wrap(nativeBytes); + javaOptions.content = nativeBuffer; + } else if (options.content instanceof ArrayBuffer) { + const typedArray = new Uint8Array(options.content as ArrayBuffer); + const nativeBuffer = java.nio.ByteBuffer.wrap(Array.from(typedArray)); + javaOptions.content = nativeBuffer; } if (typeof options.timeout === "number") { javaOptions.timeout = options.timeout; diff --git a/nativescript-core/http/http-request/http-request.ios.ts b/nativescript-core/http/http-request/http-request.ios.ts index b295e7f7e..3f524c4cd 100644 --- a/nativescript-core/http/http-request/http-request.ios.ts +++ b/nativescript-core/http/http-request/http-request.ios.ts @@ -95,6 +95,9 @@ export function request(options: httpModule.HttpRequestOptions): Promise NSDataToString(data, encoding), + toArrayBuffer: () => interop.bufferFromData(data), + toString: (encoding?: any) => { + const str = NSDataToString(data, encoding); + if (typeof str === "string") { + return str; + } else { + throw new Error("Response content may not be converted to string"); + } + }, toJSON: (encoding?: any) => parseJSON(NSDataToString(data, encoding)), toImage: () => { ensureImageSource(); diff --git a/nativescript-core/http/http.d.ts b/nativescript-core/http/http.d.ts index 3dc5d85e9..35e3ca1ef 100644 --- a/nativescript-core/http/http.d.ts +++ b/nativescript-core/http/http.d.ts @@ -56,6 +56,18 @@ export function getFile(url: string, destinationFilePath?: string): Promise; +/** + * Downloads the content from the specified URL as binary and returns an ArrayBuffer. + * @param url The URL to request from. + */ +export function getBinary(url: string): Promise; + +/** + * Downloads the content from the specified URL as binary and returns an ArrayBuffer. + * @param options An object that specifies various request options. + */ +export function getBinary(options: HttpRequestOptions): Promise; + /** * Makes a generic http request using the provided options and returns a HttpResponse Object. * @param options An object that specifies various request options. @@ -84,7 +96,7 @@ export interface HttpRequestOptions { /** * Gets or sets the request body. */ - content?: string | FormData; + content?: string | FormData | ArrayBuffer; /** * Gets or sets the request timeout in milliseconds. @@ -132,6 +144,11 @@ export interface HttpContent { */ raw: any; + /** + * Gets the response body as ArrayBuffer + */ + toArrayBuffer: () => ArrayBuffer; + /** * Gets the response body as string. */ diff --git a/nativescript-core/http/http.ts b/nativescript-core/http/http.ts index 652e7a561..f2e391adf 100644 --- a/nativescript-core/http/http.ts +++ b/nativescript-core/http/http.ts @@ -58,3 +58,17 @@ export function getFile(arg: any, destinationFilePath?: string): Promise { }, e => reject(e)); }); } + +export function getBinary(arg: any): Promise { + return new Promise((resolve, reject) => { + httpRequest.request(typeof arg === "string" ? { url: arg, method: "GET" } : arg) + .then(r => { + try { + const arrayBuffer = r.content.toArrayBuffer(); + resolve(arrayBuffer); + } catch (e) { + reject(e); + } + }, e => reject(e)); + }); +} diff --git a/nativescript-core/text/text-common.ts b/nativescript-core/text/text-common.ts new file mode 100644 index 000000000..501e78e0e --- /dev/null +++ b/nativescript-core/text/text-common.ts @@ -0,0 +1,112 @@ + +const Object_prototype_toString = ({}).toString; +const ArrayBufferString = Object_prototype_toString.call(ArrayBuffer.prototype); + +function decoderReplacer(encoded) { + var codePoint = encoded.charCodeAt(0) << 24; + var leadingOnes = Math.clz32(~codePoint) | 0; + var endPos = 0, stringLen = encoded.length | 0; + var result = ""; + if (leadingOnes < 5 && stringLen >= leadingOnes) { + codePoint = (codePoint << leadingOnes) >>> (24 + leadingOnes); + for (endPos = 1; endPos < leadingOnes; endPos = endPos + 1 | 0) { + codePoint = (codePoint << 6) | (encoded.charCodeAt(endPos) & 0x3f/*0b00111111*/); + } + if (codePoint <= 0xFFFF) { // BMP code point + result += String.fromCharCode(codePoint); + } else if (codePoint <= 0x10FFFF) { + // https://mathiasbynens.be/notes/javascript-encoding#surrogate-formulae + codePoint = codePoint - 0x10000 | 0; + result += String.fromCharCode( + (codePoint >> 10) + 0xD800 | 0, // highSurrogate + (codePoint & 0x3ff) + 0xDC00 | 0 // lowSurrogate + ); + } else { endPos = 0; } // to fill it in with INVALIDs + } + for (; endPos < stringLen; endPos = endPos + 1 | 0) { result += "\ufffd"; } + + return result; +} + +function encoderReplacer(nonAsciiChars) { + // make the UTF string into a binary UTF-8 encoded string + var point = nonAsciiChars.charCodeAt(0) | 0; + if (point >= 0xD800 && point <= 0xDBFF) { + var nextcode = nonAsciiChars.charCodeAt(1) | 0; + if (nextcode !== nextcode) { // NaN because string is 1 code point long + return String.fromCharCode(0xef/*11101111*/, 0xbf/*10111111*/, 0xbd/*10111101*/); + } + // https://mathiasbynens.be/notes/javascript-encoding#surrogate-formulae + if (nextcode >= 0xDC00 && nextcode <= 0xDFFF) { + point = ((point - 0xD800) << 10) + nextcode - 0xDC00 + 0x10000 | 0; + if (point > 0xffff) { + return String.fromCharCode( + (0x1e /*0b11110*/ << 3) | (point >>> 18), + (0x2 /*0b10*/ << 6) | ((point >>> 12) & 0x3f/*0b00111111*/), + (0x2 /*0b10*/ << 6) | ((point >>> 6) & 0x3f/*0b00111111*/), + (0x2 /*0b10*/ << 6) | (point & 0x3f/*0b00111111*/) + ); + } + } else { return String.fromCharCode(0xef, 0xbf, 0xbd); } + } + if (point <= 0x007f) { return nonAsciiChars; } + else if (point <= 0x07ff) { + return String.fromCharCode((0x6 << 5) | (point >>> 6), (0x2 << 6) | (point & 0x3f)); + } else { + return String.fromCharCode( + (0xe /*0b1110*/ << 4) | (point >>> 12), + (0x2 /*0b10*/ << 6) | ((point >>> 6) & 0x3f/*0b00111111*/), + (0x2 /*0b10*/ << 6) | (point & 0x3f/*0b00111111*/) + ); + } +} + +export class TextDecoder { + public get encoding() { + return "utf-8"; + } + + public decode(input: BufferSource): string { + const buffer = ArrayBuffer.isView(input) ? input.buffer : input; + if (Object_prototype_toString.call(buffer) !== ArrayBufferString) { + throw Error("Failed to execute 'decode' on 'TextDecoder': The provided value is not of type '(ArrayBuffer or ArrayBufferView)'"); + } + let inputAs8 = new Uint8Array(buffer); + let resultingString = ""; + for (let index = 0, len = inputAs8.length | 0; index < len; index = index + 32768 | 0) { + resultingString += String.fromCharCode.apply(0, inputAs8.slice(index, index + 32768 | 0)); + } + + return resultingString.replace(/[\xc0-\xff][\x80-\xbf]*/g, decoderReplacer); + } + + public toString() { + return "[object TextDecoder]"; + } + + [Symbol.toStringTag] = "TextDecoder"; +} + +export class TextEncoder { + public get encoding() { + return "utf-8"; + } + + public encode(input: string = ""): Uint8Array { + // 0xc0 => 0b11000000; 0xff => 0b11111111; 0xc0-0xff => 0b11xxxxxx + // 0x80 => 0b10000000; 0xbf => 0b10111111; 0x80-0xbf => 0b10xxxxxx + const encodedString = input === undefined ? "" : ("" + input).replace(/[\x80-\uD7ff\uDC00-\uFFFF]|[\uD800-\uDBFF][\uDC00-\uDFFF]?/g, encoderReplacer); + const len = encodedString.length | 0, result = new Uint8Array(len); + for (let i = 0; i < len; i = i + 1 | 0) { + result[i] = encodedString.charCodeAt(i); + } + + return result; + } + + public toString() { + return "[object TextEncoder]"; + } + + [Symbol.toStringTag] = "TextEncoder"; +} \ No newline at end of file diff --git a/nativescript-core/text/text.android.ts b/nativescript-core/text/text.android.ts index b1e8bdc07..7ee4da99c 100644 --- a/nativescript-core/text/text.android.ts +++ b/nativescript-core/text/text.android.ts @@ -1,4 +1,6 @@ -export module encoding { +export * from "./text-common"; + +export module encoding { export const ISO_8859_1 = "ISO-8859-1"; export const US_ASCII = "US-ASCII"; export const UTF_16 = "UTF-16"; diff --git a/nativescript-core/text/text.d.ts b/nativescript-core/text/text.d.ts index 7e572947e..4fa2515eb 100644 --- a/nativescript-core/text/text.d.ts +++ b/nativescript-core/text/text.d.ts @@ -3,6 +3,8 @@ * @module "text" */ /** */ +export * from "./text-common"; + /** * Defines the supported character encodings. */ diff --git a/nativescript-core/text/text.ios.ts b/nativescript-core/text/text.ios.ts index df6d47e13..78fc309a7 100644 --- a/nativescript-core/text/text.ios.ts +++ b/nativescript-core/text/text.ios.ts @@ -1,4 +1,6 @@ -export module encoding { +export * from "./text-common"; + +export module encoding { export const ISO_8859_1 = 5; //NSISOLatin1StringEncoding export const US_ASCII = 1; //NSASCIIStringEncoding export const UTF_16 = 10; //NSUnicodeStringEncoding diff --git a/nativescript-core/xhr/xhr.ts b/nativescript-core/xhr/xhr.ts index 24430c65d..4b644dd35 100644 --- a/nativescript-core/xhr/xhr.ts +++ b/nativescript-core/xhr/xhr.ts @@ -5,6 +5,8 @@ module XMLHttpRequestResponseType { export const empty = ""; export const text = "text"; export const json = "json"; + export const blob = "blob"; + export const arraybuffer = "arraybuffer"; } export class XMLHttpRequest { @@ -14,8 +16,12 @@ export class XMLHttpRequest { public LOADING = 3; public DONE = 4; - public onload: () => void; - public onerror: (any) => void; + public onabort: (...args: any[]) => void; + public onerror: (...args: any[]) => void; + public onload: (...args: any[]) => void; + public onloadend: (...args: any[]) => void; + public onloadstart: (...args: any[]) => void; + public onprogress: (...args: any[]) => void; private _options: http.HttpRequestOptions; private _readyState: number; @@ -24,14 +30,168 @@ export class XMLHttpRequest { private _responseTextReader: Function; private _headers: any; private _errorFlag: boolean; + private _sendFlag: boolean; private _responseType: string = ""; + private _overrideMimeType: string; + + private _listeners: Map> = new Map>(); public onreadystatechange: Function; + public get upload() { + return this; + } + + public get readyState(): number { + return this._readyState; + } + + public get responseType(): string { + return this._responseType; + } + + public set responseType(value: string) { + if (value === XMLHttpRequestResponseType.empty + || value in XMLHttpRequestResponseType) { + this._responseType = value; + } else { + throw new Error(`Response type of '${value}' not supported.`); + } + } + + public get responseText(): string { + if (this._responseType !== XMLHttpRequestResponseType.empty + && this._responseType !== XMLHttpRequestResponseType.text) { + throw new Error( + "Failed to read the 'responseText' property from 'XMLHttpRequest': " + + "The value is only accessible if the object's 'responseType' is '' or 'text' " + + `(was '${this._responseType}').` + ); + } + + if (types.isFunction(this._responseTextReader)) { + return this._responseTextReader(); + } + + return ""; + } + + public get response(): any { + if (this._responseType === XMLHttpRequestResponseType.empty + || this._responseType === XMLHttpRequestResponseType.text) { + if (this._readyState !== this.LOADING && this._readyState !== this.DONE) { + return ""; + } else { + return this._response; + } + } else { + if (this._readyState !== this.DONE) { + return null; + } else { + return this._response; + } + } + } + + public get status(): number { + return this._status; + } + + public get statusText(): string { + if (this._readyState === this.UNSENT + || this._readyState === this.OPENED + || this._errorFlag) { + return ""; + } + + return statuses[this._status]; + } + constructor() { this._readyState = this.UNSENT; } + private _loadResponse(r: http.HttpResponse) { + this._status = r.statusCode; + this._headers = r.headers; + this._setReadyState(this.HEADERS_RECEIVED); + + this._setReadyState(this.LOADING); + + this._responseTextReader = () => r.content.toString(); + + const contentType = this.getResponseHeader("Content-Type"); + const mimeType = (contentType && contentType.toLowerCase()) || "text/xml"; + const finalMimeType = this._overrideMimeType || mimeType; + + if (this._responseType === XMLHttpRequestResponseType.json) { + this._response = r.content.toJSON(); + } else if (this._responseType === XMLHttpRequestResponseType.text + || this._responseType === XMLHttpRequestResponseType.empty) { + this._response = this.responseText; + } else if (this._responseType === XMLHttpRequestResponseType.arraybuffer) { + this._response = r.content.toArrayBuffer(); + } else if (this._responseType === XMLHttpRequestResponseType.blob) { + this._response = new Blob([r.content.toArrayBuffer()], { type: finalMimeType }); + } + + this.emitEvent("progress"); + + this._sendFlag = false; + this._setReadyState(this.DONE); + } + + private emitEvent(eventName: string, ...args: Array) { + if (types.isFunction(this["on" + eventName])) { + this["on" + eventName](...args); + } + + let handlers = this._listeners.get(eventName) || []; + handlers.forEach((handler) => { + handler(...args); + }); + } + + private _setReadyState(value: number) { + if (this._readyState !== value) { + this._readyState = value; + this.emitEvent("readystatechange"); + } + + if (this._readyState === this.DONE) { + this.emitEvent("load"); + this.emitEvent("loadend"); + } + } + + private _setRequestError(eventName: string, error?: any) { + this._readyState = this.DONE; + + this._response = error; + + this.emitEvent("readystatechange"); + + this.emitEvent(eventName, error); + + this.emitEvent("loadend"); + } + + public addEventListener(eventName: string, handler: Function) { + if (["abort", "error", "load", "loadend", "loadstart", "progress"].indexOf(eventName) === -1) { + throw new Error("Event not supported: " + eventName); + } + + let handlers = this._listeners.get(eventName) || []; + handlers.push(handler); + this._listeners.set(eventName, handlers); + } + + public removeEventListener(eventName: string, toDetach: Function) { + let handlers = this._listeners.get(eventName) || []; + handlers = handlers.filter((handler) => handler !== toDetach); + this._listeners.set(eventName, handlers); + } + public open(method: string, url: string, async?: boolean, user?: string, password?: string) { if (types.isString(method) && types.isString(url)) { this._options = { url: url, method: method }; @@ -50,17 +210,21 @@ export class XMLHttpRequest { } public abort() { - this._errorFlag = true; - this._response = null; this._responseTextReader = null; this._headers = null; this._status = null; - if (this._readyState === this.UNSENT || this._readyState === this.OPENED || this._readyState === this.DONE) { + if ((this._readyState === this.OPENED && this._sendFlag) + || this._readyState === this.HEADERS_RECEIVED + || this._readyState === this.LOADING) { + this._errorFlag = true; + this._sendFlag = false; + this._setRequestError("abort"); + } + + if (this._readyState === this.DONE) { this._readyState = this.UNSENT; - } else { - this._setReadyState(this.DONE); } } @@ -71,125 +235,51 @@ export class XMLHttpRequest { this._headers = null; this._status = null; - if (types.isDefined(this._options)) { - if (types.isString(data) && this._options.method !== "GET") { - //The Android Java HTTP lib throws an exception if we provide a - //a request body for GET requests, so we avoid doing that. - //Browser implementations silently ignore it as well. - this._options.content = data; - } else if (data instanceof FormData) { - this._options.content = (data).toString(); + if (this._readyState !== this.OPENED || this._sendFlag) { + throw new Error( + "Failed to execute 'send' on 'XMLHttpRequest': " + + "The object's state must be OPENED." + ); + } + + if (types.isString(data) && this._options.method !== "GET") { + //The Android Java HTTP lib throws an exception if we provide a + //a request body for GET requests, so we avoid doing that. + //Browser implementations silently ignore it as well. + this._options.content = data; + } else if (data instanceof FormData) { + this._options.content = (data).toString(); + } else if (data instanceof Blob) { + this.setRequestHeader("Content-Type", data.type); + this._options.content = Blob.InternalAccessor.getBuffer(data); + } else if (data instanceof ArrayBuffer) { + this._options.content = data; + } + + this._sendFlag = true; + + this.emitEvent("loadstart"); + + http.request(this._options).then(r => { + if (!this._errorFlag && this._sendFlag) { + this._loadResponse(r); } - - http.request(this._options).then(r => { - if (!this._errorFlag) { - this._loadResponse(r); - } - - }).catch(e => { - this._errorFlag = true; - this._setReadyState(this.DONE, e); - }); - } - } - - private _loadResponse(r) { - this._status = r.statusCode; - this._response = r.content.raw + ""; - this._headers = r.headers; - this._setReadyState(this.HEADERS_RECEIVED); - - this._setReadyState(this.LOADING); - - this._setResponseType(); - - this._responseTextReader = () => r.content.toString(); - this._addToStringOnResponse(); - - if (this.responseType === XMLHttpRequestResponseType.json) { - this._response = JSON.parse(this.responseText); - } else if (this.responseType === XMLHttpRequestResponseType.text) { - this._response = this.responseText; - } - - this._setReadyState(this.DONE); - } - - private _addToStringOnResponse() { - // Add toString() method to ease debugging and - // make Angular2 response.text() method work properly. - if (types.isObject(this.response)) { - Object.defineProperty(this._response, "toString", { - configurable: true, - enumerable: false, - writable: true, - value: () => this.responseText - }); - } - } - - private textTypes: string[] = [ - "text/plain", - "application/xml", - "application/rss+xml", - "text/html", - "text/xml" - ]; - - private isTextContentType(contentType: string): boolean { - let result = false; - for (let i = 0; i < this.textTypes.length; i++) { - if (contentType.toLowerCase().indexOf(this.textTypes[i]) >= 0) { - result = true; - break; - } - } - - return result; - } - - private _setResponseType() { - const header = this.getResponseHeader("Content-Type"); - const contentType = header && header.toLowerCase(); - - if (contentType) { - if (contentType.indexOf("application/json") >= 0 || contentType.indexOf("+json") >= 0) { - this.responseType = XMLHttpRequestResponseType.json; - } else if (this.isTextContentType(contentType)) { - this.responseType = XMLHttpRequestResponseType.text; - } - } else { - this.responseType = XMLHttpRequestResponseType.text; - } - } - - private _listeners: Map> = new Map>(); - - public addEventListener(eventName: string, handler: Function) { - if (eventName !== "load" && eventName !== "error" && eventName !== "progress") { - throw new Error("Event not supported: " + eventName); - } - - let handlers = this._listeners.get(eventName) || []; - handlers.push(handler); - this._listeners.set(eventName, handlers); - } - - public removeEventListener(eventName: string, toDetach: Function) { - let handlers = this._listeners.get(eventName) || []; - handlers = handlers.filter((handler) => handler !== toDetach); - this._listeners.set(eventName, handlers); - } - - private emitEvent(eventName: string, ...args: Array) { - let handlers = this._listeners.get(eventName) || []; - handlers.forEach((handler) => { - handler(...args); + }).catch(e => { + this._errorFlag = true; + this._sendFlag = false; + this._setRequestError("error", e); }); } public setRequestHeader(header: string, value: string) { - if (types.isDefined(this._options) && types.isString(header) && types.isString(value)) { + if (this._readyState !== this.OPENED || this._sendFlag) { + throw new Error( + "Failed to execute 'setRequestHeader' on 'XMLHttpRequest': " + + "The object's state must be OPENED." + ); + } + + if (types.isString(header) && types.isString(value)) { this._options.headers[header] = value; } } @@ -225,71 +315,14 @@ export class XMLHttpRequest { } public overrideMimeType(mime: string) { - // - } - - get readyState(): number { - return this._readyState; - } - - public get responseType(): string { - return this._responseType; - } - - public set responseType(value: string) { - if (value === XMLHttpRequestResponseType.empty || value in XMLHttpRequestResponseType) { - this._responseType = value; - } else { - throw new Error(`Response type of '${value}' not supported.`); - } - } - - private _setReadyState(value: number, error?: any) { - if (this._readyState !== value) { - this._readyState = value; - - if (types.isFunction(this.onreadystatechange)) { - this.onreadystatechange(); - } + if (this._readyState === this.LOADING || this._readyState === this.DONE) { + throw new Error( + "Failed to execute 'overrideMimeType' on 'XMLHttpRequest': " + + "MimeType cannot be overridden when the state is LOADING or DONE." + ); } - if (this._readyState === this.DONE) { - if (this._errorFlag) { - if (types.isFunction(this.onerror)) { - this.onerror(error); - } - this.emitEvent("error", error); - } else { - if (types.isFunction(this.onload)) { - this.onload(); - } - this.emitEvent("load"); - } - } - } - - get responseText(): string { - if (types.isFunction(this._responseTextReader)) { - return this._responseTextReader(); - } - - return ""; - } - - get response(): any { - return this._response; - } - - get status(): number { - return this._status; - } - - get statusText(): string { - if (this._readyState === this.UNSENT || this._readyState === this.OPENED || this._errorFlag) { - return ""; - } - - return statuses[this._status]; + this._overrideMimeType = mime; } } @@ -357,3 +390,257 @@ export class FormData { return arr.join("&"); } } + +export class Blob { + // Note: only for use by XHR + public static InternalAccessor = class { + public static getBuffer(blob: Blob) { + return blob._buffer; + } + }; + + private _buffer: Uint8Array; + private _size: number; + private _type: string; + + public get size() { + return this._size; + } + public get type() { + return this._type; + } + + constructor( + chunks: Array = [], + opts: { type?: string } = {} + ) { + const dataChunks: Uint8Array[] = []; + for (const chunk of chunks) { + if (chunk instanceof Blob) { + dataChunks.push(chunk._buffer); + } else if (typeof chunk === "string") { + const textEncoder = new TextEncoder(); + dataChunks.push(textEncoder.encode(chunk)); + } else if (chunk instanceof DataView) { + dataChunks.push(new Uint8Array(chunk.buffer.slice(0))); + } else if (chunk instanceof ArrayBuffer || ArrayBuffer.isView(chunk)) { + dataChunks.push(new Uint8Array( + ArrayBuffer.isView(chunk) + ? chunk.buffer.slice(0) + : chunk.slice(0) + )); + } else { + const textEncoder = new TextEncoder(); + dataChunks.push(textEncoder.encode(String(chunk))); + } + } + + const size = dataChunks.reduce((size, chunk) => size + chunk.byteLength, 0); + const buffer = new Uint8Array(size); + let offset = 0; + for (let i = 0; i < dataChunks.length; i++) { + const chunk = dataChunks[i]; + buffer.set(chunk, offset); + offset += chunk.byteLength; + } + + this._buffer = buffer; + this._size = this._buffer.byteLength; + + this._type = opts.type || ""; + if (/[^\u0020-\u007E]/.test(this._type)) { + this._type = ""; + } else { + this._type = this._type.toLowerCase(); + } + } + + public arrayBuffer(): Promise { + return Promise.resolve(this._buffer); + } + + public text(): Promise { + const textDecoder = new TextDecoder(); + + return Promise.resolve(textDecoder.decode(this._buffer)); + } + + public slice(start?: number, end?: number, type?: string): Blob { + const slice = this._buffer.slice(start || 0, end || this._buffer.length); + + return new Blob([slice], { type: type }); + } + + public stream() { + throw new Error("stream is currently not supported"); + } + + public toString() { + return "[object Blob]"; + } + + [Symbol.toStringTag] = "Blob"; +} + +export class File extends Blob { + private _name: string; + private _lastModified: number; + + public get name() { + return this._name; + } + + public get lastModified() { + return this._lastModified; + } + + constructor( + chunks: Array, + name: string, + opts: { type?: string, lastModified?: number } = {} + ) { + super(chunks, opts); + this._name = name.replace(/\//g, ":"); + this._lastModified = + opts.lastModified + ? new Date(opts.lastModified).valueOf() + : Date.now(); + } + + public toString() { + return "[object File]"; + } + + [Symbol.toStringTag] = "File"; +} + +export class FileReader { + public EMPTY = 0; + public LOADING = 1; + public DONE = 2; + + public onabort: (...args: any[]) => void; + public onerror: (...args: any[]) => void; + public onload: (...args: any[]) => void; + public onloadend: (...args: any[]) => void; + public onloadstart: (...args: any[]) => void; + public onprogress: (...args: any[]) => void; + + private _readyState: number; + private _result: string | ArrayBuffer | null; + + private _listeners: Map> = new Map>(); + + public get readyState(): number { + return this._readyState; + } + + public get result(): string | ArrayBuffer | null { + return this._result; + } + + constructor() { + // + } + + private _array2base64(input: Uint8Array): string { + var byteToCharMap = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/="; + + var output = []; + + for (var i = 0; i < input.length; i += 3) { + var byte1 = input[i]; + var haveByte2 = i + 1 < input.length; + var byte2 = haveByte2 ? input[i + 1] : 0; + var haveByte3 = i + 2 < input.length; + var byte3 = haveByte3 ? input[i + 2] : 0; + + var outByte1 = byte1 >> 2; + var outByte2 = ((byte1 & 0x03) << 4) | (byte2 >> 4); + var outByte3 = ((byte2 & 0x0F) << 2) | (byte3 >> 6); + var outByte4 = byte3 & 0x3F; + + if (!haveByte3) { + outByte4 = 64; + + if (!haveByte2) { + outByte3 = 64; + } + } + + output.push( + byteToCharMap[outByte1], byteToCharMap[outByte2], + byteToCharMap[outByte3], byteToCharMap[outByte4] + ); + } + + return output.join(""); + } + + private _read(blob, kind) { + if (!(blob instanceof Blob)) { + throw new TypeError(`Failed to execute '${kind}' on 'FileReader': parameter 1 is not of type 'Blob'.`); + } + + this._result = ""; + + setTimeout(() => { + this._readyState = this.LOADING; + this.emitEvent("load"); + this.emitEvent("loadend"); + }); + } + + private emitEvent(eventName: string, ...args: Array) { + if (types.isFunction(this["on" + eventName])) { + this["on" + eventName](...args); + } + + let handlers = this._listeners.get(eventName) || []; + handlers.forEach((handler) => { + handler(...args); + }); + } + + public addEventListener(eventName: string, handler: Function) { + if (["abort", "error", "load", "loadend", "loadstart", "progress"].indexOf(eventName) === -1) { + throw new Error("Event not supported: " + eventName); + } + + let handlers = this._listeners.get(eventName) || []; + handlers.push(handler); + this._listeners.set(eventName, handlers); + } + + public removeEventListener(eventName: string, toDetach: Function) { + let handlers = this._listeners.get(eventName) || []; + handlers = handlers.filter((handler) => handler !== toDetach); + this._listeners.set(eventName, handlers); + } + + public readAsDataURL(blob: Blob) { + this._read(blob, "readAsDataURL"); + this._result = `data:${blob.type};base64,${this._array2base64(Blob.InternalAccessor.getBuffer(blob))}`; + } + + public readAsText(blob: Blob) { + this._read(blob, "readAsText"); + const textDecoder = new TextDecoder(); + this._result = textDecoder.decode(Blob.InternalAccessor.getBuffer(blob)); + } + + public readAsArrayBuffer(blob: Blob) { + this._read(blob, "readAsArrayBuffer"); + this._result = Blob.InternalAccessor.getBuffer(blob).buffer.slice(0); + } + + public abort() { + // + } + + public toString() { + return "[object FileReader]"; + } + + [Symbol.toStringTag] = "FileReader"; +} diff --git a/tests/app/fetch/fetch-tests.ts b/tests/app/fetch/fetch-tests.ts index 46ff5f27c..be12d6312 100644 --- a/tests/app/fetch/fetch-tests.ts +++ b/tests/app/fetch/fetch-tests.ts @@ -54,6 +54,30 @@ export var test_fetch_formData = function (done: (err: Error, res?: string) => v // << fetch-formdata }; +export var test_fetch_blob = function (done: (err: Error, res?: string) => void) { + // >> fetch-blob + fetch("https://httpbin.org/get").then(response => response.blob()).then(function (r) { + // Argument (r) is Blob object! + // >> (hide) + TKUnit.assertNotNull(r, "Result from blob() should be Blob object!"); + done(null); + // << (hide) + }).catch(failOnError(done)); + // << fetch-blob +}; + +export var test_fetch_arraybuffer = function (done: (err: Error, res?: string) => void) { + // >> fetch-arraybuffer + fetch("https://httpbin.org/get").then(response => response.arrayBuffer()).then(function (r) { + // Argument (r) is ArrayBuffer object! + // >> (hide) + TKUnit.assertNotNull(r, "Result from arrayBuffer() should be ArrayBuffer object!"); + done(null); + // << (hide) + }).catch(failOnError(done)); + // << fetch-arraybuffer +}; + export var test_fetch_fail_invalid_url = function (done) { var completed: boolean; var isReady = function () { return completed; }; @@ -64,12 +88,13 @@ export var test_fetch_fail_invalid_url = function (done) { }).catch(failOnError(done)); }; -export var test_fetch_invalid_url_fail_message = function (done) { - fetch("hgfttp://httpbin.org/get").catch(function (e: TypeError) { - TKUnit.assert(e.message.match(/Network request failed:.{2,}/), "Failure message should contain details on the failure. Actual message was: " + e.message); - done(null); - }).catch(failOnError(done)); -}; +// Note: fetch is unable to do url validation +// export var test_fetch_invalid_url_fail_message = function (done) { +// fetch("hgfttp://httpbin.org/get").catch(function (e: TypeError) { +// TKUnit.assert(e.message.match(/Network request failed:.{2,}/), "Failure message should contain details on the failure. Actual message was: " + e.message); +// done(null); +// }).catch(failOnError(done)); +// }; export var test_fetch_response_status = function (done) { diff --git a/tests/app/http/http-string-worker.ts b/tests/app/http/http-string-worker.ts index 224bd001f..7b8c7c9b0 100644 --- a/tests/app/http/http-string-worker.ts +++ b/tests/app/http/http-string-worker.ts @@ -1,5 +1,7 @@ import * as http from "@nativescript/core/http"; +(global).FormData = class FormData {}; + declare var postMessage: any; http.getString("https://httpbin.org/get").then(function (r) { diff --git a/tests/app/http/http-tests.ts b/tests/app/http/http-tests.ts index b719002bc..94e3247fb 100644 --- a/tests/app/http/http-tests.ts +++ b/tests/app/http/http-tests.ts @@ -24,20 +24,21 @@ export var test_getString_fail = function (done) { }); }; -export var test_getString_fail_when_result_is_not_string = function (done) { - var result; +// TODO: should this be kept? many decoders will decode the png data into text (even if it's gibberish) +// export var test_getString_fail_when_result_is_not_string = function (done) { +// var result; - http.getString({ url: "https://httpbin.org/image/png", method: "GET" }).catch(function (e) { - result = e; - try { - TKUnit.assert(result instanceof Error, "Result from getString().catch() should be Error! Current type is " + typeof result); - done(null); - } - catch (err) { - done(err); - } - }); -}; +// http.getString({ url: "https://httpbin.org/image/png", method: "GET" }).then(function (e) { +// result = e; +// try { +// TKUnit.assert(result instanceof Error, "Result from getString().catch() should be Error! Current type is " + typeof result); +// done(null); +// } +// catch (err) { +// done(err); +// } +// }); +// }; export var test_getJSON_isDefined = function () { TKUnit.assert(typeof (http.getJSON) !== "undefined", "Method http.getJSON() should be defined!"); @@ -635,13 +636,16 @@ export var test_request_jsonAsContentSentAndReceivedProperly = function (done) { }); }; -declare var Worker: any; -export var test_getString_WorksProperlyInWorker = function (done) { - let worker = new Worker("./http-string-worker"); - worker.onmessage = function (msg) { +export var test_getString_WorksProperlyInWorker = function(done) { + const HttpStringWorker = require("nativescript-worker-loader!./http-string-worker"); + let worker = new HttpStringWorker(); + console.log("Worker Created"); + worker.onmessage = function(msg) { + console.log("Message received"); done(); }; - worker.onerror = function (e) { + worker.onerror = function(e) { + console.log("errir received"); done(e); }; -}; +}; \ No newline at end of file diff --git a/tests/app/test-runner.ts b/tests/app/test-runner.ts index 1e2a966ed..a2d446cf8 100644 --- a/tests/app/test-runner.ts +++ b/tests/app/test-runner.ts @@ -46,6 +46,8 @@ allTests["PLATFORM"] = platformTests; import * as fsTests from "./file-system/file-system-tests"; allTests["FILE-SYSTEM"] = fsTests; +// Disabled tests as they have external dependencies +// TODO: find a way to run these tests locally, but don't run them on the CI as they are flaky // import * as httpTests from "./http/http-tests"; // allTests["HTTP"] = httpTests; diff --git a/tests/app/xhr/xhr-tests.ts b/tests/app/xhr/xhr-tests.ts index a3faad649..89ae3669f 100644 --- a/tests/app/xhr/xhr-tests.ts +++ b/tests/app/xhr/xhr-tests.ts @@ -110,17 +110,6 @@ export var test_XMLHttpRequest_headersSentAndReceivedProperly = function (done) // }; -export var test_XMLHttpRequest_setResponseTypeShouldNotThrow = function (done) { - try { - var xhr = new XMLHttpRequest(); - (xhr)._setResponseType(); - done(null); - } - catch (err) { - done(err); - } -}; - export var test_XMLHttpRequest_contentSentAndReceivedProperly = function (done) { // // ### Send/receive JSON @@ -128,14 +117,12 @@ export var test_XMLHttpRequest_contentSentAndReceivedProperly = function (done) let xhr = new XMLHttpRequest(); xhr.open("POST", "https://httpbin.org/post"); xhr.setRequestHeader("Content-Type", "application/json"); + xhr.responseType = "json"; xhr.onreadystatechange = function () { if (xhr.readyState > 3) { - var result = JSON.parse(xhr.responseText); - // var valueOne = result["json"]["MyVariableOne"]; // try { - TKUnit.assert(result["json"]["MyVariableOne"] === "ValueOne" && result["json"]["MyVariableTwo"] === "ValueTwo", "Content not sent/received properly!"); - TKUnit.assert(xhr.response.json.MyVariableOne === "ValueOne" && xhr.response.json.MyVariableTwo === "ValueTwo", "Response content not parsed properly!"); + TKUnit.assert(xhr.response.json.MyVariableOne === "ValueOne" && xhr.response.json.MyVariableTwo === "ValueTwo", "Response content not parsed properly!" + JSON.stringify(xhr.response)); done(null); } catch (err) { @@ -182,7 +169,7 @@ export var test_XMLHttpRequest_FormDataContentSentAndReceivedProperly = function }; export var test_XMLHttpRequest_abortShouldCancelonreadystatechange = function (done) { - var flag = false; + let flag = false; // // ### Abort request // ``` JavaScript @@ -198,11 +185,11 @@ export var test_XMLHttpRequest_abortShouldCancelonreadystatechange = function (d xhr.abort(); // ``` // - TKUnit.assert(flag === false, "Content not sent/received properly!"); + TKUnit.assert(flag, "Content not sent/received properly!"); done(null); }; -export var test_XMLHttpRequest_requestShouldBePossibleAfterAbort = function (done) { +export var test_XMLHttpRequest_requestShouldBePossibleAfterAbortedOpen = function (done) { xhr = new XMLHttpRequest(); xhr.open("POST", "https://httpbin.org/post"); xhr.setRequestHeader("Content-Type", "application/json"); @@ -218,12 +205,26 @@ export var test_XMLHttpRequest_requestShouldBePossibleAfterAbort = function (don } } }; - xhr.send(JSON.stringify({ MyVariableOne: "ValueOne", MyVariableTwo: "ValueTwo" })); + xhr.abort(); xhr.send(JSON.stringify({ MyVariableOne: "ValueOne", MyVariableTwo: "ValueTwo" })); }; +export var test_XMLHttpRequest_requestShouldntBePossibleAfterAbortedSentRequest = function () { + xhr = new XMLHttpRequest(); + xhr.open("POST", "https://httpbin.org/post"); + xhr.setRequestHeader("Content-Type", "application/json"); + xhr.send(JSON.stringify({ MyVariableOne: "ValueOne", MyVariableTwo: "ValueTwo" })); + xhr.abort(); + + TKUnit.assertThrows( + () => xhr.send(JSON.stringify({ MyVariableOne: "ValueOne", MyVariableTwo: "ValueTwo" })), + "Didn't raise re-attempt to send after aborted send", + "Failed to execute 'send' on 'XMLHttpRequest': The object's state must be OPENED." + ); +}; + export function test_ignore_zero_length_request_body() { let xhr = new XMLHttpRequest(); xhr.open("GET", "https://httpbin.org/get"); @@ -262,7 +263,7 @@ export function test_xhr_events() { xhr.removeEventListener("error", badEvent); xhr._errorFlag = true; - xhr._setReadyState(xhr.DONE, "error data"); + xhr._setRequestError("error", "error data"); TKUnit.assertEqual(errorCallbackData, "error data"); TKUnit.assertEqual(errorEventData, "error data"); } @@ -282,7 +283,7 @@ export function test_xhr_responseType_rss() { }; xhr._loadResponse(response); - TKUnit.assertEqual(xhr.responseType, "text"); + TKUnit.assertEqual(xhr.responseType, ""); TKUnit.assertEqual(xhr.response, rawRssFeed); } @@ -301,11 +302,11 @@ export function test_xhr_responseType_text() { }; xhr._loadResponse(response); - TKUnit.assertEqual(xhr.responseType, "text"); + TKUnit.assertEqual(xhr.responseType, ""); TKUnit.assertEqual(xhr.response, "response body"); } -export function test_xhr_responseType_switched_to_JSON_if_header_present() { +export function test_xhr_responseType_should_not_switch_to_JSON_if_header_present() { const xhr = new XMLHttpRequest(); const response = { statusCode: 200, @@ -320,11 +321,11 @@ export function test_xhr_responseType_switched_to_JSON_if_header_present() { }; xhr._loadResponse(response); - TKUnit.assertEqual(xhr.responseType, "json"); - TKUnit.assertEqual(xhr.response.data, 42); + TKUnit.assertEqual(xhr.responseType, ""); + TKUnit.assertEqual(xhr.response, "{\"data\": 42}"); } -export function test_xhr_responseType_switched_to_JSON_if_headers_content_type_has_json_suffix() { +export function test_xhr_responseType__should_not_switch_to_JSON_if_headers_content_type_has_json_suffix() { const xhr = new XMLHttpRequest(); const response = { statusCode: 200, @@ -340,8 +341,8 @@ export function test_xhr_responseType_switched_to_JSON_if_headers_content_type_h }; xhr._loadResponse(response); - TKUnit.assertEqual(xhr.responseType, "json"); - TKUnit.assertEqual(xhr.response.data, 42); + TKUnit.assertEqual(xhr.responseType, ""); + TKUnit.assertEqual(xhr.response, "{\"data\": 42}"); } export function test_sets_status_and_statusText(done) { @@ -376,11 +377,13 @@ export function test_responseType(done) { xhr.responseType = ""; xhr.responseType = "text"; xhr.responseType = "json"; + xhr.responseType = "blob"; + xhr.responseType = "arraybuffer"; TKUnit.assertThrows( - () => xhr.responseType = "arraybuffer", + () => xhr.responseType = "document", "Didn't raise on unsupported type.", - "Response type of 'arraybuffer' not supported." + "Response type of 'document' not supported." ); done(null); } @@ -403,9 +406,57 @@ export function test_getResponseHeader() { TKUnit.assertEqual(xhr.getResponseHeader("Content-Type"), "application/json"); } -export function test_soap_content_types_recognized_as_text() { - const xhr = new XMLHttpRequest(); +export var test_XMLHttpRequest_contentReceivedArrayBufferProperly = function (done) { + // + // ### Receive Blob + // ``` JavaScript + let xhr = new XMLHttpRequest(); + xhr.open("GET", "https://httpbin.org/image/jpeg"); + xhr.responseType = "arraybuffer"; + xhr.onreadystatechange = function () { + if (xhr.readyState > 3) { + // + try { + TKUnit.assertEqual(xhr.getResponseHeader("Content-Length"), "35588"); + TKUnit.assertEqual(xhr.getResponseHeader("Content-Type"), "image/jpeg"); + TKUnit.assertEqual((xhr.response as ArrayBuffer).byteLength, 35588); + done(null); + } + catch (err) { + done(err); + } + // + } + }; + xhr.send(); + // ``` + // +}; - TKUnit.assertTrue(xhr.isTextContentType("text/xml"), "text/xml failed to be recognized as a text response type"); - TKUnit.assertTrue(xhr.isTextContentType("application/xml"), "application/xml failed to be recognized as a text response type"); -} +export var test_XMLHttpRequest_contentReceivedBlobProperly = function (done) { + // + // ### Receive Blob + // ``` JavaScript + let xhr = new XMLHttpRequest(); + xhr.open("GET", "https://httpbin.org/image/jpeg"); + xhr.responseType = "blob"; + xhr.onreadystatechange = function () { + if (xhr.readyState > 3) { + // + try { + TKUnit.assertEqual(xhr.getResponseHeader("Content-Length"), "35588"); + TKUnit.assertEqual(xhr.getResponseHeader("Content-Type"), "image/jpeg"); + TKUnit.assertEqual((xhr.response as Blob).size, 35588); + TKUnit.assertEqual((xhr.response as Blob).type, "image/jpeg"); + done(null); + } + catch (err) { + done(err); + } + // + } + }; + xhr.send(); + // ``` + // +}; diff --git a/tns-core-modules-widgets/android/widgets/src/main/java/org/nativescript/widgets/Async.java b/tns-core-modules-widgets/android/widgets/src/main/java/org/nativescript/widgets/Async.java index 0da7062a9..10e88cb41 100644 --- a/tns-core-modules-widgets/android/widgets/src/main/java/org/nativescript/widgets/Async.java +++ b/tns-core-modules-widgets/android/widgets/src/main/java/org/nativescript/widgets/Async.java @@ -26,6 +26,7 @@ import java.net.CookieManager; import java.net.HttpURLConnection; import java.net.MalformedURLException; import java.net.URL; +import java.nio.ByteBuffer; import java.nio.CharBuffer; import java.util.ArrayList; import java.util.List; @@ -326,7 +327,7 @@ public class Async { public String url; public String method; public ArrayList headers; - public String content; + public Object content; public int timeout = -1; public int screenWidth = -1; public int screenHeight = -1; @@ -353,17 +354,21 @@ public class Async { } public void writeContent(HttpURLConnection connection, Stack openedStreams) throws IOException { - if (this.content == null || this.content.getClass() != String.class) { + if (this.content == null) { return; } OutputStream outStream = connection.getOutputStream(); openedStreams.push(outStream); - OutputStreamWriter writer = new OutputStreamWriter(outStream); - openedStreams.push(writer); + if (this.content instanceof String) { + OutputStreamWriter writer = new OutputStreamWriter(outStream); + openedStreams.push(writer); - writer.write((String) this.content); + writer.write((String) this.content); + } else { + outStream.write(((java.nio.ByteBuffer)this.content).array()); + } } } diff --git a/tns-platform-declarations/android/org.nativescript.widgets.d.ts b/tns-platform-declarations/android/org.nativescript.widgets.d.ts index a2e74984d..264abe4b1 100644 --- a/tns-platform-declarations/android/org.nativescript.widgets.d.ts +++ b/tns-platform-declarations/android/org.nativescript.widgets.d.ts @@ -40,7 +40,7 @@ public url: string; public method: string; public headers: java.util.ArrayList; - public content: string; + public content: java.nio.ByteBuffer; public timeout: number; public screenWidth: number; public screenHeight: number;