From e46d2d179f2db6fcfe010082b0b39bd4c16472a9 Mon Sep 17 00:00:00 2001 From: wanghongenpin Date: Mon, 7 Apr 2025 03:38:55 +0800 Subject: [PATCH] Fix script fetch binary response (#407) --- assets/js/fetch.js | 66 +++ lib/network/components/js/xhr.dart | 437 ++++++++++++++++++ .../components/manager/script_manager.dart | 13 +- lib/ui/component/toolbox/js_run.dart | 7 +- pubspec.yaml | 1 + 5 files changed, 518 insertions(+), 6 deletions(-) create mode 100644 assets/js/fetch.js create mode 100644 lib/network/components/js/xhr.dart diff --git a/assets/js/fetch.js b/assets/js/fetch.js new file mode 100644 index 0000000..94f127f --- /dev/null +++ b/assets/js/fetch.js @@ -0,0 +1,66 @@ +function fetch(url, options) { + options = options || {}; + return new Promise((resolve, reject) => { + const request = new XMLHttpRequest(); + const keys = []; + const all = []; + const headers = {}; + + const response = () => ({ + ok: (request.status / 100 | 0) === 2, // 200-299 + statusText: request.statusText, + status: request.status, + url: request.responseURL, + body: request.response.body, + text: () => Promise.resolve(request.responseText), + json: () => { + // TODO: review this handle because it may discard \n from json attributes + try { + // console.log('RESPONSE TEXT IN FETCH: ' + request.responseText); + return Promise.resolve(JSON.parse(request.responseText)); + } catch (e) { + // console.log('ERROR on fetch parsing JSON: ' + e.message); + return Promise.resolve(request.responseText); + } + }, + + blob: () => Promise.resolve(request.response.body), + clone: response, + headers: { + keys: () => keys, + entries: () => all, + get: n => headers[n.toLowerCase()], + has: n => n.toLowerCase() in headers + } + }); + + request.open(options.method || 'get', url, true); + + request.onload = () => { + request.getAllResponseHeaders().replace(/^(.*?):[^\S\n]*([\s\S]*?)$/gm, (m, key, value) => { + keys.push(key = key.toLowerCase()); + all.push([key, value]); + headers[key] = headers[key] ? `${headers[key]},${value}` : value; + }); + resolve(response()); + }; + + request.onerror = reject; + + request.withCredentials = options.credentials == 'include'; + + if (options.headers) { + if (options.headers.constructor.name == 'Object') { + for (const i in options.headers) { + request.setRequestHeader(i, options.headers[i]); + } + } else { // if it is some Headers pollyfill, the way to iterate is through for of + for (const header of options.headers) { + request.setRequestHeader(header[0], header[1]); + } + } + } + + request.send(options.body || null); + }); +} \ No newline at end of file diff --git a/lib/network/components/js/xhr.dart b/lib/network/components/js/xhr.dart new file mode 100644 index 0000000..652d99a --- /dev/null +++ b/lib/network/components/js/xhr.dart @@ -0,0 +1,437 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:flutter_js/javascript_runtime.dart'; +import 'package:http/http.dart' as http; +import 'package:proxypin/network/util/file_read.dart'; +import 'package:proxypin/network/util/logger.dart'; + +/* + * Based on bits and pieces from different OSS sources + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// ignore: non_constant_identifier_names +var _XHR_DEBUG = false; + +setXhrDebug(bool value) => _XHR_DEBUG = value; + +const HTTP_GET = "get"; +const HTTP_POST = "post"; +const HTTP_PATCH = "patch"; +const HTTP_DELETE = "delete"; +const HTTP_PUT = "put"; +const HTTP_HEAD = "head"; + +enum HttpMethod { put, get, post, delete, patch, head } + +String _debugSendNativeCallback() { + if (_XHR_DEBUG) { + return """console.log("XMLHttpRequest._send_native_callback"); + console.log("arguments"); + console.log(arguments); + console.log(responseInfo); + console.log(responseText); + console.log(error);"""; + } else + return ""; +} + +final String xhrJsCode = """ +function XMLHttpRequest() { + this._send_native = XMLHttpRequestExtension_send_native; + this._httpMethod = null; + this._url = null; + this._requestHeaders = []; + this._responseHeaders = []; + this.response = null; + this.responseText = null; + this.responseXML = null; + this.onreadystatechange = null; + this.onloadstart = null; + this.onprogress = null; + this.onabort = null; + this.onerror = null; + this.onload = null; + this.onloadend = null; + this.ontimeout = null; + this.readyState = 0; + this.status = 0; + this.statusText = ""; + this.withCredentials = null; +}; +// readystate enum +XMLHttpRequest.UNSENT = 0; +XMLHttpRequest.OPENED = 1; +XMLHttpRequest.HEADERS = 2; +XMLHttpRequest.LOADING = 3; +XMLHttpRequest.DONE = 4; +XMLHttpRequest.prototype.constructor = XMLHttpRequest; +XMLHttpRequest.prototype.open = function(httpMethod, url) { + this._httpMethod = httpMethod; + this._url = url; + this.readyState = XMLHttpRequest.OPENED; + if (typeof this.onreadystatechange === "function") { + //console.log("Calling onreadystatechange(OPENED)..."); + this.onreadystatechange(); + } +}; +XMLHttpRequest.prototype.send = function(data) { + this.readyState = XMLHttpRequest.LOADING; + if (typeof this.onreadystatechange === "function") { + //console.log("Calling onreadystatechange(LOADING)..."); + this.onreadystatechange(); + } + if (typeof this.onloadstart === "function") { + //console.log("Calling onloadstart()..."); + this.onloadstart(); + } + var that = this; + this._send_native(this._httpMethod, this._url, this._requestHeaders, data || null, function(responseInfo, responseText, error) { + that._send_native_callback(responseInfo, responseText, error); + }, this); +}; +XMLHttpRequest.prototype.abort = function() { + this.readyState = XMLHttpRequest.UNSENT; + // Note: this.onreadystatechange() is not supposed to be called according to the XHR specs +} +// responseInfo: {statusCode, statusText, responseHeaders} +XMLHttpRequest.prototype._send_native_callback = function(responseInfo, responseText, error) { + ${_debugSendNativeCallback()} + if (this.readyState === XMLHttpRequest.UNSENT) { + console.log("XHR native callback ignored because the request has been aborted"); + if (typeof this.onabort === "function") { + //console.log("Calling onabort()..."); + this.onabort(); + } + return; + } + if (this.readyState != XMLHttpRequest.LOADING) { + // Request was not expected + console.log("XHR native callback ignored because the current state is not LOADING"); + return; + } + // Response info + // TODO: responseXML? + this.responseURL = this._url; + this.status = responseInfo.statusCode; + this.statusText = responseInfo.statusText; + this.responseBody = responseInfo.body; + this._responseHeaders = responseInfo.responseHeaders || []; + this.readyState = XMLHttpRequest.DONE; + // Response + this.response = null; + this.responseText = null; + this.responseXML = null; + if (error) { + this.responseText = error; + } else { + this.responseText = responseText; + this.response = { + body: responseInfo.body, + } + // console.log('RESPONSE TEXT: ' + responseText); + } + this.readyState = XMLHttpRequest.DONE; + if (typeof this.onreadystatechange === "function") { + //console.log("Calling onreadystatechange(DONE)..."); + this.onreadystatechange(); + } + if (error === "timeout") { + // Timeout + console.warn("Got XHR timeout"); + if (typeof this.ontimeout === "function") { + //console.log("Calling ontimeout()..."); + this.ontimeout(); + } + } else if (error) { + // Error + console.warn("Got XHR error:", error); + if (typeof this.onerror === "function") { + //console.log("Calling onerror()..."); + this.onerror(); + } + } else { + // Success + //console.log("XHR success: response =", this.response); + if (typeof this.onload === "function") { + //console.log("Calling onload()..."); + this.onload(); + } + } + if (typeof this.onloadend === "function") { + //console.log("Calling onloadend()..."); + this.onloadend(); + } +}; +XMLHttpRequest.prototype.setRequestHeader = function(header, value) { + this._requestHeaders.push([header, value]); +}; +XMLHttpRequest.prototype.getAllResponseHeaders = function() { + var ret = ""; + for (var i = 0; i < this._responseHeaders.length; i++) { + var keyValue = this._responseHeaders[i]; + ret += keyValue[0] + ": " + keyValue[1] + "\\r\\n"; + } + return ret; +}; +XMLHttpRequest.prototype.getResponseHeader = function(name) { + var ret = ""; + for (var i = 0; i < this._responseHeaders.length; i++) { + var keyValue = this._responseHeaders[i]; + if (keyValue[0] !== name) continue; + if (ret === "") ret += ", "; + ret += keyValue[1]; + } + return ret; +}; +// XMLHttpRequest.prototype.overrideMimeType = function() { +// // TODO +// }; +this.XMLHttpRequest = XMLHttpRequest;"""; + +RegExp regexpHeader = RegExp("^([\\w-])+:(?!\\s*\$).+\$"); + +class XhrPendingCall { + int? idRequest; + String? method; + String? url; + Map headers; + String? body; + + XhrPendingCall({ + required this.idRequest, + required this.method, + required this.url, + required this.headers, + required this.body, + }); +} + +const XHR_PENDING_CALLS_KEY = "xhrPendingCalls"; + +http.Client? httpClient; + +xhrSetHttpClient(http.Client client) { + httpClient = client; +} + +extension JavascriptRuntimeXhrExtension on JavascriptRuntime { + List? getPendingXhrCalls() { + return dartContext[XHR_PENDING_CALLS_KEY]; + } + + bool hasPendingXhrCalls() => getPendingXhrCalls()!.length > 0; + + void clearXhrPendingCalls() { + dartContext[XHR_PENDING_CALLS_KEY] = []; + } + + Future enableFetch2() async { + enableXhr2(); + final fetchPolyfill = await FileRead.readAsString('assets/js/fetch.js'); + final evalFetchResult = evaluate(fetchPolyfill); + logger.d('Eval Fetch Result: $evalFetchResult'); + } + + + JavascriptRuntime enableXhr2() { + httpClient = httpClient ?? http.Client(); + dartContext[XHR_PENDING_CALLS_KEY] = []; + + Timer.periodic(Duration(milliseconds: 40), (timer) { + // exits if there is no pending call to remote + if (!hasPendingXhrCalls()) return; + + // collect the pending calls into a local variable making copies + List pendingCalls = List.from(getPendingXhrCalls()!); + // clear the global pending calls list + clearXhrPendingCalls(); + + // for each pending call, calls the remote http service + pendingCalls.forEach((element) async { + XhrPendingCall pendingCall = element as XhrPendingCall; + HttpMethod eMethod = HttpMethod.values + .firstWhere((e) => e.toString().toLowerCase() == ("HttpMethod.${pendingCall.method}".toLowerCase())); + late http.Response response; + switch (eMethod) { + case HttpMethod.head: + response = await httpClient!.head( + Uri.parse(pendingCall.url!), + headers: pendingCall.headers, + ); + break; + case HttpMethod.get: + response = await httpClient!.get( + Uri.parse(pendingCall.url!), + headers: pendingCall.headers, + ); + break; + case HttpMethod.post: + response = await httpClient!.post( + Uri.parse(pendingCall.url!), + body: (pendingCall.body is String) ? pendingCall.body : jsonEncode(pendingCall.body), + headers: pendingCall.headers, + ); + break; + case HttpMethod.put: + response = await httpClient!.put( + Uri.parse(pendingCall.url!), + body: (pendingCall.body is String) ? pendingCall.body : jsonEncode(pendingCall.body), + headers: pendingCall.headers, + ); + break; + case HttpMethod.patch: + response = await httpClient!.patch( + Uri.parse(pendingCall.url!), + body: (pendingCall.body is String) ? pendingCall.body : jsonEncode(pendingCall.body), + headers: pendingCall.headers, + ); + break; + case HttpMethod.delete: + response = await httpClient!.delete( + Uri.parse(pendingCall.url!), + headers: pendingCall.headers, + ); + break; + } + // assuming request was successfully executed + String? responseText; + List? body; + try { + responseText = utf8.decode(response.bodyBytes); + responseText = jsonEncode(json.decode(responseText)); + } on Exception { + // responseText = response.body; + body = response.bodyBytes; + } + + // logger.d('RESPONSE TEXT: $responseText'); + final xhrResult = XmlHttpRequestResponse( + responseText: responseText, + responseInfo: XhtmlHttpResponseInfo(statusCode: 200, statusText: "OK", body: body), + ); + + final responseInfo = jsonEncode(xhrResult.responseInfo); + //final responseText = xhrResult.responseText; //.replaceAll("\\n", "\\\n"); + final error = xhrResult.error; + // send back to the javascript environment the + // response for the http pending callback + this.evaluate( + "globalThis.xhrRequests[${pendingCall.idRequest}].callback($responseInfo, `$responseText`, $error);", + ); + }); + }); + + this.evaluate(""" + var xhrRequests = {}; + var idRequest = -1; + function XMLHttpRequestExtension_send_native() { + idRequest += 1; + var cb = arguments[4]; + var context = arguments[5]; + xhrRequests[idRequest] = { + callback: function(responseInfo, responseText, error) { + cb(responseInfo, responseText, error); + } + }; + var args = []; + args[0] = arguments[0]; + args[1] = arguments[1]; + args[2] = arguments[2]; + args[3] = arguments[3]; + args[4] = idRequest; + sendMessage('SendNative', JSON.stringify(args)); + } + """); + + final evalXhrResult = this.evaluate(xhrJsCode); + + if (_XHR_DEBUG) print('RESULT evalXhrResult: $evalXhrResult'); + + this.onMessage('SendNative', (arguments) { + try { + String? method = arguments[0]; + String? url = arguments[1]; + dynamic headersList = arguments[2]; + String? body = arguments[3]; + int? idRequest = arguments[4]; + + Map headers = {}; + headersList.forEach((header) { + // final headerMatch = regexpHeader.allMatches(value).first; + // String? headerName = headerMatch.group(0); + // String? headerValue = headerMatch.group(1); + // if (headerName != null) { + // headers[headerName] = headerValue ?? ''; + // } + String headerKey = header[0]; + headers[headerKey] = header[1]; + }); + (dartContext[XHR_PENDING_CALLS_KEY] as List).add( + XhrPendingCall( + idRequest: idRequest, + method: method, + url: url, + headers: headers, + body: body, + ), + ); + } on Error catch (e) { + if (_XHR_DEBUG) print('ERROR calling sendNative on Dart: >>>> $e'); + } on Exception catch (e) { + if (_XHR_DEBUG) print('Exception calling sendNative on Dart: >>>> $e'); + } + }); + return this; + } +} + +class XhtmlHttpResponseInfo { + final int? statusCode; + final String? statusText; + final List? body; + final List> responseHeaders = []; + + XhtmlHttpResponseInfo({ + this.body, + this.statusCode, + this.statusText, + }); + + void addResponseHeaders(String name, String value) { + responseHeaders.add([name, value]); + } + + Map toJson() { + return { + "statusCode": statusCode, + "statusText": statusText, + "body": body, + "responseHeaders": jsonEncode(responseHeaders) + }; + } +} + +class XmlHttpRequestResponse { + final String? responseText; + final String? error; // should be timeout in case of timeout + final XhtmlHttpResponseInfo? responseInfo; + + XmlHttpRequestResponse({this.responseText, this.responseInfo, this.error}); + + Map toJson() { + return {'responseText': responseText, 'responseInfo': responseInfo!.toJson(), 'error': error}; + } +} diff --git a/lib/network/components/manager/script_manager.dart b/lib/network/components/manager/script_manager.dart index 31a2ed0..4308e9c 100644 --- a/lib/network/components/manager/script_manager.dart +++ b/lib/network/components/manager/script_manager.dart @@ -21,7 +21,9 @@ import 'package:desktop_multi_window/desktop_multi_window.dart'; import 'package:flutter_js/flutter_js.dart'; import 'package:proxypin/network/components/js/file.dart'; import 'package:proxypin/network/components/js/md5.dart'; +import 'package:proxypin/network/components/js/xhr.dart'; import 'package:proxypin/network/http/http.dart'; +import 'package:proxypin/network/http/http.dart' as http; import 'package:proxypin/network/http/http_headers.dart'; import 'package:proxypin/network/util/lang.dart'; import 'package:proxypin/network/util/logger.dart'; @@ -69,7 +71,7 @@ async function onResponse(context, request, response) { final Map _scriptMap = {}; - static JavascriptRuntime flutterJs = getJavascriptRuntime(); + static JavascriptRuntime flutterJs = getJavascriptRuntime(xhr: false); static String? deviceId; @@ -89,6 +91,9 @@ async function onResponse(context, request, response) { deviceId = await DeviceUtils.deviceId(); Md5Bridge.registerMd5(flutterJs); FileBridge.registerFile(flutterJs); + + flutterJs.enableFetch2(); + logger.d('init script manager $deviceId'); } return _instance!; @@ -347,7 +352,7 @@ async function onResponse(context, request, response) { //http request HttpRequest convertHttpRequest(HttpRequest request, Map map) { request.headers.clear(); - request.method = HttpMethod.values.firstWhere((element) => element.name == map['method']); + request.method = http.HttpMethod.values.firstWhere((element) => element.name == map['method']); String query = UriUtils.mapToQuery(map['queries']); var requestUri = request.requestUri!.replace(path: map['path'], query: query); @@ -362,7 +367,7 @@ async function onResponse(context, request, response) { request.headers.addValues(key, value.map((e) => e.toString()).toList()); return; } - request.headers.add(key, value); + request.headers.set(key, value); }); //判断是否是二进制 @@ -389,7 +394,7 @@ async function onResponse(context, request, response) { return; } - response.headers.add(key, value); + response.headers.set(key, value); }); response.headers.remove(HttpHeaders.CONTENT_ENCODING); diff --git a/lib/ui/component/toolbox/js_run.dart b/lib/ui/component/toolbox/js_run.dart index b110966..8593674 100644 --- a/lib/ui/component/toolbox/js_run.dart +++ b/lib/ui/component/toolbox/js_run.dart @@ -11,6 +11,7 @@ import 'package:flutter_toastr/flutter_toastr.dart'; import 'package:highlight/languages/javascript.dart'; import 'package:proxypin/network/components/js/file.dart'; import 'package:proxypin/network/components/js/md5.dart'; +import 'package:proxypin/network/components/js/xhr.dart'; class JavaScript extends StatefulWidget { const JavaScript({super.key}); @@ -40,13 +41,14 @@ class _JavaScriptState extends State { void initState() { super.initState(); if (resetEnvironment || flutterJs == null) { - flutterJs = getJavascriptRuntime(); + flutterJs = getJavascriptRuntime(xhr: false); } // register channel callback final channelCallbacks = JavascriptRuntime.channelFunctionsRegistered[flutterJs!.getEngineInstanceId()]; channelCallbacks!["ConsoleLog"] = consoleLog; Md5Bridge.registerMd5(flutterJs!); FileBridge.registerFile(flutterJs!); + flutterJs?.enableFetch2(); code = CodeController(language: javascript, text: 'console.log("Hello, World!")'); } @@ -145,7 +147,8 @@ class _JavaScriptState extends State { ))))), const SizedBox(height: 10), Row(children: [ - Text("${localizations.output}:", style: TextStyle(fontSize: 16, color: primaryColor, fontWeight: FontWeight.w500)), + Text("${localizations.output}:", + style: TextStyle(fontSize: 16, color: primaryColor, fontWeight: FontWeight.w500)), const SizedBox(width: 15), //copy IconButton( diff --git a/pubspec.yaml b/pubspec.yaml index e310e1e..00debf7 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -59,3 +59,4 @@ flutter: - assets/certs/ca.crt - assets/certs/ca_key.pem - assets/icon.png + - assets/js/ \ No newline at end of file