diff --git a/android/.idea/misc.xml b/android/.idea/misc.xml index 860da66a..54d5acd7 100644 --- a/android/.idea/misc.xml +++ b/android/.idea/misc.xml @@ -1,6 +1,7 @@ - + + diff --git a/android/.idea/runConfigurations.xml b/android/.idea/runConfigurations.xml deleted file mode 100644 index 797acea5..00000000 --- a/android/.idea/runConfigurations.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/android/build.gradle b/android/build.gradle index 35ab45d5..89f5cdaf 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -34,6 +34,10 @@ android { lintOptions { abortOnError false } + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } } repositories { diff --git a/android/src/main/java/com/getcapacitor/plugin/http/Http.java b/android/src/main/java/com/getcapacitor/plugin/http/Http.java index afae2ead..cf62a416 100644 --- a/android/src/main/java/com/getcapacitor/plugin/http/Http.java +++ b/android/src/main/java/com/getcapacitor/plugin/http/Http.java @@ -129,6 +129,20 @@ public void del(final PluginCall call) { this.http(call, "DELETE"); } + @PluginMethod + public void __abortRequest(final PluginCall call) { + try { + Integer abortCode = call.getInt("abortCode"); + + HttpRequestHandler.abortRequest(abortCode); + + call.resolve(); + } catch (Exception e) { + System.out.println(e.toString()); + call.reject(e.getClass().getSimpleName(), e); + } + } + @PluginMethod public void downloadFile(final PluginCall call) { try { diff --git a/android/src/main/java/com/getcapacitor/plugin/http/HttpRequestHandler.java b/android/src/main/java/com/getcapacitor/plugin/http/HttpRequestHandler.java index 215beb51..14328ed0 100644 --- a/android/src/main/java/com/getcapacitor/plugin/http/HttpRequestHandler.java +++ b/android/src/main/java/com/getcapacitor/plugin/http/HttpRequestHandler.java @@ -18,9 +18,11 @@ import java.io.InputStreamReader; import java.net.HttpURLConnection; import java.net.MalformedURLException; +import java.net.SocketException; import java.net.URI; import java.net.URISyntaxException; import java.net.URL; +import java.util.HashMap; import java.util.Iterator; import java.util.List; import java.util.Map; @@ -30,6 +32,8 @@ public class HttpRequestHandler { + private static final HashMap abortMap = new HashMap<>(); + /** * An enum specifying conventional HTTP Response Types * See https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest/responseType @@ -361,6 +365,17 @@ private static String readStreamAsString(InputStream in) throws IOException { * @throws JSONException thrown when the incoming JSON is malformed */ public static JSObject request(PluginCall call, String httpMethod) throws IOException, URISyntaxException, JSONException { + JSObject signal = call.getObject("signal"); + + Integer abortCode = signal.getInteger("abortCode"); + Boolean aborted = signal.getBoolean("aborted", false); + + // If the passed signal was already aborted, the request shouldn't be made. This ensures + // compatibility with the web fetch behaviour + if (aborted != null && aborted) { + throw new SocketException(); + } + String urlString = call.getString("url", ""); JSObject headers = call.getObject("headers"); JSObject params = call.getObject("params"); @@ -398,7 +413,43 @@ public static JSObject request(PluginCall call, String httpMethod) throws IOExce connection.connect(); - return buildResponse(connection, responseType); + if (abortCode != null) { + Runnable aborter = new Runnable() { + @Override + public void run() { + connection.getHttpConnection().disconnect(); + // Remove the aborter from memory to avoid leakage + abortMap.remove(abortCode); + } + }; + + abortMap.put(abortCode, aborter); + } + + JSObject response = buildResponse(connection, responseType); + + if (abortCode != null) { + // Remove the aborter from memory to avoid leakage + abortMap.remove(abortCode); + } + + return response; + } + + /** + * Aborts a request based on its abort code, which is generated on client side + * @param abortCode Abort code for identifying the proper abort function + * @throws IllegalArgumentException thrown when the abort code is invalid, e.g. when the + * request has already been aborted + */ + public static void abortRequest(Integer abortCode) throws IllegalArgumentException { + Runnable aborter = abortMap.get(abortCode); + + if (aborter == null) { + throw new IllegalArgumentException("Invalid abort code provided"); + } + + aborter.run(); } /** diff --git a/example/package-lock.json b/example/package-lock.json index 3466cfa9..374e3df8 100644 --- a/example/package-lock.json +++ b/example/package-lock.json @@ -5,6 +5,7 @@ "requires": true, "packages": { "": { + "name": "example", "version": "0.0.1", "license": "MIT", "dependencies": { @@ -19,7 +20,7 @@ }, "..": { "name": "@capacitor-community/http", - "version": "1.0.0", + "version": "1.3.0", "license": "MIT", "dependencies": { "@capacitor/android": "^3.0.0", diff --git a/example/server/server.mjs b/example/server/server.mjs index 37404e77..774267b4 100644 --- a/example/server/server.mjs +++ b/example/server/server.mjs @@ -1,17 +1,17 @@ -import express from 'express' -import compression from 'compression' -import bodyParser from 'body-parser' -import cors from 'cors' -import cookieParser from 'cookie-parser' -import multer from 'multer' -import path from 'path' +import express from 'express'; +import compression from 'compression'; +import bodyParser from 'body-parser'; +import cors from 'cors'; +import cookieParser from 'cookie-parser'; +import multer from 'multer'; +import path from 'path'; // __dirname workaround for .mjs file import { fileURLToPath } from 'url'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const app = express(); -const upload = multer({ dest: 'uploads/' }) +const upload = multer({ dest: 'uploads/' }); const staticPath = path.join(__dirname, '/public'); @@ -35,7 +35,7 @@ app.get('/get', (req, res) => { res.send(); }); -app.get('/get-gzip', compression({ filter: (req, res) => true, threshold: 1,}), (req, res) => { +app.get('/get-gzip', compression({ filter: (req, res) => true, threshold: 1 }), (req, res) => { const headers = req.headers; const params = req.query; console.log('Got headers', headers); @@ -69,6 +69,11 @@ app.get('/head', (req, res) => { res.send(); }); +app.get('/abortable', (req, res) => { + res.status(200); + setTimeout(() => res.send(''), 2000); +}); + app.delete('/delete', (req, res) => { const headers = req.headers; console.log('DELETE'); diff --git a/example/src/components/app-home/app-home.tsx b/example/src/components/app-home/app-home.tsx index e78c47ad..1d5328a0 100644 --- a/example/src/components/app-home/app-home.tsx +++ b/example/src/components/app-home/app-home.tsx @@ -11,6 +11,8 @@ export class AppHome { @State() output: string = ''; + @State() abortController: AbortController = null; + loading: HTMLIonLoadingElement; async get(path = '/get', method = 'GET') { @@ -43,8 +45,43 @@ export class AppHome { } } + async abortable() { + this.output = 'Requesting... This can be aborted'; + + this.abortController = new AbortController(); + + // This request shouldn't show the loading modal, since it blocks the + // user from clicking the "abort" button, which defeats the purpouse of + // this demo + + try { + const ret = await Http.request({ + method: 'GET', + url: this.apiUrl('/abortable'), + headers: { + 'X-Fake-Header': 'Max was here', + }, + params: { + size: ['XL', 'L', 'M', 'S', 'XS'], + music: 'cool', + }, + signal: this.abortController.signal, + }); + console.log('Got ret', ret); + this.output = JSON.stringify(ret, null, 2); + } catch (e) { + this.output = `Error: ${e.message}, ${e.platformMessage}`; + console.error(e); + } finally { + this.abortController = null; + } + } + getDefault = () => this.get(); + getAbortable = () => this.abortable(); + abort = () => this.abortController.abort(); + getGzip = () => this.get('/get-gzip'); getJson = () => this.get('/get-json'); getHtml = () => this.get('/get-html'); @@ -233,6 +270,18 @@ export class AppHome { }; render() { + const getAbortButton = () => { + if (this.abortController) { + return ( + + Abort + + ); + } + + return Get Abortable; + }; + return [ @@ -265,6 +314,8 @@ export class AppHome { Upload File Download File + {getAbortButton()} +

Output

{this.output}
, diff --git a/package-lock.json b/package-lock.json index a721c190..d3a14bed 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,5 +1,6 @@ { "name": "@capacitor-community/http", + "version": "1.3.0", "version": "1.4.1", "lockfileVersion": 2, "requires": true, diff --git a/src/definitions.ts b/src/definitions.ts index 649cc088..343341ab 100644 --- a/src/definitions.ts +++ b/src/definitions.ts @@ -65,6 +65,13 @@ export interface HttpOptions { * (already encoded, azure/firebase testing, etc.). The default is _true_. */ shouldEncodeUrlParams?: boolean; + /** + * This is used to bind an AbortSignal to the request being made so it can be + * aborted by the AbortController + * + * @see https://developer.mozilla.org/en-US/docs/Web/API/AbortController + */ + signal?: AbortSignal; } export interface HttpParams { diff --git a/src/index.ts b/src/index.ts index 92166bde..48435eeb 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,10 +1,15 @@ -import { registerPlugin } from '@capacitor/core'; +import { Capacitor, registerPlugin } from '@capacitor/core'; +import { nativeWrap } from './native'; import type { HttpPlugin } from './definitions'; -const Http = registerPlugin('Http', { +let Http = registerPlugin('Http', { web: () => import('./web').then(m => new m.HttpWeb()), electron: () => import('./web').then(m => new m.HttpWeb()), }); +if (Capacitor.isNativePlatform()) { + Http = nativeWrap(Http); +} + export * from './definitions'; export { Http }; diff --git a/src/native.ts b/src/native.ts new file mode 100644 index 00000000..872c0fea --- /dev/null +++ b/src/native.ts @@ -0,0 +1,83 @@ +import { Capacitor } from '@capacitor/core'; +import type { HttpPlugin, HttpOptions, HttpResponse } from './definitions'; + +interface HttpNativePlugin extends HttpPlugin { + __abortRequest(options: { abortCode: number }): Promise; +} + +type RequestFn = HttpPlugin['request']; + +export function nativeWrap(Http: HttpPlugin) { + // Original proxy from the registerPlugin function + const nativePlugin = Http as HttpNativePlugin; + + // Unique id counter for the abortion codes + let abortCodeCounter = 0; + + const makeSignalProxy = + (requestFn: RequestFn) => async (options: HttpOptions) => { + if (!options.signal) { + // If a signal is not passed, we can just call the default request function + return requestFn(options); + } + + if (Capacitor.getPlatform() === 'ios') { + throw new Error('Request cancelation is not implemented on iOS'); + } + + const { signal } = options; + + const abortCode = ++abortCodeCounter; + + options = { + ...options, + // Since the original AbortSignal object is not serializable, + // we need to create our own and add the `abortCode` property + signal: { + abortCode, + aborted: signal.aborted, + } as any, + }; + + const onAbort = () => nativePlugin.__abortRequest({ abortCode }); + + signal.addEventListener('abort', onAbort); + + let response: HttpResponse; + + try { + response = await requestFn(options); + } finally { + // The event listener must be removed regardless of the result + signal.removeEventListener('abort', onAbort); + } + + return response; + }; + + const requestFnsByMethod: Record = {}; + + // Proxy wrapper around the original plugin object + return new Proxy({} as HttpPlugin, { + get(_, prop: keyof HttpNativePlugin) { + switch (prop) { + // By doing this, we prevent users from accessing this method + case '__abortRequest': + return undefined; + + case 'request': + case 'get': + case 'post': + case 'put': + case 'patch': + case 'del': + return (requestFnsByMethod[prop] ||= makeSignalProxy( + nativePlugin[prop], + )); + + default: + return nativePlugin[prop]; + } + }, + }); +} diff --git a/src/request.ts b/src/request.ts index c6400c16..24fe0922 100644 --- a/src/request.ts +++ b/src/request.ts @@ -68,6 +68,7 @@ export const buildRequestInit = ( const output: RequestInit = { method: options.method || 'GET', headers: options.headers, + signal: options.signal, ...extra, };