From c4647e44594d9e0aafd0b0d53b69f47c14c71394 Mon Sep 17 00:00:00 2001 From: Dimitris - Rafail Katsampas Date: Thu, 5 Mar 2026 11:44:12 +0200 Subject: [PATCH 1/6] ref(http-request): Split http module to solve circular deps --- package-lock.json | 34 -- packages/core/http/http-interfaces.ts | 19 +- .../http-request-internal-common.ts | 11 + .../http-request-internal/index.android.ts | 241 ++++++++++++ .../http/http-request-internal/index.d.ts | 28 ++ .../http/http-request-internal/index.ios.ts | 200 ++++++++++ .../http/http-request/http-request-common.ts | 17 + .../core/http/http-request/index.android.ts | 343 +++--------------- packages/core/http/http-request/index.d.ts | 1 + packages/core/http/http-request/index.ios.ts | 271 ++------------ packages/core/http/http-shared.ts | 7 - packages/core/http/index.ts | 4 +- packages/core/image-source/index.android.ts | 4 +- packages/core/image-source/index.ios.ts | 16 +- 14 files changed, 616 insertions(+), 580 deletions(-) create mode 100644 packages/core/http/http-request-internal/http-request-internal-common.ts create mode 100644 packages/core/http/http-request-internal/index.android.ts create mode 100644 packages/core/http/http-request-internal/index.d.ts create mode 100644 packages/core/http/http-request-internal/index.ios.ts delete mode 100644 packages/core/http/http-shared.ts diff --git a/package-lock.json b/package-lock.json index ab79d59288..357063aea7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -242,24 +242,6 @@ } } }, - "node_modules/@angular-devkit/architect/node_modules/chokidar": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", - "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "readdirp": "^4.0.1" - }, - "engines": { - "node": ">= 14.16.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, "node_modules/@angular-devkit/architect/node_modules/jsonc-parser": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz", @@ -280,22 +262,6 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/@angular-devkit/architect/node_modules/readdirp": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", - "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">= 14.18.0" - }, - "funding": { - "type": "individual", - "url": "https://paulmillr.com/funding/" - } - }, "node_modules/@angular-devkit/architect/node_modules/rxjs": { "version": "7.8.2", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", diff --git a/packages/core/http/http-interfaces.ts b/packages/core/http/http-interfaces.ts index 1790113e6b..04237c250d 100644 --- a/packages/core/http/http-interfaces.ts +++ b/packages/core/http/http-interfaces.ts @@ -1,5 +1,6 @@ import type { ImageSource } from '../image-source'; import type { File } from '../file-system'; +import type { BaseHttpContent } from './http-request-internal'; /** * Provides options for the http requests. @@ -39,7 +40,7 @@ export interface HttpRequestOptions { /** * Encapsulates HTTP-response information from an HTTP-request. */ -export interface HttpResponse { +export interface HttpResponse { /** * Gets the response status code. */ @@ -53,7 +54,7 @@ export interface HttpResponse { /** * Gets the response content. */ - content?: HttpContent; + content?: T; } export type Headers = { [key: string]: string | string[] }; @@ -62,15 +63,8 @@ export enum HttpResponseEncoding { UTF8, GBK, } -/** - * Encapsulates the content of an HttpResponse. - */ -export interface HttpContent { - /** - * Gets the response body as raw data. - */ - raw: any; +export interface HttpContentHandler { /** * Gets the response body as ArrayBuffer */ @@ -96,3 +90,8 @@ export interface HttpContent { */ toFile: (destinationFilePath?: string) => File; } + +/** + * Encapsulates the content of an HttpResponse. + */ +export interface HttpContent extends HttpContentHandler, BaseHttpContent {} diff --git a/packages/core/http/http-request-internal/http-request-internal-common.ts b/packages/core/http/http-request-internal/http-request-internal-common.ts new file mode 100644 index 0000000000..24cbbb60ee --- /dev/null +++ b/packages/core/http/http-request-internal/http-request-internal-common.ts @@ -0,0 +1,11 @@ +import type { Headers } from '../http-interfaces'; + +export function _addHeader(headers: Headers, key: string, value: string): void { + if (!headers[key]) { + headers[key] = value; + } else if (Array.isArray(headers[key])) { + headers[key].push(value); + } else { + headers[key] = [headers[key], value]; + } +} diff --git a/packages/core/http/http-request-internal/index.android.ts b/packages/core/http/http-request-internal/index.android.ts new file mode 100644 index 0000000000..3dce375bef --- /dev/null +++ b/packages/core/http/http-request-internal/index.android.ts @@ -0,0 +1,241 @@ +// imported for definition purposes only +import type { Headers, HttpResponse, HttpRequestOptions, HttpContentHandler } from '../../http'; +import { Screen } from '../../platform/screen'; +import * as domainDebugger from '../../debugger'; +import { isObject } from '../../utils'; +import { BaseHttpContent } from '.'; +import { _addHeader } from './http-request-internal-common'; +export { _addHeader } from './http-request-internal-common'; + +interface PendingRequest { + url: string; + contentHandler?: HttpContentHandler; + resolveCallback: (value: HttpResponse> | PromiseLike>>) => void; + rejectCallback: (reason?: any) => void; +} + +let requestIdCounter = 0; +const pendingRequests = new Map(); + +let debugRequests: Map; +if (__DEV__) { + debugRequests = new Map(); +} +let completeCallback: org.nativescript.widgets.Async.CompleteCallback; +function ensureCompleteCallback() { + if (completeCallback) { + return; + } + + completeCallback = new org.nativescript.widgets.Async.CompleteCallback({ + onComplete: function (result: any, context: any) { + // as a context we will receive the id of the request + onRequestComplete(context, result); + }, + onError: function (error: string, context: any) { + onRequestError(error, context); + }, + }); +} + +function onRequestComplete(requestId: number, result: org.nativescript.widgets.Async.Http.RequestResult) { + const callbacks = pendingRequests.get(requestId); + pendingRequests.delete(requestId); + + if (result.error) { + callbacks.rejectCallback(new Error(result.error.toString())); + return; + } + + // read the headers + const headers: Headers = {}; + if (result.headers) { + const jHeaders = result.headers; + const length = jHeaders.size(); + let pair: org.nativescript.widgets.Async.Http.KeyValuePair; + for (let i = 0; i < length; i++) { + pair = jHeaders.get(i); + _addHeader(headers, pair.key, pair.value); + } + } + + if (__DEV__) { + const debugRequestInfo = debugRequests.get(requestId); + + if (debugRequestInfo) { + const debugRequest = debugRequestInfo.debugRequest; + let mime = (headers['Content-Type'] ?? 'text/plain') as string; + if (typeof mime === 'string') { + mime = mime.split(';')[0] ?? 'text/plain'; + } + + debugRequest.mimeType = mime; + debugRequest.data = result.raw; + const debugResponse = { + url: result.url, + status: result.statusCode, + statusText: result.statusText, + headers: headers, + mimeType: mime, + fromDiskCache: false, + timing: { + requestTime: debugRequestInfo.timestamp, + proxyStart: -1, + proxyEnd: -1, + dnsStart: -1, + dnsEnd: -1, + connectStart: -1, + connectEnd: -1, + sslStart: -1, + sslEnd: -1, + serviceWorkerFetchStart: -1, + serviceWorkerFetchReady: -1, + serviceWorkerFetchEnd: -1, + sendStart: -1, + sendEnd: -1, + receiveHeadersEnd: -1, + }, + }; + debugRequest.responseReceived(debugResponse); + debugRequest.loadingFinished(); + debugRequests.delete(requestId); + } + } + + const content = { + raw: result.raw, + requestURL: callbacks.url, + toNativeImage: () => { + return new Promise((resolveImage, rejectImage) => { + if (result.responseAsImage != null) { + resolveImage(result.responseAsImage); + } else { + rejectImage(new Error('Response content may not be converted to an Image')); + } + }); + }, + toNativeString: () => result.responseAsString, + }; + + if (callbacks.contentHandler != null && isObject(callbacks.contentHandler) && !Array.isArray(callbacks.contentHandler)) { + Object.assign(content, callbacks.contentHandler); + } + + callbacks.resolveCallback({ + content, + statusCode: result.statusCode, + headers: headers, + }); +} + +function onRequestError(error: string, requestId: number) { + const callbacks = pendingRequests.get(requestId); + pendingRequests.delete(requestId); + + if (callbacks) { + callbacks.rejectCallback(new Error(error)); + } +} + +function buildJavaOptions(options: HttpRequestOptions) { + if (typeof options.url !== 'string') { + throw new Error('Http request must provide a valid url.'); + } + + const javaOptions = new org.nativescript.widgets.Async.Http.RequestOptions(); + + javaOptions.url = options.url; + + if (typeof options.method === 'string') { + javaOptions.method = options.method; + } + if (typeof options.content === 'string' || options.content instanceof FormData) { + 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; + } + if (typeof options.dontFollowRedirects === 'boolean') { + javaOptions.dontFollowRedirects = options.dontFollowRedirects; + } + + if (options.headers) { + const arrayList = new java.util.ArrayList(); + const pair = org.nativescript.widgets.Async.Http.KeyValuePair; + + for (const key in options.headers) { + arrayList.add(new pair(key, options.headers[key] + '')); + } + + javaOptions.headers = arrayList; + } + + // pass the maximum available image size to the request options in case we need a bitmap conversion + javaOptions.screenWidth = Screen.mainScreen.widthPixels; + javaOptions.screenHeight = Screen.mainScreen.heightPixels; + + return javaOptions; +} + +export function requestInternal(options: HttpRequestOptions, contentHandler?: T): Promise & T>> { + if (options === undefined || options === null) { + // TODO: Shouldn't we throw an error here - defensive programming + return; + } + + return new Promise & T>>((resolve, reject) => { + try { + // initialize the options + const javaOptions = buildJavaOptions(options); + + // // send request data to network debugger + // if (global.__inspector && global.__inspector.isConnected) { + // NetworkAgent.requestWillBeSent(requestIdCounter, options); + // } + + if (__DEV__) { + const network = domainDebugger.getNetwork(); + const debugRequest = network && network.create(); + + if (options.url && debugRequest) { + const timestamp = Date.now() / 1000; + debugRequests.set(requestIdCounter, { + debugRequest, + timestamp, + }); + const request = { + url: options.url, + method: 'GET', + headers: options.headers, + timestamp, + }; + debugRequest.requestWillBeSent(request); + } + } + + // Remember the callbacks so that we can use them when the CompleteCallback is called + pendingRequests.set(requestIdCounter, { + url: options.url, + contentHandler, + resolveCallback: resolve, + rejectCallback: reject, + }); + + ensureCompleteCallback(); + //make the actual async call + org.nativescript.widgets.Async.Http.MakeRequest(javaOptions, completeCallback, new java.lang.Integer(requestIdCounter)); + + // increment the id counter + requestIdCounter++; + } catch (ex) { + reject(ex); + } + }); +} diff --git a/packages/core/http/http-request-internal/index.d.ts b/packages/core/http/http-request-internal/index.d.ts new file mode 100644 index 0000000000..29240a40e4 --- /dev/null +++ b/packages/core/http/http-request-internal/index.d.ts @@ -0,0 +1,28 @@ +import { HttpRequestOptions, HttpResponse, Headers, HttpResponseEncoding, HttpContentHandler } from '../http-interfaces'; + +export interface BaseHttpContent { + /** + * Gets the response body as raw data. + */ + raw: T; + /** + * Gets the request options URL. + */ + requestURL: string; + /** + * Gets the response native image. + */ + toNativeImage: () => Promise; + /** + * Gets the response as native string. + */ + toNativeString: (encoding?: HttpResponseEncoding) => any; +} + +/** + * Makes a generic http request using the provided options and returns a HttpResponse Object. + * @param options An object that specifies various request options. + * @param contentHandler An object that specifies various functions to parse raw response content. + */ +export function requestInternal(options: HttpRequestOptions, contentHandler?: T): Promise & T>>; +export function _addHeader(headers: Headers, key: string, value: string): void; diff --git a/packages/core/http/http-request-internal/index.ios.ts b/packages/core/http/http-request-internal/index.ios.ts new file mode 100644 index 0000000000..068243bb94 --- /dev/null +++ b/packages/core/http/http-request-internal/index.ios.ts @@ -0,0 +1,200 @@ +import { SDK_VERSION } from '../../utils/constants'; +import { isRealDevice } from '../../utils/native-helper'; +import * as types from '../../utils/types'; +import * as domainDebugger from '../../debugger'; +import type { HttpRequestOptions, HttpResponse, Headers, HttpContentHandler } from '../http-interfaces'; +import { HttpResponseEncoding } from '../http-interfaces'; +import { BaseHttpContent } from '.'; +import { _addHeader } from './http-request-internal-common'; +export { _addHeader } from './http-request-internal-common'; + +const currentDevice = UIDevice.currentDevice; +const device = currentDevice.userInterfaceIdiom === UIUserInterfaceIdiom.Phone ? 'Phone' : 'Pad'; +const osVersion = currentDevice.systemVersion; + +const GET = 'GET'; +const USER_AGENT_HEADER = 'User-Agent'; +const USER_AGENT = `Mozilla/5.0 (i${device}; CPU OS ${osVersion.replace('.', '_')} like Mac OS X) AppleWebKit/536.26 (KHTML, like Gecko) Version/${osVersion} Mobile/10A5355d Safari/8536.25`; +// mitigate iOS 18.4 simulator regression +// https://developer.apple.com/forums/thread/777999 +const sessionConfig = SDK_VERSION === 18.4 && !isRealDevice() ? NSURLSessionConfiguration.ephemeralSessionConfiguration : NSURLSessionConfiguration.defaultSessionConfiguration; +const queue = NSOperationQueue.mainQueue; + +@NativeClass +class NSURLSessionTaskDelegateImpl extends NSObject implements NSURLSessionTaskDelegate { + public static ObjCProtocols = [NSURLSessionTaskDelegate]; + public URLSessionTaskWillPerformHTTPRedirectionNewRequestCompletionHandler(session: NSURLSession, task: NSURLSessionTask, response: NSHTTPURLResponse, request: NSURLRequest, completionHandler: (p1: NSURLRequest) => void): void { + completionHandler(null); + } +} +const sessionTaskDelegateInstance: NSURLSessionTaskDelegateImpl = NSURLSessionTaskDelegateImpl.new(); + +let defaultSession; +function ensureDefaultSession() { + if (!defaultSession) { + defaultSession = NSURLSession.sessionWithConfigurationDelegateDelegateQueue(sessionConfig, null, queue); + } +} + +let sessionNotFollowingRedirects; +function ensureSessionNotFollowingRedirects() { + if (!sessionNotFollowingRedirects) { + sessionNotFollowingRedirects = NSURLSession.sessionWithConfigurationDelegateDelegateQueue(sessionConfig, sessionTaskDelegateInstance, queue); + } +} + +export function requestInternal(options: HttpRequestOptions, contentHandler?: T): Promise & T>> { + return new Promise & T>>((resolve, reject) => { + if (!options.url) { + reject(new Error('Request url was empty.')); + return; + } + + try { + const network = domainDebugger.getNetwork(); + const debugRequest = network && network.create(); + + const urlRequest = NSMutableURLRequest.requestWithURL(NSURL.URLWithString(options.url)); + + urlRequest.HTTPMethod = types.isDefined(options.method) ? options.method : GET; + + urlRequest.setValueForHTTPHeaderField(USER_AGENT, USER_AGENT_HEADER); + + if (options.headers) { + for (const header in options.headers) { + urlRequest.setValueForHTTPHeaderField(options.headers[header] + '', header); + } + } + + if (types.isString(options.content) || options.content instanceof FormData) { + urlRequest.HTTPBody = NSString.stringWithString(options.content.toString()).dataUsingEncoding(4); + } else if (options.content instanceof ArrayBuffer) { + const buffer = options.content as ArrayBuffer; + urlRequest.HTTPBody = NSData.dataWithData(buffer as any); + } + + if (types.isNumber(options.timeout)) { + urlRequest.timeoutInterval = options.timeout / 1000; + } + + let session; + if (types.isBoolean(options.dontFollowRedirects) && options.dontFollowRedirects) { + ensureSessionNotFollowingRedirects(); + session = sessionNotFollowingRedirects; + } else { + ensureDefaultSession(); + session = defaultSession; + } + + let timestamp = -1; + const dataTask = session.dataTaskWithRequestCompletionHandler(urlRequest, function (data: NSData, response: NSHTTPURLResponse, error: NSError) { + if (error) { + reject(new Error(error.localizedDescription)); + } else { + const headers: Headers = {}; + if (response && response.allHeaderFields) { + const headerFields = response.allHeaderFields; + + headerFields.enumerateKeysAndObjectsUsingBlock((key, value, stop) => { + _addHeader(headers, key, value); + }); + } + + if (debugRequest) { + debugRequest.mimeType = response.MIMEType; + debugRequest.data = data; + const debugResponse = { + url: options.url, + status: response.statusCode, + statusText: NSHTTPURLResponse.localizedStringForStatusCode(response.statusCode), + headers: headers, + mimeType: response.MIMEType, + fromDiskCache: false, + timing: { + requestTime: timestamp, + proxyStart: -1, + proxyEnd: -1, + dnsStart: -1, + dnsEnd: -1, + connectStart: -1, + connectEnd: -1, + sslStart: -1, + sslEnd: -1, + serviceWorkerFetchStart: -1, + serviceWorkerFetchReady: -1, + serviceWorkerFetchEnd: -1, + sendStart: -1, + sendEnd: -1, + receiveHeadersEnd: -1, + }, + headersSize: headers?.length ?? -1, + }; + debugRequest.responseReceived(debugResponse); + debugRequest.loadingFinished(); + } + + const content = { + raw: data, + requestURL: options.url, + toNativeImage: () => { + return new Promise((resolveImage, rejectImage) => { + UIImage.tns_decodeImageWithDataCompletion(this.raw, (image) => { + if (image) { + resolveImage(image); + } else { + rejectImage(new Error('Response content may not be converted to an Image')); + } + }); + }); + }, + toNativeString: (encoding?: HttpResponseEncoding) => NSDataToString(this.raw, encoding), + }; + + if (contentHandler != null && types.isObject(contentHandler) && !Array.isArray(contentHandler)) { + Object.assign(content, contentHandler); + } + + resolve({ + content: content as BaseHttpContent & T, + statusCode: response.statusCode, + headers: headers, + }); + } + }); + + if (options.url && debugRequest) { + timestamp = Date.now() / 1000; + const request = { + url: options.url, + method: 'GET', + headers: options.headers, + timestamp, + headersSize: options?.headers?.length ?? -1, + }; + debugRequest.requestWillBeSent(request); + } + + dataTask.resume(); + } catch (ex) { + reject(ex); + } + }); +} + +function NSDataToString(data: any, encoding?: HttpResponseEncoding): string { + let code = NSUTF8StringEncoding; // long:4 + + if (encoding === HttpResponseEncoding.GBK) { + code = CFStringEncodings.kCFStringEncodingGB_18030_2000; // long:1586 + } + + let encodedString = NSString.alloc().initWithDataEncoding(data, code); + + // If UTF8 string encoding fails try with ISO-8859-1 + if (!encodedString) { + code = NSISOLatin1StringEncoding; // long:5 + encodedString = NSString.alloc().initWithDataEncoding(data, code); + } + + return encodedString.toString(); +} diff --git a/packages/core/http/http-request/http-request-common.ts b/packages/core/http/http-request/http-request-common.ts index d2088d4817..81a7ef8b31 100644 --- a/packages/core/http/http-request/http-request-common.ts +++ b/packages/core/http/http-request/http-request-common.ts @@ -1,4 +1,6 @@ import { knownFolders, path } from '../../file-system'; +import type { Headers } from '../http-interfaces'; +import { _addHeader } from '../http-request-internal'; export function getFilenameFromUrl(url: string) { const slashPos = url.lastIndexOf('/') + 1; @@ -15,3 +17,18 @@ export function getFilenameFromUrl(url: string) { return result; } + +export function parseJSON(source: string): any { + const src = source.trim(); + const lastIndex = src.lastIndexOf(')'); + + if (lastIndex === src.length - 1) { + return JSON.parse(src.substring(src.indexOf('(') + 1, lastIndex)); + } + + return JSON.parse(src); +} + +export function addHeader(headers: Headers, key: string, value: string): void { + _addHeader(headers, key, value); +} diff --git a/packages/core/http/http-request/index.android.ts b/packages/core/http/http-request/index.android.ts index 52aa3b2698..25f06c9e74 100644 --- a/packages/core/http/http-request/index.android.ts +++ b/packages/core/http/http-request/index.android.ts @@ -1,302 +1,73 @@ // imported for definition purposes only -import type { Headers, HttpResponse, HttpRequestOptions } from '../../http'; +import type { HttpResponse, HttpRequestOptions, HttpContentHandler } from '../../http'; import { ImageSource } from '../../image-source'; -import { Screen } from '../../platform/screen'; import { File } from '../../file-system'; import { HttpResponseEncoding } from '../http-interfaces'; -import { getFilenameFromUrl } from './http-request-common'; -import * as domainDebugger from '../../debugger'; - -function parseJSON(source: string): any { - const src = source.trim(); - if (src.lastIndexOf(')') === src.length - 1) { - return JSON.parse(src.substring(src.indexOf('(') + 1, src.lastIndexOf(')'))); - } - - return JSON.parse(src); -} - -let requestIdCounter = 0; -const pendingRequests = {}; - -let debugRequests: Map; -if (__DEV__) { - debugRequests = new Map(); -} -let completeCallback: org.nativescript.widgets.Async.CompleteCallback; -function ensureCompleteCallback() { - if (completeCallback) { - return; - } - - completeCallback = new org.nativescript.widgets.Async.CompleteCallback({ - onComplete: function (result: any, context: any) { - // as a context we will receive the id of the request - onRequestComplete(context, result); - }, - onError: function (error: string, context: any) { - onRequestError(error, context); - }, - }); -} - -function onRequestComplete(requestId: number, result: org.nativescript.widgets.Async.Http.RequestResult) { - const callbacks = pendingRequests[requestId]; - delete pendingRequests[requestId]; - - if (result.error) { - callbacks.rejectCallback(new Error(result.error.toString())); - - return; - } - - // read the headers - const headers: Headers = {}; - if (result.headers) { - const jHeaders = result.headers; - const length = jHeaders.size(); - let pair: org.nativescript.widgets.Async.Http.KeyValuePair; - for (let i = 0; i < length; i++) { - pair = jHeaders.get(i); - addHeader(headers, pair.key, pair.value); +import { BaseHttpContent, requestInternal } from '../http-request-internal'; +import { getFilenameFromUrl, parseJSON } from './http-request-common'; +export { addHeader } from './http-request-common'; + +type AndroidHttpContent = BaseHttpContent; + +const contentHandler: HttpContentHandler = { + toArrayBuffer(this: AndroidHttpContent) { + return Uint8Array.from(this.raw.toByteArray()).buffer; + }, + toString(this: AndroidHttpContent, encoding?: HttpResponseEncoding) { + let str: string; + if (encoding) { + str = decodeResponse(this.raw, encoding); + } else { + str = this.toNativeString(encoding); } - } - - if (__DEV__) { - const debugRequestInfo = debugRequests.get(requestId); - - if (debugRequestInfo) { - const debugRequest = debugRequestInfo.debugRequest; - let mime = (headers['Content-Type'] ?? 'text/plain') as string; - if (typeof mime === 'string') { - mime = mime.split(';')[0] ?? 'text/plain'; - } - - debugRequest.mimeType = mime; - debugRequest.data = result.raw; - const debugResponse = { - url: result.url, - status: result.statusCode, - statusText: result.statusText, - headers: headers, - mimeType: mime, - fromDiskCache: false, - timing: { - requestTime: debugRequestInfo.timestamp, - proxyStart: -1, - proxyEnd: -1, - dnsStart: -1, - dnsEnd: -1, - connectStart: -1, - connectEnd: -1, - sslStart: -1, - sslEnd: -1, - serviceWorkerFetchStart: -1, - serviceWorkerFetchReady: -1, - serviceWorkerFetchEnd: -1, - sendStart: -1, - sendEnd: -1, - receiveHeadersEnd: -1, - }, - }; - debugRequest.responseReceived(debugResponse); - debugRequest.loadingFinished(); - debugRequests.delete(requestId); + if (typeof str === 'string') { + return str; + } else { + throw new Error('Response content may not be converted to string'); } - } - - callbacks.resolveCallback({ - content: { - raw: result.raw, - toArrayBuffer: () => Uint8Array.from(result.raw.toByteArray()).buffer, - toString: (encoding?: HttpResponseEncoding) => { - let str: string; - if (encoding) { - str = decodeResponse(result.raw, encoding); - } else { - str = result.responseAsString; - } - if (typeof str === 'string') { - return str; - } else { - throw new Error('Response content may not be converted to string'); - } - }, - toJSON: (encoding?: HttpResponseEncoding) => { - let str: string; - if (encoding) { - str = decodeResponse(result.raw, encoding); - } else { - str = result.responseAsString; - } - - return parseJSON(str); - }, - toImage: () => { - return new Promise((resolveImage, rejectImage) => { - if (result.responseAsImage != null) { - resolveImage(new ImageSource(result.responseAsImage)); - } else { - rejectImage(new Error('Response content may not be converted to an Image')); - } - }); - }, - toFile: (destinationFilePath: string) => { - if (!destinationFilePath) { - destinationFilePath = getFilenameFromUrl(callbacks.url); - } - let stream: java.io.FileOutputStream; - try { - // ensure destination path exists by creating any missing parent directories - const file = File.fromPath(destinationFilePath); - - const javaFile = new java.io.File(destinationFilePath); - stream = new java.io.FileOutputStream(javaFile); - stream.write(result.raw.toByteArray()); - - return file; - } catch (exception) { - throw new Error(`Cannot save file with path: ${destinationFilePath}.`); - } finally { - if (stream) { - stream.close(); - } - } - }, - }, - statusCode: result.statusCode, - headers: headers, - }); -} - -function onRequestError(error: string, requestId: number) { - const callbacks = pendingRequests[requestId]; - delete pendingRequests[requestId]; - if (callbacks) { - callbacks.rejectCallback(new Error(error)); - } -} - -function buildJavaOptions(options: HttpRequestOptions) { - if (typeof options.url !== 'string') { - throw new Error('Http request must provide a valid url.'); - } - - const javaOptions = new org.nativescript.widgets.Async.Http.RequestOptions(); - - javaOptions.url = options.url; - - if (typeof options.method === 'string') { - javaOptions.method = options.method; - } - if (typeof options.content === 'string' || options.content instanceof FormData) { - 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; - } - if (typeof options.dontFollowRedirects === 'boolean') { - javaOptions.dontFollowRedirects = options.dontFollowRedirects; - } - - if (options.headers) { - const arrayList = new java.util.ArrayList(); - const pair = org.nativescript.widgets.Async.Http.KeyValuePair; - - for (const key in options.headers) { - arrayList.add(new pair(key, options.headers[key] + '')); + }, + toJSON(this: AndroidHttpContent, encoding?: HttpResponseEncoding) { + let str: string; + if (encoding) { + str = decodeResponse(this.raw, encoding); + } else { + str = this.toNativeString(encoding); } - javaOptions.headers = arrayList; - } - - // pass the maximum available image size to the request options in case we need a bitmap conversion - javaOptions.screenWidth = Screen.mainScreen.widthPixels; - javaOptions.screenHeight = Screen.mainScreen.heightPixels; - - return javaOptions; -} - -export function request(options: HttpRequestOptions): Promise { - if (options === undefined || options === null) { - // TODO: Shouldn't we throw an error here - defensive programming - return; - } - - return new Promise((resolve, reject) => { + return parseJSON(str); + }, + toImage(this: AndroidHttpContent) { + return this.toNativeImage().then((value) => new ImageSource(value)); + }, + toFile(this: AndroidHttpContent, destinationFilePath: string) { + if (!destinationFilePath) { + destinationFilePath = getFilenameFromUrl(this.requestURL); + } + let stream: java.io.FileOutputStream; try { - // initialize the options - const javaOptions = buildJavaOptions(options); - - // // send request data to network debugger - // if (global.__inspector && global.__inspector.isConnected) { - // NetworkAgent.requestWillBeSent(requestIdCounter, options); - // } - - if (__DEV__) { - const network = domainDebugger.getNetwork(); - const debugRequest = network && network.create(); - - if (options.url && debugRequest) { - const timestamp = Date.now() / 1000; - debugRequests.set(requestIdCounter, { - debugRequest, - timestamp, - }); - const request = { - url: options.url, - method: 'GET', - headers: options.headers, - timestamp, - }; - debugRequest.requestWillBeSent(request); - } + // ensure destination path exists by creating any missing parent directories + const file = File.fromPath(destinationFilePath); + + const javaFile = new java.io.File(destinationFilePath); + stream = new java.io.FileOutputStream(javaFile); + stream.write(this.raw.toByteArray()); + + return file; + } catch (exception) { + throw new Error(`Cannot save file with path: ${destinationFilePath}.`); + } finally { + if (stream) { + stream.close(); } - - // remember the callbacks so that we can use them when the CompleteCallback is called - const callbacks = { - url: options.url, - resolveCallback: resolve, - rejectCallback: reject, - }; - pendingRequests[requestIdCounter] = callbacks; - - ensureCompleteCallback(); - //make the actual async call - org.nativescript.widgets.Async.Http.MakeRequest(javaOptions, completeCallback, new java.lang.Integer(requestIdCounter)); - - // increment the id counter - requestIdCounter++; - } catch (ex) { - reject(ex); } - }); + }, +}; + +export function request(options: HttpRequestOptions): Promise { + return requestInternal(options, contentHandler); } function decodeResponse(raw: any, encoding?: HttpResponseEncoding) { - let charsetName = 'UTF-8'; - if (encoding === HttpResponseEncoding.GBK) { - charsetName = 'GBK'; - } - + const charsetName = encoding === HttpResponseEncoding.GBK ? 'GBK' : 'UTF-8'; return raw.toString(charsetName); } - -export function addHeader(headers: Headers, key: string, value: string): void { - if (!headers[key]) { - headers[key] = value; - } else if (Array.isArray(headers[key])) { - (headers[key]).push(value); - } else { - const values: string[] = [headers[key]]; - values.push(value); - headers[key] = values; - } -} diff --git a/packages/core/http/http-request/index.d.ts b/packages/core/http/http-request/index.d.ts index f97b3c2501..f5daa95c14 100644 --- a/packages/core/http/http-request/index.d.ts +++ b/packages/core/http/http-request/index.d.ts @@ -1,4 +1,5 @@ import { HttpRequestOptions, HttpResponse, Headers } from '../http-interfaces'; + /** * Makes a generic http request using the provided options and returns a HttpResponse Object. * @param options An object that specifies various request options. diff --git a/packages/core/http/http-request/index.ios.ts b/packages/core/http/http-request/index.ios.ts index 96019a975d..b1310e8482 100644 --- a/packages/core/http/http-request/index.ios.ts +++ b/packages/core/http/http-request/index.ios.ts @@ -1,239 +1,48 @@ -import { SDK_VERSION } from '../../utils/constants'; -import { isRealDevice } from '../../utils/native-helper'; -import * as types from '../../utils/types'; -import * as domainDebugger from '../../debugger'; -import { getFilenameFromUrl } from './http-request-common'; import { ImageSource } from '../../image-source'; import { File } from '../../file-system'; -import type { HttpRequestOptions, HttpResponse, Headers } from '../http-interfaces'; -import { HttpResponseEncoding } from '../http-interfaces'; - -const currentDevice = UIDevice.currentDevice; -const device = currentDevice.userInterfaceIdiom === UIUserInterfaceIdiom.Phone ? 'Phone' : 'Pad'; -const osVersion = currentDevice.systemVersion; - -const GET = 'GET'; -const USER_AGENT_HEADER = 'User-Agent'; -const USER_AGENT = `Mozilla/5.0 (i${device}; CPU OS ${osVersion.replace('.', '_')} like Mac OS X) AppleWebKit/536.26 (KHTML, like Gecko) Version/${osVersion} Mobile/10A5355d Safari/8536.25`; -// mitigate iOS 18.4 simulator regression -// https://developer.apple.com/forums/thread/777999 -const sessionConfig = SDK_VERSION === 18.4 && !isRealDevice() ? NSURLSessionConfiguration.ephemeralSessionConfiguration : NSURLSessionConfiguration.defaultSessionConfiguration; -const queue = NSOperationQueue.mainQueue; - -function parseJSON(source: string): any { - const src = source.trim(); - if (src.lastIndexOf(')') === src.length - 1) { - return JSON.parse(src.substring(src.indexOf('(') + 1, src.lastIndexOf(')'))); - } - - return JSON.parse(src); -} - -@NativeClass -class NSURLSessionTaskDelegateImpl extends NSObject implements NSURLSessionTaskDelegate { - public static ObjCProtocols = [NSURLSessionTaskDelegate]; - public URLSessionTaskWillPerformHTTPRedirectionNewRequestCompletionHandler(session: NSURLSession, task: NSURLSessionTask, response: NSHTTPURLResponse, request: NSURLRequest, completionHandler: (p1: NSURLRequest) => void): void { - completionHandler(null); - } -} -const sessionTaskDelegateInstance: NSURLSessionTaskDelegateImpl = NSURLSessionTaskDelegateImpl.new(); - -let defaultSession; -function ensureDefaultSession() { - if (!defaultSession) { - defaultSession = NSURLSession.sessionWithConfigurationDelegateDelegateQueue(sessionConfig, null, queue); - } -} - -let sessionNotFollowingRedirects; -function ensureSessionNotFollowingRedirects() { - if (!sessionNotFollowingRedirects) { - sessionNotFollowingRedirects = NSURLSession.sessionWithConfigurationDelegateDelegateQueue(sessionConfig, sessionTaskDelegateInstance, queue); - } -} - -export function request(options: HttpRequestOptions): Promise { - return new Promise((resolve, reject) => { - if (!options.url) { - reject(new Error('Request url was empty.')); - - return; +import type { HttpRequestOptions, HttpResponse, HttpContentHandler } from '../http-interfaces'; +import type { HttpResponseEncoding } from '../http-interfaces'; +import { requestInternal, BaseHttpContent } from '../http-request-internal'; +import { getFilenameFromUrl, parseJSON } from './http-request-common'; +export { addHeader } from './http-request-common'; + +type iOSHttpContent = BaseHttpContent; + +const contentHandler: HttpContentHandler = { + toArrayBuffer(this: iOSHttpContent) { + return interop.bufferFromData(this.raw); + }, + toString(this: iOSHttpContent, encoding?: HttpResponseEncoding) { + const str = this.toNativeString(encoding); + if (typeof str === 'string') { + return str; + } else { + throw new Error('Response content may not be converted to string'); } - - try { - const network = domainDebugger.getNetwork(); - const debugRequest = network && network.create(); - - const urlRequest = NSMutableURLRequest.requestWithURL(NSURL.URLWithString(options.url)); - - urlRequest.HTTPMethod = types.isDefined(options.method) ? options.method : GET; - - urlRequest.setValueForHTTPHeaderField(USER_AGENT, USER_AGENT_HEADER); - - if (options.headers) { - for (const header in options.headers) { - urlRequest.setValueForHTTPHeaderField(options.headers[header] + '', header); - } - } - - if (types.isString(options.content) || options.content instanceof FormData) { - urlRequest.HTTPBody = NSString.stringWithString(options.content.toString()).dataUsingEncoding(4); - } else if (options.content instanceof ArrayBuffer) { - const buffer = options.content as ArrayBuffer; - urlRequest.HTTPBody = NSData.dataWithData(buffer as any); - } - - if (types.isNumber(options.timeout)) { - urlRequest.timeoutInterval = options.timeout / 1000; - } - - let session; - if (types.isBoolean(options.dontFollowRedirects) && options.dontFollowRedirects) { - ensureSessionNotFollowingRedirects(); - session = sessionNotFollowingRedirects; - } else { - ensureDefaultSession(); - session = defaultSession; - } - - let timestamp = -1; - const dataTask = session.dataTaskWithRequestCompletionHandler(urlRequest, function (data: NSData, response: NSHTTPURLResponse, error: NSError) { - if (error) { - reject(new Error(error.localizedDescription)); - } else { - const headers: Headers = {}; - if (response && response.allHeaderFields) { - const headerFields = response.allHeaderFields; - - headerFields.enumerateKeysAndObjectsUsingBlock((key, value, stop) => { - addHeader(headers, key, value); - }); - } - - if (debugRequest) { - debugRequest.mimeType = response.MIMEType; - debugRequest.data = data; - const debugResponse = { - url: options.url, - status: response.statusCode, - statusText: NSHTTPURLResponse.localizedStringForStatusCode(response.statusCode), - headers: headers, - mimeType: response.MIMEType, - fromDiskCache: false, - timing: { - requestTime: timestamp, - proxyStart: -1, - proxyEnd: -1, - dnsStart: -1, - dnsEnd: -1, - connectStart: -1, - connectEnd: -1, - sslStart: -1, - sslEnd: -1, - serviceWorkerFetchStart: -1, - serviceWorkerFetchReady: -1, - serviceWorkerFetchEnd: -1, - sendStart: -1, - sendEnd: -1, - receiveHeadersEnd: -1, - }, - headersSize: headers?.length ?? -1, - }; - debugRequest.responseReceived(debugResponse); - debugRequest.loadingFinished(); - } - - resolve({ - content: { - raw: data, - 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: () => { - return new Promise((resolve, reject) => { - (UIImage).tns_decodeImageWithDataCompletion(data, (image) => { - if (image) { - resolve(new ImageSource(image)); - } else { - reject(new Error('Response content may not be converted to an Image')); - } - }); - }); - }, - toFile: (destinationFilePath?: string) => { - if (!destinationFilePath) { - destinationFilePath = getFilenameFromUrl(options.url); - } - if (data instanceof NSData) { - // ensure destination path exists by creating any missing parent directories - const file = File.fromPath(destinationFilePath); - - data.writeToFileAtomically(destinationFilePath, true); - - return file; - } else { - reject(new Error(`Cannot save file with path: ${destinationFilePath}.`)); - } - }, - }, - statusCode: response.statusCode, - headers: headers, - }); - } - }); - - if (options.url && debugRequest) { - timestamp = Date.now() / 1000; - const request = { - url: options.url, - method: 'GET', - headers: options.headers, - timestamp, - headersSize: options?.headers?.length ?? -1, - }; - debugRequest.requestWillBeSent(request); - } - - dataTask.resume(); - } catch (ex) { - reject(ex); + }, + toJSON(this: iOSHttpContent, encoding?: HttpResponseEncoding) { + return parseJSON(this.toNativeString(encoding)); + }, + toImage(this: iOSHttpContent) { + return this.toNativeImage().then((value) => new ImageSource(value)); + }, + toFile(this: iOSHttpContent, destinationFilePath?: string) { + if (!destinationFilePath) { + destinationFilePath = getFilenameFromUrl(this.requestURL); } - }); -} - -function NSDataToString(data: any, encoding?: HttpResponseEncoding): string { - let code = NSUTF8StringEncoding; // long:4 - - if (encoding === HttpResponseEncoding.GBK) { - code = CFStringEncodings.kCFStringEncodingGB_18030_2000; // long:1586 - } + if (this.raw instanceof NSData) { + // ensure destination path exists by creating any missing parent directories + const file = File.fromPath(destinationFilePath); - let encodedString = NSString.alloc().initWithDataEncoding(data, code); + this.raw.writeToFileAtomically(destinationFilePath, true); - // If UTF8 string encoding fails try with ISO-8859-1 - if (!encodedString) { - code = NSISOLatin1StringEncoding; // long:5 - encodedString = NSString.alloc().initWithDataEncoding(data, code); - } - - return encodedString.toString(); -} + return file; + } else { + throw new Error(`Cannot save file with path: ${destinationFilePath}.`); + } + }, +}; -export function addHeader(headers: Headers, key: string, value: string): void { - if (!headers[key]) { - headers[key] = value; - } else if (Array.isArray(headers[key])) { - (headers[key]).push(value); - } else { - const values: string[] = [headers[key]]; - values.push(value); - headers[key] = values; - } +export function request(options: HttpRequestOptions): Promise { + return requestInternal(options, contentHandler); } diff --git a/packages/core/http/http-shared.ts b/packages/core/http/http-shared.ts deleted file mode 100644 index aacb5eaf7a..0000000000 --- a/packages/core/http/http-shared.ts +++ /dev/null @@ -1,7 +0,0 @@ -// Example: (add more as needed) -// Note: Circular dep help between http and image-source. -// Interfaces can be moved around further in future to help avoid. -export interface ImageSourceLike { - toBase64String(format: string, quality?: number): string; - // ... add other shared methods/properties as needed -} diff --git a/packages/core/http/index.ts b/packages/core/http/index.ts index fd43bb3f3f..d3e50ef47b 100644 --- a/packages/core/http/index.ts +++ b/packages/core/http/index.ts @@ -1,5 +1,5 @@ -import { type ImageSourceLike } from './http-shared'; import { request } from './http-request'; +import { ImageSource } from '../image-source'; export { request } from './http-request'; export * from './http-interfaces'; @@ -35,7 +35,7 @@ export function getJSON(arg: any): Promise { }); } -export function getImage(arg: any): Promise { +export function getImage(arg: any): Promise { return new Promise((resolve, reject) => { request(typeof arg === 'string' ? { url: arg, method: 'GET' } : arg).then( (r) => { diff --git a/packages/core/image-source/index.android.ts b/packages/core/image-source/index.android.ts index f7099e0753..3637c05182 100644 --- a/packages/core/image-source/index.android.ts +++ b/packages/core/image-source/index.android.ts @@ -1,7 +1,7 @@ import { ImageSource as ImageSourceDefinition, iosSymbolScaleType } from '.'; import { ImageAsset } from '../image-asset'; -import { getImage } from '../http'; import { path as fsPath, knownFolders } from '../file-system'; +import { requestInternal as httpRequest } from '../http/http-request-internal'; import { isFileOrResourcePath, RESOURCE_PREFIX, layout } from '../utils'; import { getNativeApp } from '../application/helpers-common'; import { Font } from '../ui/styling/font'; @@ -63,7 +63,7 @@ export class ImageSource implements ImageSourceDefinition { } static fromUrl(url: string): Promise { - return getImage(url) as Promise; + return httpRequest({ url, method: 'GET' }).then((response) => response.content.toNativeImage().then((value) => new ImageSource(value))); } static fromResourceSync(name: string): ImageSource { diff --git a/packages/core/image-source/index.ios.ts b/packages/core/image-source/index.ios.ts index e0218b59c4..bc2fd473c1 100644 --- a/packages/core/image-source/index.ios.ts +++ b/packages/core/image-source/index.ios.ts @@ -5,9 +5,9 @@ import { Font } from '../ui/styling/font'; import { Color } from '../color'; import { Trace } from '../trace'; import { path as fsPath, knownFolders } from '../file-system'; -import { isFileOrResourcePath, RESOURCE_PREFIX, layout, releaseNativeObject, SYSTEM_PREFIX } from '../utils'; +import { requestInternal as httpRequest } from '../http/http-request-internal'; +import { isFileOrResourcePath, RESOURCE_PREFIX, SYSTEM_PREFIX } from '../utils'; import { getScaledDimensions } from './image-source-common'; -import { getImage } from '../http'; export { isFileOrResourcePath }; @@ -58,7 +58,7 @@ export class ImageSource implements ImageSourceDefinition { } static fromUrl(url: string): Promise { - return getImage(url) as Promise; + return httpRequest({ url, method: 'GET' }).then((response) => response.content.toNativeImage().then((value) => new ImageSource(value))); } static iosSystemScaleFor(scale: iosSymbolScaleType) { @@ -112,18 +112,18 @@ export class ImageSource implements ImageSourceDefinition { } static fromResourceSync(name: string): ImageSource { - const nativeSource = (UIImage).tns_safeImageNamed(name) || (UIImage).tns_safeImageNamed(`${name}.jpg`); + const nativeSource = UIImage.tns_safeImageNamed(name) || UIImage.tns_safeImageNamed(`${name}.jpg`); return nativeSource ? new ImageSource(nativeSource) : null; } static fromResource(name: string): Promise { return new Promise((resolve, reject) => { try { - (UIImage).tns_safeDecodeImageNamedCompletion(name, (image) => { + UIImage.tns_safeDecodeImageNamedCompletion(name, (image) => { if (image) { resolve(new ImageSource(image)); } else { - (UIImage).tns_safeDecodeImageNamedCompletion(`${name}.jpg`, (img) => { + UIImage.tns_safeDecodeImageNamedCompletion(`${name}.jpg`, (img) => { if (img) { resolve(new ImageSource(img)); } @@ -144,7 +144,7 @@ export class ImageSource implements ImageSourceDefinition { static fromFile(path: string): Promise { return new Promise((resolve, reject) => { try { - (UIImage).tns_decodeImageWidthContentsOfFileCompletion(getFileName(path), (uiImage) => { + UIImage.tns_decodeImageWidthContentsOfFileCompletion(getFileName(path), (uiImage) => { if (uiImage) { resolve(new ImageSource(uiImage)); } @@ -181,7 +181,7 @@ export class ImageSource implements ImageSourceDefinition { static fromData(data: any): Promise { return new Promise((resolve, reject) => { try { - (UIImage).tns_decodeImageWithDataCompletion(data, (uiImage) => { + UIImage.tns_decodeImageWithDataCompletion(data, (uiImage) => { if (uiImage) { resolve(new ImageSource(uiImage)); } From 19160868afe1cdfb86e2add2712e6f26cf1c6b89 Mon Sep 17 00:00:00 2001 From: Dimitris - Rafail Katsampas Date: Mon, 16 Mar 2026 11:02:00 +0200 Subject: [PATCH 2/6] chore cleanup --- packages/core/http/http-interfaces.ts | 2 +- .../http-request-internal-common.ts | 2 +- .../http-request-internal/index.android.ts | 16 ++++++++-------- .../core/http/http-request-internal/index.d.ts | 8 ++++---- .../http/http-request-internal/index.ios.ts | 18 +++++++++--------- .../http/http-request/http-request-common.ts | 6 ------ .../core/http/http-request/index.android.ts | 15 ++++++--------- packages/core/http/http-request/index.d.ts | 1 - packages/core/http/http-request/index.ios.ts | 13 +++++-------- 9 files changed, 34 insertions(+), 47 deletions(-) diff --git a/packages/core/http/http-interfaces.ts b/packages/core/http/http-interfaces.ts index 04237c250d..43edf80f13 100644 --- a/packages/core/http/http-interfaces.ts +++ b/packages/core/http/http-interfaces.ts @@ -94,4 +94,4 @@ export interface HttpContentHandler { /** * Encapsulates the content of an HttpResponse. */ -export interface HttpContent extends HttpContentHandler, BaseHttpContent {} +export interface HttpContent extends HttpContentHandler, BaseHttpContent {} diff --git a/packages/core/http/http-request-internal/http-request-internal-common.ts b/packages/core/http/http-request-internal/http-request-internal-common.ts index 24cbbb60ee..c6d37980d3 100644 --- a/packages/core/http/http-request-internal/http-request-internal-common.ts +++ b/packages/core/http/http-request-internal/http-request-internal-common.ts @@ -1,6 +1,6 @@ import type { Headers } from '../http-interfaces'; -export function _addHeader(headers: Headers, key: string, value: string): void { +export function addHeader(headers: Headers, key: string, value: string): void { if (!headers[key]) { headers[key] = value; } else if (Array.isArray(headers[key])) { diff --git a/packages/core/http/http-request-internal/index.android.ts b/packages/core/http/http-request-internal/index.android.ts index 3dce375bef..a967c39714 100644 --- a/packages/core/http/http-request-internal/index.android.ts +++ b/packages/core/http/http-request-internal/index.android.ts @@ -1,16 +1,16 @@ // imported for definition purposes only -import type { Headers, HttpResponse, HttpRequestOptions, HttpContentHandler } from '../../http'; +import type { Headers, HttpResponse, HttpRequestOptions } from '../../http'; import { Screen } from '../../platform/screen'; import * as domainDebugger from '../../debugger'; import { isObject } from '../../utils'; import { BaseHttpContent } from '.'; -import { _addHeader } from './http-request-internal-common'; -export { _addHeader } from './http-request-internal-common'; +import { addHeader } from './http-request-internal-common'; +export { addHeader } from './http-request-internal-common'; interface PendingRequest { url: string; - contentHandler?: HttpContentHandler; - resolveCallback: (value: HttpResponse> | PromiseLike>>) => void; + contentHandler?: object; + resolveCallback: (value: HttpResponse | PromiseLike>) => void; rejectCallback: (reason?: any) => void; } @@ -55,7 +55,7 @@ function onRequestComplete(requestId: number, result: org.nativescript.widgets.A let pair: org.nativescript.widgets.Async.Http.KeyValuePair; for (let i = 0; i < length; i++) { pair = jHeaders.get(i); - _addHeader(headers, pair.key, pair.value); + addHeader(headers, pair.key, pair.value); } } @@ -184,13 +184,13 @@ function buildJavaOptions(options: HttpRequestOptions) { return javaOptions; } -export function requestInternal(options: HttpRequestOptions, contentHandler?: T): Promise & T>> { +export function requestInternal(options: HttpRequestOptions, contentHandler?: T): Promise> { if (options === undefined || options === null) { // TODO: Shouldn't we throw an error here - defensive programming return; } - return new Promise & T>>((resolve, reject) => { + return new Promise>((resolve, reject) => { try { // initialize the options const javaOptions = buildJavaOptions(options); diff --git a/packages/core/http/http-request-internal/index.d.ts b/packages/core/http/http-request-internal/index.d.ts index 29240a40e4..3cad365cd0 100644 --- a/packages/core/http/http-request-internal/index.d.ts +++ b/packages/core/http/http-request-internal/index.d.ts @@ -1,10 +1,10 @@ import { HttpRequestOptions, HttpResponse, Headers, HttpResponseEncoding, HttpContentHandler } from '../http-interfaces'; -export interface BaseHttpContent { +export interface BaseHttpContent { /** * Gets the response body as raw data. */ - raw: T; + raw: any; /** * Gets the request options URL. */ @@ -24,5 +24,5 @@ export interface BaseHttpContent { * @param options An object that specifies various request options. * @param contentHandler An object that specifies various functions to parse raw response content. */ -export function requestInternal(options: HttpRequestOptions, contentHandler?: T): Promise & T>>; -export function _addHeader(headers: Headers, key: string, value: string): void; +export function requestInternal(options: HttpRequestOptions, contentHandler?: T): Promise>; +export function addHeader(headers: Headers, key: string, value: string): void; diff --git a/packages/core/http/http-request-internal/index.ios.ts b/packages/core/http/http-request-internal/index.ios.ts index 068243bb94..f7d873fbbe 100644 --- a/packages/core/http/http-request-internal/index.ios.ts +++ b/packages/core/http/http-request-internal/index.ios.ts @@ -2,11 +2,11 @@ import { SDK_VERSION } from '../../utils/constants'; import { isRealDevice } from '../../utils/native-helper'; import * as types from '../../utils/types'; import * as domainDebugger from '../../debugger'; -import type { HttpRequestOptions, HttpResponse, Headers, HttpContentHandler } from '../http-interfaces'; +import type { HttpRequestOptions, HttpResponse, Headers } from '../http-interfaces'; import { HttpResponseEncoding } from '../http-interfaces'; import { BaseHttpContent } from '.'; -import { _addHeader } from './http-request-internal-common'; -export { _addHeader } from './http-request-internal-common'; +import { addHeader } from './http-request-internal-common'; +export { addHeader } from './http-request-internal-common'; const currentDevice = UIDevice.currentDevice; const device = currentDevice.userInterfaceIdiom === UIUserInterfaceIdiom.Phone ? 'Phone' : 'Pad'; @@ -43,8 +43,8 @@ function ensureSessionNotFollowingRedirects() { } } -export function requestInternal(options: HttpRequestOptions, contentHandler?: T): Promise & T>> { - return new Promise & T>>((resolve, reject) => { +export function requestInternal(options: HttpRequestOptions, contentHandler?: T): Promise> { + return new Promise>((resolve, reject) => { if (!options.url) { reject(new Error('Request url was empty.')); return; @@ -96,7 +96,7 @@ export function requestInternal(options: HttpReque const headerFields = response.allHeaderFields; headerFields.enumerateKeysAndObjectsUsingBlock((key, value, stop) => { - _addHeader(headers, key, value); + addHeader(headers, key, value); }); } @@ -138,7 +138,7 @@ export function requestInternal(options: HttpReque requestURL: options.url, toNativeImage: () => { return new Promise((resolveImage, rejectImage) => { - UIImage.tns_decodeImageWithDataCompletion(this.raw, (image) => { + UIImage.tns_decodeImageWithDataCompletion(data, (image) => { if (image) { resolveImage(image); } else { @@ -147,7 +147,7 @@ export function requestInternal(options: HttpReque }); }); }, - toNativeString: (encoding?: HttpResponseEncoding) => NSDataToString(this.raw, encoding), + toNativeString: (encoding?: HttpResponseEncoding) => NSDataToString(data, encoding), }; if (contentHandler != null && types.isObject(contentHandler) && !Array.isArray(contentHandler)) { @@ -155,7 +155,7 @@ export function requestInternal(options: HttpReque } resolve({ - content: content as BaseHttpContent & T, + content: content as BaseHttpContent & T, statusCode: response.statusCode, headers: headers, }); diff --git a/packages/core/http/http-request/http-request-common.ts b/packages/core/http/http-request/http-request-common.ts index 81a7ef8b31..961d13f443 100644 --- a/packages/core/http/http-request/http-request-common.ts +++ b/packages/core/http/http-request/http-request-common.ts @@ -1,6 +1,4 @@ import { knownFolders, path } from '../../file-system'; -import type { Headers } from '../http-interfaces'; -import { _addHeader } from '../http-request-internal'; export function getFilenameFromUrl(url: string) { const slashPos = url.lastIndexOf('/') + 1; @@ -28,7 +26,3 @@ export function parseJSON(source: string): any { return JSON.parse(src); } - -export function addHeader(headers: Headers, key: string, value: string): void { - _addHeader(headers, key, value); -} diff --git a/packages/core/http/http-request/index.android.ts b/packages/core/http/http-request/index.android.ts index 25f06c9e74..e8d2ba8ccc 100644 --- a/packages/core/http/http-request/index.android.ts +++ b/packages/core/http/http-request/index.android.ts @@ -5,15 +5,12 @@ import { File } from '../../file-system'; import { HttpResponseEncoding } from '../http-interfaces'; import { BaseHttpContent, requestInternal } from '../http-request-internal'; import { getFilenameFromUrl, parseJSON } from './http-request-common'; -export { addHeader } from './http-request-common'; - -type AndroidHttpContent = BaseHttpContent; const contentHandler: HttpContentHandler = { - toArrayBuffer(this: AndroidHttpContent) { + toArrayBuffer(this: BaseHttpContent) { return Uint8Array.from(this.raw.toByteArray()).buffer; }, - toString(this: AndroidHttpContent, encoding?: HttpResponseEncoding) { + toString(this: BaseHttpContent, encoding?: HttpResponseEncoding) { let str: string; if (encoding) { str = decodeResponse(this.raw, encoding); @@ -26,7 +23,7 @@ const contentHandler: HttpContentHandler = { throw new Error('Response content may not be converted to string'); } }, - toJSON(this: AndroidHttpContent, encoding?: HttpResponseEncoding) { + toJSON(this: BaseHttpContent, encoding?: HttpResponseEncoding) { let str: string; if (encoding) { str = decodeResponse(this.raw, encoding); @@ -36,10 +33,10 @@ const contentHandler: HttpContentHandler = { return parseJSON(str); }, - toImage(this: AndroidHttpContent) { + toImage(this: BaseHttpContent) { return this.toNativeImage().then((value) => new ImageSource(value)); }, - toFile(this: AndroidHttpContent, destinationFilePath: string) { + toFile(this: BaseHttpContent, destinationFilePath: string) { if (!destinationFilePath) { destinationFilePath = getFilenameFromUrl(this.requestURL); } @@ -50,7 +47,7 @@ const contentHandler: HttpContentHandler = { const javaFile = new java.io.File(destinationFilePath); stream = new java.io.FileOutputStream(javaFile); - stream.write(this.raw.toByteArray()); + stream.write((this.raw as java.io.ByteArrayOutputStream).toByteArray()); return file; } catch (exception) { diff --git a/packages/core/http/http-request/index.d.ts b/packages/core/http/http-request/index.d.ts index f5daa95c14..dccee5a63d 100644 --- a/packages/core/http/http-request/index.d.ts +++ b/packages/core/http/http-request/index.d.ts @@ -5,4 +5,3 @@ * @param options An object that specifies various request options. */ export const request: (options: HttpRequestOptions) => Promise; -export function addHeader(headers: Headers, key: string, value: string): void; diff --git a/packages/core/http/http-request/index.ios.ts b/packages/core/http/http-request/index.ios.ts index b1310e8482..128feadd9d 100644 --- a/packages/core/http/http-request/index.ios.ts +++ b/packages/core/http/http-request/index.ios.ts @@ -4,15 +4,12 @@ import type { HttpRequestOptions, HttpResponse, HttpContentHandler } from '../ht import type { HttpResponseEncoding } from '../http-interfaces'; import { requestInternal, BaseHttpContent } from '../http-request-internal'; import { getFilenameFromUrl, parseJSON } from './http-request-common'; -export { addHeader } from './http-request-common'; - -type iOSHttpContent = BaseHttpContent; const contentHandler: HttpContentHandler = { - toArrayBuffer(this: iOSHttpContent) { + toArrayBuffer(this: BaseHttpContent) { return interop.bufferFromData(this.raw); }, - toString(this: iOSHttpContent, encoding?: HttpResponseEncoding) { + toString(this: BaseHttpContent, encoding?: HttpResponseEncoding) { const str = this.toNativeString(encoding); if (typeof str === 'string') { return str; @@ -20,13 +17,13 @@ const contentHandler: HttpContentHandler = { throw new Error('Response content may not be converted to string'); } }, - toJSON(this: iOSHttpContent, encoding?: HttpResponseEncoding) { + toJSON(this: BaseHttpContent, encoding?: HttpResponseEncoding) { return parseJSON(this.toNativeString(encoding)); }, - toImage(this: iOSHttpContent) { + toImage(this: BaseHttpContent) { return this.toNativeImage().then((value) => new ImageSource(value)); }, - toFile(this: iOSHttpContent, destinationFilePath?: string) { + toFile(this: BaseHttpContent, destinationFilePath?: string) { if (!destinationFilePath) { destinationFilePath = getFilenameFromUrl(this.requestURL); } From e49d8505e5f89742b4c323c58659b3686f52f05b Mon Sep 17 00:00:00 2001 From: Dimitris - Rafail Katsampas Date: Mon, 16 Mar 2026 11:02:24 +0200 Subject: [PATCH 3/6] chore: added automated tests for http-request-internal module --- apps/automated/src/http/http-tests.ts | 85 ++++++++++++++++++++++++++- 1 file changed, 84 insertions(+), 1 deletion(-) diff --git a/apps/automated/src/http/http-tests.ts b/apps/automated/src/http/http-tests.ts index e554862118..5272f8759a 100644 --- a/apps/automated/src/http/http-tests.ts +++ b/apps/automated/src/http/http-tests.ts @@ -2,7 +2,7 @@ import { ImageSource } from '@nativescript/core'; import * as TKUnit from '../tk-unit'; import * as http from '@nativescript/core/http'; import * as fs from '@nativescript/core/file-system'; -import { addHeader } from '@nativescript/core/http/http-request'; +import { requestInternal, addHeader, BaseHttpContent } from '@nativescript/core/http/http-request-internal'; export var test_getString_isDefined = function () { TKUnit.assert(typeof http.getString !== 'undefined', 'Method http.getString() should be defined!'); @@ -329,6 +329,89 @@ export var test_request_requestShouldTimeout = function (done) { }); }; +export var test_requestInternal_responseStatusCodeShouldBeDefined = function (done) { + requestInternal({ url: 'https://http-echo.nativescript.org/get', method: 'GET' }).then( + function (response) { + //// Argument (response) is HttpResponse! + var statusCode = response.statusCode; + try { + TKUnit.assert(typeof statusCode !== 'undefined', 'response.statusCode should be defined!'); + done(null); + } catch (err) { + done(err); + } + }, + function (e) { + //// Argument (e) is Error! + done(e); + }, + ); +}; + +export var test_requestInternal_responseContentShouldExposeNativeContentFunctions = function (done) { + requestInternal({ url: 'https://http-echo.nativescript.org/get', method: 'GET' }).then( + function (response) { + try { + TKUnit.assert(typeof response.content.toNativeImage === 'function' && typeof response.content.toNativeString === 'function', `response.content should expose native content functions!`); + done(null); + } catch (err) { + done(err); + } + }, + function (e) { + //// Argument (e) is Error! + done(e); + }, + ); +}; + +export var test_requestInternal_responseContentShouldExposeHandlerFunctions = function (done) { + const responseHandler = { + toDummy1: () => 'dummy1', + toDummy2: () => 'dummy2', + }; + + requestInternal({ url: 'https://http-echo.nativescript.org/get', method: 'GET' }, responseHandler).then( + function (response) { + try { + TKUnit.assert(typeof response.content.toDummy1 === 'function' && typeof response.content.toDummy2 === 'function', `response.content should expose content handler functions!`); + done(null); + } catch (err) { + done(err); + } + }, + function (e) { + //// Argument (e) is Error! + done(e); + }, + ); +}; + +export var test_requestInternal_responseHandlerShouldBeAvailable = function (done) { + const suffix = '-nsformatted'; + const responseHandler = { + toFormattedString: function (this: BaseHttpContent) { + return this.toNativeString() + suffix; + }, + }; + + requestInternal({ url: 'https://http-echo.nativescript.org/get', method: 'GET' }, responseHandler).then( + function (response) { + const value = response.content.toFormattedString(); + try { + TKUnit.assert(typeof value === 'string' && value.endsWith(suffix), `response.content.toFormattedString should return the response string appended with ${suffix} at the end!`); + done(null); + } catch (err) { + done(err); + } + }, + function (e) { + //// Argument (e) is Error! + done(e); + }, + ); +}; + export var test_request_responseStatusCodeShouldBeDefined = function (done) { var result: http.HttpResponse; From cd164af870b5b4353585f6e4c56bd1236b493d1a Mon Sep 17 00:00:00 2001 From: Dimitris - Rafail Katsampas Date: Wed, 18 Mar 2026 18:40:15 +0200 Subject: [PATCH 4/6] chore: types correction --- packages/core/http/http-request/index.android.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/core/http/http-request/index.android.ts b/packages/core/http/http-request/index.android.ts index e8d2ba8ccc..dc1bd9bc7f 100644 --- a/packages/core/http/http-request/index.android.ts +++ b/packages/core/http/http-request/index.android.ts @@ -8,12 +8,12 @@ import { getFilenameFromUrl, parseJSON } from './http-request-common'; const contentHandler: HttpContentHandler = { toArrayBuffer(this: BaseHttpContent) { - return Uint8Array.from(this.raw.toByteArray()).buffer; + return Uint8Array.from((this.raw as java.io.ByteArrayOutputStream).toByteArray()).buffer; }, toString(this: BaseHttpContent, encoding?: HttpResponseEncoding) { let str: string; if (encoding) { - str = decodeResponse(this.raw, encoding); + str = decodeResponse(this.raw as java.io.ByteArrayOutputStream, encoding); } else { str = this.toNativeString(encoding); } @@ -26,7 +26,7 @@ const contentHandler: HttpContentHandler = { toJSON(this: BaseHttpContent, encoding?: HttpResponseEncoding) { let str: string; if (encoding) { - str = decodeResponse(this.raw, encoding); + str = decodeResponse(this.raw as java.io.ByteArrayOutputStream, encoding); } else { str = this.toNativeString(encoding); } @@ -64,7 +64,7 @@ export function request(options: HttpRequestOptions): Promise { return requestInternal(options, contentHandler); } -function decodeResponse(raw: any, encoding?: HttpResponseEncoding) { +function decodeResponse(raw: java.io.ByteArrayOutputStream, encoding?: HttpResponseEncoding) { const charsetName = encoding === HttpResponseEncoding.GBK ? 'GBK' : 'UTF-8'; return raw.toString(charsetName); } From b7d4b27d3e69cb8aeab8a017c56c1dbc9a7518ff Mon Sep 17 00:00:00 2001 From: Dimitris - Rafail Katsampas Date: Wed, 18 Mar 2026 19:35:35 +0200 Subject: [PATCH 5/6] chore: Removed unneeded import reference --- packages/core/http/index.d.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/http/index.d.ts b/packages/core/http/index.d.ts index 987ad7c0ee..fcf47573f9 100644 --- a/packages/core/http/index.d.ts +++ b/packages/core/http/index.d.ts @@ -1,6 +1,6 @@ import type { File } from '../file-system'; import type { ImageSource } from '../image-source'; -import type { HttpResponse, HttpRequestOptions } from './http-interfaces'; +import type { HttpRequestOptions } from './http-interfaces'; export { request } from './http-request'; export * from './http-interfaces'; From 6596627ae69eda93dcfa609c1f68a597821d7d4c Mon Sep 17 00:00:00 2001 From: Dimitris - Rafail Katsampas Date: Wed, 18 Mar 2026 19:40:57 +0200 Subject: [PATCH 6/6] chore: switch import to import type --- packages/core/http/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/http/index.ts b/packages/core/http/index.ts index d3e50ef47b..3f8b4b304f 100644 --- a/packages/core/http/index.ts +++ b/packages/core/http/index.ts @@ -1,5 +1,5 @@ import { request } from './http-request'; -import { ImageSource } from '../image-source'; +import type { ImageSource } from '../image-source'; export { request } from './http-request'; export * from './http-interfaces';