diff --git a/api-iife.js b/api-iife.js index 738e3cd..da5c1de 100644 --- a/api-iife.js +++ b/api-iife.js @@ -1 +1 @@ -if("__TAURI__"in window){var __TAURI_PLUGIN_HTTP__=function(e){"use strict";function t(e,t,r,n){if("function"==typeof t?e!==t||!n:!t.has(e))throw new TypeError("Cannot read private member from an object whose class did not declare it");return"m"===r?n:"a"===r?n.call(e):n?n.value:t.get(e)}function r(e,t,r,n,s){if("function"==typeof t||!t.has(e))throw new TypeError("Cannot write private member to an object whose class did not declare it");return t.set(e,r),r}var n,s,i,a;"function"==typeof SuppressedError&&SuppressedError;const o="__TAURI_TO_IPC_KEY__";class c{constructor(e){n.set(this,void 0),s.set(this,0),i.set(this,[]),a.set(this,void 0),r(this,n,e||(()=>{})),this.id=function(e,t=!1){return window.__TAURI_INTERNALS__.transformCallback(e,t)}((e=>{const o=e.index;if("end"in e)return void(o==t(this,s,"f")?this.cleanupCallback():r(this,a,o));const c=e.message;if(o==t(this,s,"f")){for(t(this,n,"f").call(this,c),r(this,s,t(this,s,"f")+1);t(this,s,"f")in t(this,i,"f");){const e=t(this,i,"f")[t(this,s,"f")];t(this,n,"f").call(this,e),delete t(this,i,"f")[t(this,s,"f")],r(this,s,t(this,s,"f")+1)}t(this,s,"f")===t(this,a,"f")&&this.cleanupCallback()}else t(this,i,"f")[o]=c}))}cleanupCallback(){window.__TAURI_INTERNALS__.unregisterCallback(this.id)}set onmessage(e){r(this,n,e)}get onmessage(){return t(this,n,"f")}[(n=new WeakMap,s=new WeakMap,i=new WeakMap,a=new WeakMap,o)](){return`__CHANNEL__:${this.id}`}toJSON(){return this[o]()}}async function d(e,t={},r){return window.__TAURI_INTERNALS__.invoke(e,t,r)}const h="Request cancelled";return e.fetch=async function(e,t){const r=t?.signal;if(r?.aborted)throw new Error(h);const n=t?.maxRedirections,s=t?.connectTimeout,i=t?.proxy,a=t?.danger;t&&(delete t.maxRedirections,delete t.connectTimeout,delete t.proxy,delete t.danger);const o=t?.headers?t.headers instanceof Headers?t.headers:new Headers(t.headers):new Headers,f=new Request(e,t),l=await f.arrayBuffer(),u=0!==l.byteLength?Array.from(new Uint8Array(l)):null;for(const[e,t]of f.headers)o.get(e)||o.set(e,t);const _=(o instanceof Headers?Array.from(o.entries()):Array.isArray(o)?o:Object.entries(o)).map((([e,t])=>[e,"string"==typeof t?t:t.toString()]));if(r?.aborted)throw new Error(h);const w=await d("plugin:http|fetch",{clientConfig:{method:f.method,url:f.url,headers:_,data:u,maxRedirections:n,connectTimeout:s,proxy:i,danger:a}}),p=()=>d("plugin:http|fetch_cancel",{rid:w});if(r?.aborted)throw p(),new Error(h);r?.addEventListener("abort",(()=>{p()}));const{status:y,statusText:m,url:b,headers:T,rid:g}=await d("plugin:http|fetch_send",{rid:w}),A=[101,103,204,205,304].includes(y)?null:new ReadableStream({start:e=>{const t=new c;t.onmessage=t=>{if(r?.aborted)return void e.error(h);const n=new Uint8Array(t),s=n[n.byteLength-1],i=n.slice(0,n.byteLength-1);1!=s?e.enqueue(i):e.close()},d("plugin:http|fetch_read_body",{rid:g,streamChannel:t}).catch((t=>{e.error(t)}))}}),R=new Response(A,{status:y,statusText:m});return Object.defineProperty(R,"url",{value:b}),Object.defineProperty(R,"headers",{value:new Headers(T)}),R},e}({});Object.defineProperty(window.__TAURI__,"http",{value:__TAURI_PLUGIN_HTTP__})} +if("__TAURI__"in window){var __TAURI_PLUGIN_HTTP__=function(e){"use strict";async function t(e,t={},r){return window.__TAURI_INTERNALS__.invoke(e,t,r)}"function"==typeof SuppressedError&&SuppressedError;const r="Request cancelled";return e.fetch=async function(e,n){const a=n?.signal;if(a?.aborted)throw new Error(r);const o=n?.maxRedirections,s=n?.connectTimeout,i=n?.proxy,d=n?.danger;n&&(delete n.maxRedirections,delete n.connectTimeout,delete n.proxy,delete n.danger);const c=n?.headers?n.headers instanceof Headers?n.headers:new Headers(n.headers):new Headers,u=new Request(e,n),l=await u.arrayBuffer(),_=0!==l.byteLength?Array.from(new Uint8Array(l)):null;for(const[e,t]of u.headers)c.get(e)||c.set(e,t);const h=(c instanceof Headers?Array.from(c.entries()):Array.isArray(c)?c:Object.entries(c)).map((([e,t])=>[e,"string"==typeof t?t:t.toString()]));if(a?.aborted)throw new Error(r);const f=await t("plugin:http|fetch",{clientConfig:{method:u.method,url:u.url,headers:h,data:_,maxRedirections:o,connectTimeout:s,proxy:i,danger:d}}),p=()=>t("plugin:http|fetch_cancel",{rid:f});if(a?.aborted)throw p(),new Error(r);a?.addEventListener("abort",(()=>{p()}));const{status:w,statusText:y,url:g,headers:b,rid:T}=await t("plugin:http|fetch_send",{rid:f}),R=()=>t("plugin:http|fetch_cancel_body",{rid:T}),m=[101,103,204,205,304].includes(w)?null:new ReadableStream({start:e=>{a?.addEventListener("abort",(()=>{e.error(r),R()}))},pull:e=>(async e=>{let r;try{r=await t("plugin:http|fetch_read_body",{rid:T})}catch(t){return e.error(t),void R()}const n=new Uint8Array(r),a=n[n.byteLength-1],o=n.slice(0,n.byteLength-1);1!==a?e.enqueue(o):e.close()})(e)}),A=new Response(m,{status:w,statusText:y});return Object.defineProperty(A,"url",{value:g}),Object.defineProperty(A,"headers",{value:new Headers(b)}),A},e}({});Object.defineProperty(window.__TAURI__,"http",{value:__TAURI_PLUGIN_HTTP__})} diff --git a/build.rs b/build.rs index a4b802a..31fa023 100644 --- a/build.rs +++ b/build.rs @@ -6,7 +6,13 @@ #[allow(dead_code)] mod scope; -const COMMANDS: &[&str] = &["fetch", "fetch_cancel", "fetch_send", "fetch_read_body"]; +const COMMANDS: &[&str] = &[ + "fetch", + "fetch_cancel", + "fetch_send", + "fetch_read_body", + "fetch_cancel_body", +]; /// HTTP scope entry. #[derive(schemars::JsonSchema)] diff --git a/dist-js/index.cjs b/dist-js/index.cjs index 93f3438..0f07539 100644 --- a/dist-js/index.cjs +++ b/dist-js/index.cjs @@ -44,7 +44,7 @@ const ERROR_REQUEST_CANCELLED = 'Request cancelled'; * @since 2.0.0 */ async function fetch(input, init) { - // abort early here if needed + // Optimistically check for abort signal and avoid doing any work const signal = init?.signal; if (signal?.aborted) { throw new Error(ERROR_REQUEST_CANCELLED); @@ -87,7 +87,7 @@ async function fetch(input, init) { // eslint-disable-next-line typeof val === 'string' ? val : val.toString() ]); - // abort early here if needed + // Optimistically check for abort signal and avoid doing any work on the Rust side if (signal?.aborted) { throw new Error(ERROR_REQUEST_CANCELLED); } @@ -104,7 +104,8 @@ async function fetch(input, init) { } }); const abort = () => core.invoke('plugin:http|fetch_cancel', { rid }); - // abort early here if needed + // Optimistically check for abort signal + // and avoid doing any work after doing intial work on the Rust side if (signal?.aborted) { // we don't care about the result of this proimse // eslint-disable-next-line @typescript-eslint/no-floating-promises @@ -115,37 +116,46 @@ async function fetch(input, init) { const { status, statusText, url, headers: responseHeaders, rid: responseRid } = await core.invoke('plugin:http|fetch_send', { rid }); + const dropBody = () => { + return core.invoke('plugin:http|fetch_cancel_body', { rid: responseRid }); + }; + const readChunk = async (controller) => { + let data; + try { + data = await core.invoke('plugin:http|fetch_read_body', { + rid: responseRid + }); + } + catch (e) { + // close the stream if an error occurs + // and drop the body on Rust side + controller.error(e); + void dropBody(); + return; + } + const dataUint8 = new Uint8Array(data); + const lastByte = dataUint8[dataUint8.byteLength - 1]; + const actualData = dataUint8.slice(0, dataUint8.byteLength - 1); + // close when the signal to close (last byte is 1) is sent from the IPC. + if (lastByte === 1) { + controller.close(); + return; + } + controller.enqueue(actualData); + }; // no body for 101, 103, 204, 205 and 304 // see https://fetch.spec.whatwg.org/#null-body-status const body = [101, 103, 204, 205, 304].includes(status) ? null : new ReadableStream({ start: (controller) => { - const streamChannel = new core.Channel(); - streamChannel.onmessage = (res) => { - // close early if aborted - if (signal?.aborted) { - controller.error(ERROR_REQUEST_CANCELLED); - return; - } - const resUint8 = new Uint8Array(res); - const lastByte = resUint8[resUint8.byteLength - 1]; - const actualRes = resUint8.slice(0, resUint8.byteLength - 1); - // close when the signal to close (last byte is 1) is sent from the IPC. - if (lastByte == 1) { - controller.close(); - return; - } - controller.enqueue(actualRes); - }; - // run a non-blocking body stream fetch - core.invoke('plugin:http|fetch_read_body', { - rid: responseRid, - streamChannel - }).catch((e) => { - controller.error(e); + // listen for abort events to cancel reading + signal?.addEventListener('abort', () => { + controller.error(ERROR_REQUEST_CANCELLED); + void dropBody(); }); - } + }, + pull: (controller) => readChunk(controller) }); const res = new Response(body, { status, diff --git a/dist-js/index.js b/dist-js/index.js index f4db76b..5fbe6e4 100644 --- a/dist-js/index.js +++ b/dist-js/index.js @@ -1,4 +1,4 @@ -import { invoke, Channel } from '@tauri-apps/api/core'; +import { invoke } from '@tauri-apps/api/core'; // Copyright 2019-2023 Tauri Programme within The Commons Conservancy // SPDX-License-Identifier: Apache-2.0 @@ -42,7 +42,7 @@ const ERROR_REQUEST_CANCELLED = 'Request cancelled'; * @since 2.0.0 */ async function fetch(input, init) { - // abort early here if needed + // Optimistically check for abort signal and avoid doing any work const signal = init?.signal; if (signal?.aborted) { throw new Error(ERROR_REQUEST_CANCELLED); @@ -85,7 +85,7 @@ async function fetch(input, init) { // eslint-disable-next-line typeof val === 'string' ? val : val.toString() ]); - // abort early here if needed + // Optimistically check for abort signal and avoid doing any work on the Rust side if (signal?.aborted) { throw new Error(ERROR_REQUEST_CANCELLED); } @@ -102,7 +102,8 @@ async function fetch(input, init) { } }); const abort = () => invoke('plugin:http|fetch_cancel', { rid }); - // abort early here if needed + // Optimistically check for abort signal + // and avoid doing any work after doing intial work on the Rust side if (signal?.aborted) { // we don't care about the result of this proimse // eslint-disable-next-line @typescript-eslint/no-floating-promises @@ -113,37 +114,46 @@ async function fetch(input, init) { const { status, statusText, url, headers: responseHeaders, rid: responseRid } = await invoke('plugin:http|fetch_send', { rid }); + const dropBody = () => { + return invoke('plugin:http|fetch_cancel_body', { rid: responseRid }); + }; + const readChunk = async (controller) => { + let data; + try { + data = await invoke('plugin:http|fetch_read_body', { + rid: responseRid + }); + } + catch (e) { + // close the stream if an error occurs + // and drop the body on Rust side + controller.error(e); + void dropBody(); + return; + } + const dataUint8 = new Uint8Array(data); + const lastByte = dataUint8[dataUint8.byteLength - 1]; + const actualData = dataUint8.slice(0, dataUint8.byteLength - 1); + // close when the signal to close (last byte is 1) is sent from the IPC. + if (lastByte === 1) { + controller.close(); + return; + } + controller.enqueue(actualData); + }; // no body for 101, 103, 204, 205 and 304 // see https://fetch.spec.whatwg.org/#null-body-status const body = [101, 103, 204, 205, 304].includes(status) ? null : new ReadableStream({ start: (controller) => { - const streamChannel = new Channel(); - streamChannel.onmessage = (res) => { - // close early if aborted - if (signal?.aborted) { - controller.error(ERROR_REQUEST_CANCELLED); - return; - } - const resUint8 = new Uint8Array(res); - const lastByte = resUint8[resUint8.byteLength - 1]; - const actualRes = resUint8.slice(0, resUint8.byteLength - 1); - // close when the signal to close (last byte is 1) is sent from the IPC. - if (lastByte == 1) { - controller.close(); - return; - } - controller.enqueue(actualRes); - }; - // run a non-blocking body stream fetch - invoke('plugin:http|fetch_read_body', { - rid: responseRid, - streamChannel - }).catch((e) => { - controller.error(e); + // listen for abort events to cancel reading + signal?.addEventListener('abort', () => { + controller.error(ERROR_REQUEST_CANCELLED); + void dropBody(); }); - } + }, + pull: (controller) => readChunk(controller) }); const res = new Response(body, { status, diff --git a/guest-js/index.ts b/guest-js/index.ts index 4b38f8b..3a98042 100644 --- a/guest-js/index.ts +++ b/guest-js/index.ts @@ -26,7 +26,7 @@ * @module */ -import { Channel, invoke } from '@tauri-apps/api/core' +import { invoke } from '@tauri-apps/api/core' /** * Configuration of a proxy that a Client should pass requests to. @@ -126,7 +126,7 @@ export async function fetch( input: URL | Request | string, init?: RequestInit & ClientOptions ): Promise { - // abort early here if needed + // Optimistically check for abort signal and avoid doing any work const signal = init?.signal if (signal?.aborted) { throw new Error(ERROR_REQUEST_CANCELLED) @@ -181,7 +181,7 @@ export async function fetch( ] ) - // abort early here if needed + // Optimistically check for abort signal and avoid doing any work on the Rust side if (signal?.aborted) { throw new Error(ERROR_REQUEST_CANCELLED) } @@ -201,7 +201,8 @@ export async function fetch( const abort = () => invoke('plugin:http|fetch_cancel', { rid }) - // abort early here if needed + // Optimistically check for abort signal + // and avoid doing any work after doing intial work on the Rust side if (signal?.aborted) { // we don't care about the result of this proimse // eslint-disable-next-line @typescript-eslint/no-floating-promises @@ -229,41 +230,52 @@ export async function fetch( rid }) + const dropBody = () => { + return invoke('plugin:http|fetch_cancel_body', { rid: responseRid }) + } + + const readChunk = async ( + controller: ReadableStreamDefaultController + ) => { + let data: ArrayBuffer + try { + data = await invoke('plugin:http|fetch_read_body', { + rid: responseRid + }) + } catch (e) { + // close the stream if an error occurs + // and drop the body on Rust side + controller.error(e) + void dropBody() + return + } + + const dataUint8 = new Uint8Array(data) + const lastByte = dataUint8[dataUint8.byteLength - 1] + const actualData = dataUint8.slice(0, dataUint8.byteLength - 1) + + // close when the signal to close (last byte is 1) is sent from the IPC. + if (lastByte === 1) { + controller.close() + return + } + + controller.enqueue(actualData) + } + // no body for 101, 103, 204, 205 and 304 // see https://fetch.spec.whatwg.org/#null-body-status const body = [101, 103, 204, 205, 304].includes(status) ? null - : new ReadableStream({ + : new ReadableStream({ start: (controller) => { - const streamChannel = new Channel() - streamChannel.onmessage = (res: ArrayBuffer | number[]) => { - // close early if aborted - if (signal?.aborted) { - controller.error(ERROR_REQUEST_CANCELLED) - return - } - - const resUint8 = new Uint8Array(res) - const lastByte = resUint8[resUint8.byteLength - 1] - const actualRes = resUint8.slice(0, resUint8.byteLength - 1) - - // close when the signal to close (last byte is 1) is sent from the IPC. - if (lastByte == 1) { - controller.close() - return - } - - controller.enqueue(actualRes) - } - - // run a non-blocking body stream fetch - invoke('plugin:http|fetch_read_body', { - rid: responseRid, - streamChannel - }).catch((e) => { - controller.error(e) + // listen for abort events to cancel reading + signal?.addEventListener('abort', () => { + controller.error(ERROR_REQUEST_CANCELLED) + void dropBody() }) - } + }, + pull: (controller) => readChunk(controller) }) const res = new Response(body, { diff --git a/permissions/autogenerated/commands/fetch_cancel_body.toml b/permissions/autogenerated/commands/fetch_cancel_body.toml new file mode 100644 index 0000000..19e6652 --- /dev/null +++ b/permissions/autogenerated/commands/fetch_cancel_body.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-fetch-cancel-body" +description = "Enables the fetch_cancel_body command without any pre-configured scope." +commands.allow = ["fetch_cancel_body"] + +[[permission]] +identifier = "deny-fetch-cancel-body" +description = "Denies the fetch_cancel_body command without any pre-configured scope." +commands.deny = ["fetch_cancel_body"] diff --git a/permissions/autogenerated/reference.md b/permissions/autogenerated/reference.md index 40c41ca..2f39e74 100644 --- a/permissions/autogenerated/reference.md +++ b/permissions/autogenerated/reference.md @@ -15,8 +15,9 @@ All fetch operations are enabled. - `allow-fetch` - `allow-fetch-cancel` -- `allow-fetch-read-body` - `allow-fetch-send` +- `allow-fetch-read-body` +- `allow-fetch-cancel-body` ## Permission Table @@ -82,6 +83,32 @@ Denies the fetch_cancel command without any pre-configured scope. +`http:allow-fetch-cancel-body` + + + + +Enables the fetch_cancel_body command without any pre-configured scope. + + + + + + + +`http:deny-fetch-cancel-body` + + + + +Denies the fetch_cancel_body command without any pre-configured scope. + + + + + + + `http:allow-fetch-read-body` diff --git a/permissions/default.toml b/permissions/default.toml index b469536..541bf49 100644 --- a/permissions/default.toml +++ b/permissions/default.toml @@ -17,6 +17,7 @@ All fetch operations are enabled. permissions = [ "allow-fetch", "allow-fetch-cancel", - "allow-fetch-read-body", "allow-fetch-send", + "allow-fetch-read-body", + "allow-fetch-cancel-body", ] diff --git a/permissions/schemas/schema.json b/permissions/schemas/schema.json index ea77439..a6b457b 100644 --- a/permissions/schemas/schema.json +++ b/permissions/schemas/schema.json @@ -318,6 +318,18 @@ "const": "deny-fetch-cancel", "markdownDescription": "Denies the fetch_cancel command without any pre-configured scope." }, + { + "description": "Enables the fetch_cancel_body command without any pre-configured scope.", + "type": "string", + "const": "allow-fetch-cancel-body", + "markdownDescription": "Enables the fetch_cancel_body command without any pre-configured scope." + }, + { + "description": "Denies the fetch_cancel_body command without any pre-configured scope.", + "type": "string", + "const": "deny-fetch-cancel-body", + "markdownDescription": "Denies the fetch_cancel_body command without any pre-configured scope." + }, { "description": "Enables the fetch_read_body command without any pre-configured scope.", "type": "string", @@ -343,10 +355,10 @@ "markdownDescription": "Denies the fetch_send command without any pre-configured scope." }, { - "description": "This permission set configures what kind of\nfetch operations are available from the http plugin.\n\nThis enables all fetch operations but does not\nallow explicitly any origins to be fetched. This needs to\nbe manually configured before usage.\n\n#### Granted Permissions\n\nAll fetch operations are enabled.\n\n\n#### This default permission set includes:\n\n- `allow-fetch`\n- `allow-fetch-cancel`\n- `allow-fetch-read-body`\n- `allow-fetch-send`", + "description": "This permission set configures what kind of\nfetch operations are available from the http plugin.\n\nThis enables all fetch operations but does not\nallow explicitly any origins to be fetched. This needs to\nbe manually configured before usage.\n\n#### Granted Permissions\n\nAll fetch operations are enabled.\n\n\n#### This default permission set includes:\n\n- `allow-fetch`\n- `allow-fetch-cancel`\n- `allow-fetch-send`\n- `allow-fetch-read-body`\n- `allow-fetch-cancel-body`", "type": "string", "const": "default", - "markdownDescription": "This permission set configures what kind of\nfetch operations are available from the http plugin.\n\nThis enables all fetch operations but does not\nallow explicitly any origins to be fetched. This needs to\nbe manually configured before usage.\n\n#### Granted Permissions\n\nAll fetch operations are enabled.\n\n\n#### This default permission set includes:\n\n- `allow-fetch`\n- `allow-fetch-cancel`\n- `allow-fetch-read-body`\n- `allow-fetch-send`" + "markdownDescription": "This permission set configures what kind of\nfetch operations are available from the http plugin.\n\nThis enables all fetch operations but does not\nallow explicitly any origins to be fetched. This needs to\nbe manually configured before usage.\n\n#### Granted Permissions\n\nAll fetch operations are enabled.\n\n\n#### This default permission set includes:\n\n- `allow-fetch`\n- `allow-fetch-cancel`\n- `allow-fetch-send`\n- `allow-fetch-read-body`\n- `allow-fetch-cancel-body`" } ] } diff --git a/src/commands.rs b/src/commands.rs index bb47444..0ed6a12 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -10,7 +10,7 @@ use serde::{Deserialize, Serialize}; use tauri::{ async_runtime::Mutex, command, - ipc::{Channel, CommandScope, GlobalScope}, + ipc::{CommandScope, GlobalScope}, Manager, ResourceId, ResourceTable, Runtime, State, Webview, }; use tokio::sync::oneshot::{channel, Receiver, Sender}; @@ -415,26 +415,42 @@ pub async fn fetch_send( pub async fn fetch_read_body( webview: Webview, rid: ResourceId, - stream_channel: Channel, -) -> crate::Result<()> { +) -> crate::Result { let res = { - let mut resources_table = webview.resources_table(); - resources_table.take::(rid)? + let resources_table = webview.resources_table(); + resources_table.get::(rid)? }; - let mut res = Arc::into_inner(res).unwrap().0; + // SAFETY: we can access the inner value mutably + // because we are the only ones with a reference to it + // and we don't want to use `Arc::into_inner` because we want to keep the value in the table + // for potential future calls to `fetch_cancel_body` + let res_ptr = Arc::as_ptr(&res) as *mut ReqwestResponse; + let res = unsafe { &mut *res_ptr }; + let res = &mut res.0; - // send response through IPC channel - while let Some(chunk) = res.chunk().await? { - let mut chunk = chunk.to_vec(); - // append 0 to indicate we are not done yet - chunk.push(0); - stream_channel.send(tauri::ipc::InvokeResponseBody::Raw(chunk))?; - } + let Some(chunk) = res.chunk().await? else { + let mut resources_table = webview.resources_table(); + resources_table.close(rid)?; - // send 1 to indicate we are done - stream_channel.send(tauri::ipc::InvokeResponseBody::Raw(vec![1]))?; + // return a response with a single byte to indicate that the body is empty + return Ok(tauri::ipc::Response::new(vec![1])); + }; + let mut chunk = chunk.to_vec(); + // append a 0 byte to indicate that the body is not empty + chunk.push(0); + + Ok(tauri::ipc::Response::new(chunk)) +} + +#[command] +pub async fn fetch_cancel_body( + webview: Webview, + rid: ResourceId, +) -> crate::Result<()> { + let mut resources_table = webview.resources_table(); + resources_table.close(rid)?; Ok(()) } diff --git a/src/lib.rs b/src/lib.rs index 5acc2b4..364d3bf 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -84,7 +84,8 @@ pub fn init() -> TauriPlugin { commands::fetch, commands::fetch_cancel, commands::fetch_send, - commands::fetch_read_body + commands::fetch_read_body, + commands::fetch_cancel_body, ]) .build() }