Browse Source

feat: add protocol.handle (#36674)

Jeremy Rose 2 years ago
parent
commit
fda8ea9277

+ 2 - 0
docs/api/client-request.md

@@ -243,6 +243,8 @@ it is not allowed to add or remove a custom header.
 * `encoding` string (optional)
 * `callback` Function (optional)
 
+Returns `this`.
+
 Sends the last chunk of the request data. Subsequent write or end operations
 will not be allowed. The `finish` event is emitted just after the end operation.
 

+ 19 - 5
docs/api/net.md

@@ -65,8 +65,8 @@ requests according to the specified protocol scheme in the `options` object.
 
 ### `net.fetch(input[, init])`
 
-* `input` string | [Request](https://nodejs.org/api/globals.html#request)
-* `init` [RequestInit](https://developer.mozilla.org/en-US/docs/Web/API/fetch#options) (optional)
+* `input` string | [GlobalRequest](https://nodejs.org/api/globals.html#request)
+* `init` [RequestInit](https://developer.mozilla.org/en-US/docs/Web/API/fetch#options) & { bypassCustomProtocolHandlers?: boolean } (optional)
 
 Returns `Promise<GlobalResponse>` - see [Response](https://developer.mozilla.org/en-US/docs/Web/API/Response).
 
@@ -101,9 +101,23 @@ Limitations:
 * The `.type` and `.url` values of the returned `Response` object are
   incorrect.
 
-Requests made with `net.fetch` can be made to [custom protocols](protocol.md)
-as well as `file:`, and will trigger [webRequest](web-request.md) handlers if
-present.
+By default, requests made with `net.fetch` can be made to [custom
+protocols](protocol.md) as well as `file:`, and will trigger
+[webRequest](web-request.md) handlers if present. When the non-standard
+`bypassCustomProtocolHandlers` option is set in RequestInit, custom protocol
+handlers will not be called for this request. This allows forwarding an
+intercepted request to the built-in handler. [webRequest](web-request.md)
+handlers will still be triggered when bypassing custom protocols.
+
+```js
+protocol.handle('https', (req) => {
+  if (req.url === 'https://my-app.com') {
+    return new Response('<body>my app</body>')
+  } else {
+    return net.fetch(req, { bypassCustomProtocolHandlers: true })
+  }
+})
+```
 
 ### `net.isOnline()`
 

+ 88 - 24
docs/api/protocol.md

@@ -8,15 +8,11 @@ An example of implementing a protocol that has the same effect as the
 `file://` protocol:
 
 ```javascript
-const { app, protocol } = require('electron')
-const path = require('path')
-const url = require('url')
+const { app, protocol, net } = require('electron')
 
 app.whenReady().then(() => {
-  protocol.registerFileProtocol('atom', (request, callback) => {
-    const filePath = url.fileURLToPath('file://' + request.url.slice('atom://'.length))
-    callback(filePath)
-  })
+  protocol.handle('atom', (request) =>
+    net.fetch('file://' + request.url.slice('atom://'.length)))
 })
 ```
 
@@ -38,14 +34,15 @@ to register it to that session explicitly.
 ```javascript
 const { session, app, protocol } = require('electron')
 const path = require('path')
+const url = require('url')
 
 app.whenReady().then(() => {
   const partition = 'persist:example'
   const ses = session.fromPartition(partition)
 
-  ses.protocol.registerFileProtocol('atom', (request, callback) => {
-    const url = request.url.substr(7)
-    callback({ path: path.normalize(`${__dirname}/${url}`) })
+  ses.protocol.handle('atom', (request) => {
+    const path = request.url.slice('atom://'.length)
+    return net.fetch(url.pathToFileURL(path.join(__dirname, path)))
   })
 
   mainWindow = new BrowserWindow({ webPreferences: { partition } })
@@ -109,7 +106,74 @@ The `<video>` and `<audio>` HTML elements expect protocols to buffer their
 responses by default. The `stream` flag configures those elements to correctly
 expect streaming responses.
 
-### `protocol.registerFileProtocol(scheme, handler)`
+### `protocol.handle(scheme, handler)`
+
+* `scheme` string - scheme to handle, for example `https` or `my-app`. This is
+  the bit before the `:` in a URL.
+* `handler` Function<[GlobalResponse](https://nodejs.org/api/globals.html#response) | Promise<GlobalResponse>>
+  * `request` [GlobalRequest](https://nodejs.org/api/globals.html#request)
+
+Register a protocol handler for `scheme`. Requests made to URLs with this
+scheme will delegate to this handler to determine what response should be sent.
+
+Either a `Response` or a `Promise<Response>` can be returned.
+
+Example:
+
+```js
+import { app, protocol } from 'electron'
+import { join } from 'path'
+import { pathToFileURL } from 'url'
+
+protocol.registerSchemesAsPrivileged([
+  {
+    scheme: 'app',
+    privileges: {
+      standard: true,
+      secure: true,
+      supportsFetchAPI: true
+    }
+  }
+])
+
+app.whenReady().then(() => {
+  protocol.handle('app', (req) => {
+    const { host, pathname } = new URL(req.url)
+    if (host === 'bundle') {
+      if (pathname === '/') {
+        return new Response('<h1>hello, world</h1>', {
+          headers: { 'content-type': 'text/html' }
+        })
+      }
+      // NB, this does not check for paths that escape the bundle, e.g.
+      // app://bundle/../../secret_file.txt
+      return net.fetch(pathToFileURL(join(__dirname, pathname)))
+    } else if (host === 'api') {
+      return net.fetch('https://api.my-server.com/' + pathname, {
+        method: req.method,
+        headers: req.headers,
+        body: req.body
+      })
+    }
+  })
+})
+```
+
+See the MDN docs for [`Request`](https://developer.mozilla.org/en-US/docs/Web/API/Request) and [`Response`](https://developer.mozilla.org/en-US/docs/Web/API/Response) for more details.
+
+### `protocol.unhandle(scheme)`
+
+* `scheme` string - scheme for which to remove the handler.
+
+Removes a protocol handler registered with `protocol.handle`.
+
+### `protocol.isProtocolHandled(scheme)`
+
+* `scheme` string
+
+Returns `boolean` - Whether `scheme` is already handled.
+
+### `protocol.registerFileProtocol(scheme, handler)` _Deprecated_
 
 * `scheme` string
 * `handler` Function
@@ -130,7 +194,7 @@ path or an object that has a `path` property, e.g. `callback(filePath)` or
 By default the `scheme` is treated like `http:`, which is parsed differently
 from protocols that follow the "generic URI syntax" like `file:`.
 
-### `protocol.registerBufferProtocol(scheme, handler)`
+### `protocol.registerBufferProtocol(scheme, handler)` _Deprecated_
 
 * `scheme` string
 * `handler` Function
@@ -154,7 +218,7 @@ protocol.registerBufferProtocol('atom', (request, callback) => {
 })
 ```
 
-### `protocol.registerStringProtocol(scheme, handler)`
+### `protocol.registerStringProtocol(scheme, handler)` _Deprecated_
 
 * `scheme` string
 * `handler` Function
@@ -170,7 +234,7 @@ The usage is the same with `registerFileProtocol`, except that the `callback`
 should be called with either a `string` or an object that has the `data`
 property.
 
-### `protocol.registerHttpProtocol(scheme, handler)`
+### `protocol.registerHttpProtocol(scheme, handler)` _Deprecated_
 
 * `scheme` string
 * `handler` Function
@@ -185,7 +249,7 @@ Registers a protocol of `scheme` that will send an HTTP request as a response.
 The usage is the same with `registerFileProtocol`, except that the `callback`
 should be called with an object that has the `url` property.
 
-### `protocol.registerStreamProtocol(scheme, handler)`
+### `protocol.registerStreamProtocol(scheme, handler)` _Deprecated_
 
 * `scheme` string
 * `handler` Function
@@ -234,7 +298,7 @@ protocol.registerStreamProtocol('atom', (request, callback) => {
 })
 ```
 
-### `protocol.unregisterProtocol(scheme)`
+### `protocol.unregisterProtocol(scheme)` _Deprecated_
 
 * `scheme` string
 
@@ -242,13 +306,13 @@ Returns `boolean` - Whether the protocol was successfully unregistered
 
 Unregisters the custom protocol of `scheme`.
 
-### `protocol.isProtocolRegistered(scheme)`
+### `protocol.isProtocolRegistered(scheme)` _Deprecated_
 
 * `scheme` string
 
 Returns `boolean` - Whether `scheme` is already registered.
 
-### `protocol.interceptFileProtocol(scheme, handler)`
+### `protocol.interceptFileProtocol(scheme, handler)` _Deprecated_
 
 * `scheme` string
 * `handler` Function
@@ -261,7 +325,7 @@ Returns `boolean` - Whether the protocol was successfully intercepted
 Intercepts `scheme` protocol and uses `handler` as the protocol's new handler
 which sends a file as a response.
 
-### `protocol.interceptStringProtocol(scheme, handler)`
+### `protocol.interceptStringProtocol(scheme, handler)` _Deprecated_
 
 * `scheme` string
 * `handler` Function
@@ -274,7 +338,7 @@ Returns `boolean` - Whether the protocol was successfully intercepted
 Intercepts `scheme` protocol and uses `handler` as the protocol's new handler
 which sends a `string` as a response.
 
-### `protocol.interceptBufferProtocol(scheme, handler)`
+### `protocol.interceptBufferProtocol(scheme, handler)` _Deprecated_
 
 * `scheme` string
 * `handler` Function
@@ -287,7 +351,7 @@ Returns `boolean` - Whether the protocol was successfully intercepted
 Intercepts `scheme` protocol and uses `handler` as the protocol's new handler
 which sends a `Buffer` as a response.
 
-### `protocol.interceptHttpProtocol(scheme, handler)`
+### `protocol.interceptHttpProtocol(scheme, handler)` _Deprecated_
 
 * `scheme` string
 * `handler` Function
@@ -300,7 +364,7 @@ Returns `boolean` - Whether the protocol was successfully intercepted
 Intercepts `scheme` protocol and uses `handler` as the protocol's new handler
 which sends a new HTTP request as a response.
 
-### `protocol.interceptStreamProtocol(scheme, handler)`
+### `protocol.interceptStreamProtocol(scheme, handler)` _Deprecated_
 
 * `scheme` string
 * `handler` Function
@@ -313,7 +377,7 @@ Returns `boolean` - Whether the protocol was successfully intercepted
 Same as `protocol.registerStreamProtocol`, except that it replaces an existing
 protocol handler.
 
-### `protocol.uninterceptProtocol(scheme)`
+### `protocol.uninterceptProtocol(scheme)` _Deprecated_
 
 * `scheme` string
 
@@ -321,7 +385,7 @@ Returns `boolean` - Whether the protocol was successfully unintercepted
 
 Remove the interceptor installed for `scheme` and restore its original handler.
 
-### `protocol.isProtocolIntercepted(scheme)`
+### `protocol.isProtocolIntercepted(scheme)` _Deprecated_
 
 * `scheme` string
 

+ 17 - 3
docs/api/session.md

@@ -784,9 +784,23 @@ Limitations:
 * The `.type` and `.url` values of the returned `Response` object are
   incorrect.
 
-Requests made with `ses.fetch` can be made to [custom protocols](protocol.md)
-as well as `file:`, and will trigger [webRequest](web-request.md) handlers if
-present.
+By default, requests made with `net.fetch` can be made to [custom
+protocols](protocol.md) as well as `file:`, and will trigger
+[webRequest](web-request.md) handlers if present. When the non-standard
+`bypassCustomProtocolHandlers` option is set in RequestInit, custom protocol
+handlers will not be called for this request. This allows forwarding an
+intercepted request to the built-in handler. [webRequest](web-request.md)
+handlers will still be triggered when bypassing custom protocols.
+
+```js
+protocol.handle('https', (req) => {
+  if (req.url === 'https://my-app.com') {
+    return new Response('<body>my app</body>')
+  } else {
+    return net.fetch(req, { bypassCustomProtocolHandlers: true })
+  }
+})
+```
 
 #### `ses.disableNetworkEmulation()`
 

+ 4 - 4
docs/api/structures/upload-file.md

@@ -2,8 +2,8 @@
 
 * `type` 'file' - `file`.
 * `filePath` string - Path of file to be uploaded.
-* `offset` Integer - Defaults to `0`.
-* `length` Integer - Number of bytes to read from `offset`.
+* `offset` Integer (optional) - Defaults to `0`.
+* `length` Integer (optional) - Number of bytes to read from `offset`.
   Defaults to `0`.
-* `modificationTime` Double - Last Modification time in
-  number of seconds since the UNIX epoch.
+* `modificationTime` Double (optional) - Last Modification time in
+  number of seconds since the UNIX epoch. Defaults to `0`.

+ 49 - 0
docs/breaking-changes.md

@@ -12,6 +12,55 @@ This document uses the following convention to categorize breaking changes:
 * **Deprecated:** An API was marked as deprecated. The API will continue to function, but will emit a deprecation warning, and will be removed in a future release.
 * **Removed:** An API or feature was removed, and is no longer supported by Electron.
 
+## Planned Breaking API Changes (25.0)
+
+### Deprecated: `protocol.{register,intercept}{Buffer,String,Stream,File,Http}Protocol`
+
+The `protocol.register*Protocol` and `protocol.intercept*Protocol` methods have
+been replaced with [`protocol.handle`](api/protocol.md#protocolhandlescheme-handler).
+
+The new method can either register a new protocol or intercept an existing
+protocol, and responses can be of any type.
+
+```js
+// Deprecated in Electron 25
+protocol.registerBufferProtocol('some-protocol', () => {
+  callback({ mimeType: 'text/html', data: Buffer.from('<h5>Response</h5>') })
+})
+
+// Replace with
+protocol.handle('some-protocol', () => {
+  return new Response(
+    Buffer.from('<h5>Response</h5>'), // Could also be a string or ReadableStream.
+    { headers: { 'content-type': 'text/html' } }
+  )
+})
+```
+
+```js
+// Deprecated in Electron 25
+protocol.registerHttpProtocol('some-protocol', () => {
+  callback({ url: 'https://electronjs.org' })
+})
+
+// Replace with
+protocol.handle('some-protocol', () => {
+  return net.fetch('https://electronjs.org')
+})
+```
+
+```js
+// Deprecated in Electron 25
+protocol.registerFileProtocol('some-protocol', () => {
+  callback({ filePath: '/path/to/my/file' })
+})
+
+// Replace with
+protocol.handle('some-protocol', () => {
+  return net.fetch('file:///path/to/my/file')
+})
+```
+
 ## Planned Breaking API Changes (24.0)
 
 ### API Changed: `nativeImage.createThumbnailFromPath(path, size)`

+ 4 - 1
lib/browser/api/net-fetch.ts

@@ -13,7 +13,7 @@ function createDeferredPromise<T, E extends Error = Error> (): { promise: Promis
   return { promise, resolve: res!, reject: rej! };
 }
 
-export function fetchWithSession (input: RequestInfo, init: RequestInit | undefined, session: SessionT): Promise<Response> {
+export function fetchWithSession (input: RequestInfo, init: (RequestInit & {bypassCustomProtocolHandlers?: boolean}) | undefined, session: SessionT): Promise<Response> {
   const p = createDeferredPromise<Response>();
   let req: Request;
   try {
@@ -84,6 +84,8 @@ export function fetchWithSession (input: RequestInfo, init: RequestInit | undefi
     redirect: req.redirect
   }));
 
+  (r as any)._urlLoaderOptions.bypassCustomProtocolHandlers = !!init?.bypassCustomProtocolHandlers;
+
   // cors is the default mode, but we can't set mode=cors without an origin.
   if (req.mode && (req.mode !== 'cors' || origin)) {
     r.setHeader('Sec-Fetch-Mode', req.mode);
@@ -104,6 +106,7 @@ export function fetchWithSession (input: RequestInfo, init: RequestInit | undefi
       status: resp.statusCode,
       statusText: resp.statusMessage
     });
+    (rResp as any).__original_resp = resp;
     p.resolve(rResp);
   });
 

+ 126 - 29
lib/browser/api/protocol.ts

@@ -1,33 +1,130 @@
-import { app, session } from 'electron/main';
+import { ProtocolRequest, session } from 'electron/main';
+import { createReadStream } from 'fs';
+import { Readable } from 'stream';
+import { ReadableStream } from 'stream/web';
 
 // Global protocol APIs.
-const protocol = process._linkedBinding('electron_browser_protocol');
-
-// Fallback protocol APIs of default session.
-Object.setPrototypeOf(protocol, new Proxy({}, {
-  get (_target, property) {
-    if (!app.isReady()) return;
-
-    const protocol = session.defaultSession!.protocol;
-    if (!Object.prototype.hasOwnProperty.call(protocol, property)) return;
-
-    // Returning a native function directly would throw error.
-    return (...args: any[]) => (protocol[property as keyof Electron.Protocol] as Function)(...args);
-  },
-
-  ownKeys () {
-    if (!app.isReady()) return [];
-    return Reflect.ownKeys(session.defaultSession!.protocol);
-  },
-
-  has: (target, property: string) => {
-    if (!app.isReady()) return false;
-    return Reflect.has(session.defaultSession!.protocol, property);
-  },
-
-  getOwnPropertyDescriptor () {
-    return { configurable: true, enumerable: true };
-  }
-}));
+const { registerSchemesAsPrivileged, getStandardSchemes, Protocol } = process._linkedBinding('electron_browser_protocol');
+
+const ERR_FAILED = -2;
+const ERR_UNEXPECTED = -9;
+
+const isBuiltInScheme = (scheme: string) => scheme === 'http' || scheme === 'https';
+
+function makeStreamFromPipe (pipe: any): ReadableStream {
+  const buf = new Uint8Array(1024 * 1024 /* 1 MB */);
+  return new ReadableStream({
+    async pull (controller) {
+      try {
+        const rv = await pipe.read(buf);
+        if (rv > 0) {
+          controller.enqueue(buf.subarray(0, rv));
+        } else {
+          controller.close();
+        }
+      } catch (e) {
+        controller.error(e);
+      }
+    }
+  });
+}
+
+function convertToRequestBody (uploadData: ProtocolRequest['uploadData']): RequestInit['body'] {
+  if (!uploadData) return null;
+  // Optimization: skip creating a stream if the request is just a single buffer.
+  if (uploadData.length === 1 && (uploadData[0] as any).type === 'rawData') return uploadData[0].bytes;
+
+  const chunks = [...uploadData] as any[]; // TODO: types are wrong
+  let current: ReadableStreamDefaultReader | null = null;
+  return new ReadableStream({
+    pull (controller) {
+      if (current) {
+        current.read().then(({ done, value }) => {
+          controller.enqueue(value);
+          if (done) current = null;
+        }, (err) => {
+          controller.error(err);
+        });
+      } else {
+        if (!chunks.length) { return controller.close(); }
+        const chunk = chunks.shift()!;
+        if (chunk.type === 'rawData') { controller.enqueue(chunk.bytes); } else if (chunk.type === 'file') {
+          current = Readable.toWeb(createReadStream(chunk.filePath, { start: chunk.offset ?? 0, end: chunk.length >= 0 ? chunk.offset + chunk.length : undefined })).getReader();
+          this.pull!(controller);
+        } else if (chunk.type === 'stream') {
+          current = makeStreamFromPipe(chunk.body).getReader();
+          this.pull!(controller);
+        }
+      }
+    }
+  }) as RequestInit['body'];
+}
+
+Protocol.prototype.handle = function (this: Electron.Protocol, scheme: string, handler: (req: Request) => Response | Promise<Response>) {
+  const register = isBuiltInScheme(scheme) ? this.interceptProtocol : this.registerProtocol;
+  const success = register.call(this, scheme, async (preq: ProtocolRequest, cb: any) => {
+    try {
+      const body = convertToRequestBody(preq.uploadData);
+      const req = new Request(preq.url, {
+        headers: preq.headers,
+        method: preq.method,
+        referrer: preq.referrer,
+        body,
+        duplex: body instanceof ReadableStream ? 'half' : undefined
+      } as any);
+      const res = await handler(req);
+      if (!res || typeof res !== 'object') {
+        return cb({ error: ERR_UNEXPECTED });
+      }
+      if (res.type === 'error') { cb({ error: ERR_FAILED }); } else {
+        cb({
+          data: res.body ? Readable.fromWeb(res.body as ReadableStream<ArrayBufferView>) : null,
+          headers: Object.fromEntries(res.headers),
+          statusCode: res.status,
+          statusText: res.statusText,
+          mimeType: (res as any).__original_resp?._responseHead?.mimeType
+        });
+      }
+    } catch (e) {
+      console.error(e);
+      cb({ error: ERR_UNEXPECTED });
+    }
+  });
+  if (!success) throw new Error(`Failed to register protocol: ${scheme}`);
+};
+
+Protocol.prototype.unhandle = function (this: Electron.Protocol, scheme: string) {
+  const unregister = isBuiltInScheme(scheme) ? this.uninterceptProtocol : this.unregisterProtocol;
+  if (!unregister.call(this, scheme)) { throw new Error(`Failed to unhandle protocol: ${scheme}`); }
+};
+
+Protocol.prototype.isProtocolHandled = function (this: Electron.Protocol, scheme: string) {
+  const isRegistered = isBuiltInScheme(scheme) ? this.isProtocolIntercepted : this.isProtocolRegistered;
+  return isRegistered.call(this, scheme);
+};
+
+const protocol = {
+  registerSchemesAsPrivileged,
+  getStandardSchemes,
+  registerStringProtocol: (...args) => session.defaultSession.protocol.registerStringProtocol(...args),
+  registerBufferProtocol: (...args) => session.defaultSession.protocol.registerBufferProtocol(...args),
+  registerStreamProtocol: (...args) => session.defaultSession.protocol.registerStreamProtocol(...args),
+  registerFileProtocol: (...args) => session.defaultSession.protocol.registerFileProtocol(...args),
+  registerHttpProtocol: (...args) => session.defaultSession.protocol.registerHttpProtocol(...args),
+  registerProtocol: (...args) => session.defaultSession.protocol.registerProtocol(...args),
+  unregisterProtocol: (...args) => session.defaultSession.protocol.unregisterProtocol(...args),
+  isProtocolRegistered: (...args) => session.defaultSession.protocol.isProtocolRegistered(...args),
+  interceptStringProtocol: (...args) => session.defaultSession.protocol.interceptStringProtocol(...args),
+  interceptBufferProtocol: (...args) => session.defaultSession.protocol.interceptBufferProtocol(...args),
+  interceptStreamProtocol: (...args) => session.defaultSession.protocol.interceptStreamProtocol(...args),
+  interceptFileProtocol: (...args) => session.defaultSession.protocol.interceptFileProtocol(...args),
+  interceptHttpProtocol: (...args) => session.defaultSession.protocol.interceptHttpProtocol(...args),
+  interceptProtocol: (...args) => session.defaultSession.protocol.interceptProtocol(...args),
+  uninterceptProtocol: (...args) => session.defaultSession.protocol.uninterceptProtocol(...args),
+  isProtocolIntercepted: (...args) => session.defaultSession.protocol.isProtocolIntercepted(...args),
+  handle: (...args) => session.defaultSession.protocol.handle(...args),
+  unhandle: (...args) => session.defaultSession.protocol.unhandle(...args),
+  isProtocolHandled: (...args) => session.defaultSession.protocol.isProtocolHandled(...args)
+} as typeof Electron.protocol;
 
 export default protocol;

+ 1 - 0
patches/chromium/.patches

@@ -124,5 +124,6 @@ chore_introduce_blocking_api_for_electron.patch
 chore_patch_out_partition_attribute_dcheck_for_webviews.patch
 expose_v8initializer_codegenerationcheckcallbackinmainthread.patch
 chore_patch_out_profile_methods_in_profile_selections_cc.patch
+add_gin_converter_support_for_arraybufferview.patch
 chore_defer_usb_service_getdevices_request_until_usb_service_is.patch
 revert_roll_clang_rust_llvmorg-16-init-17653-g39da55e8-3.patch

+ 60 - 0
patches/chromium/add_gin_converter_support_for_arraybufferview.patch

@@ -0,0 +1,60 @@
+From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
+From: Jeremy Rose <[email protected]>
+Date: Wed, 8 Mar 2023 14:53:17 -0800
+Subject: add gin::Converter support for ArrayBufferView
+
+This should be upstreamed.
+
+diff --git a/gin/converter.cc b/gin/converter.cc
+index 4eb8c3d8c8392512eeb235bc18012589549b872b..d0432f6fff09cdcebed55ccf03a6524a445ef346 100644
+--- a/gin/converter.cc
++++ b/gin/converter.cc
+@@ -18,6 +18,7 @@
+ #include "v8/include/v8-value.h"
+ 
+ using v8::ArrayBuffer;
++using v8::ArrayBufferView;
+ using v8::External;
+ using v8::Function;
+ using v8::Int32;
+@@ -244,6 +245,20 @@ bool Converter<Local<ArrayBuffer>>::FromV8(Isolate* isolate,
+   return true;
+ }
+ 
++Local<Value> Converter<Local<ArrayBufferView>>::ToV8(Isolate* isolate,
++                                                 Local<ArrayBufferView> val) {
++  return val.As<Value>();
++}
++
++bool Converter<Local<ArrayBufferView>>::FromV8(Isolate* isolate,
++                                           Local<Value> val,
++                                           Local<ArrayBufferView>* out) {
++  if (!val->IsArrayBufferView())
++    return false;
++  *out = Local<ArrayBufferView>::Cast(val);
++  return true;
++}
++
+ Local<Value> Converter<Local<External>>::ToV8(Isolate* isolate,
+                                               Local<External> val) {
+   return val.As<Value>();
+diff --git a/gin/converter.h b/gin/converter.h
+index eb704fcd56dee861e18e9cd64a857d68dea6f415..d32a8c26403cf32f3333ed85c23292915e6f0681 100644
+--- a/gin/converter.h
++++ b/gin/converter.h
+@@ -180,6 +180,15 @@ struct GIN_EXPORT Converter<v8::Local<v8::ArrayBuffer> > {
+                      v8::Local<v8::ArrayBuffer>* out);
+ };
+ 
++template<>
++struct GIN_EXPORT Converter<v8::Local<v8::ArrayBufferView> > {
++  static v8::Local<v8::Value> ToV8(v8::Isolate* isolate,
++                                    v8::Local<v8::ArrayBufferView> val);
++  static bool FromV8(v8::Isolate* isolate,
++                     v8::Local<v8::Value> val,
++                     v8::Local<v8::ArrayBufferView>* out);
++};
++
+ template<>
+ struct GIN_EXPORT Converter<v8::Local<v8::External> > {
+   static v8::Local<v8::Value> ToV8(v8::Isolate* isolate,

+ 14 - 4
shell/browser/api/electron_api_protocol.cc

@@ -273,9 +273,17 @@ gin::Handle<Protocol> Protocol::Create(
       isolate, new Protocol(isolate, browser_context->protocol_registry()));
 }
 
-gin::ObjectTemplateBuilder Protocol::GetObjectTemplateBuilder(
-    v8::Isolate* isolate) {
-  return gin::Wrappable<Protocol>::GetObjectTemplateBuilder(isolate)
+// static
+gin::Handle<Protocol> Protocol::New(gin_helper::ErrorThrower thrower) {
+  thrower.ThrowError("Protocol cannot be created from JS");
+  return gin::Handle<Protocol>();
+}
+
+// static
+v8::Local<v8::ObjectTemplate> Protocol::FillObjectTemplate(
+    v8::Isolate* isolate,
+    v8::Local<v8::ObjectTemplate> tmpl) {
+  return gin::ObjectTemplateBuilder(isolate, "Protocol", tmpl)
       .SetMethod("registerStringProtocol",
                  &Protocol::RegisterProtocolFor<ProtocolType::kString>)
       .SetMethod("registerBufferProtocol",
@@ -304,7 +312,8 @@ gin::ObjectTemplateBuilder Protocol::GetObjectTemplateBuilder(
       .SetMethod("interceptProtocol",
                  &Protocol::InterceptProtocolFor<ProtocolType::kFree>)
       .SetMethod("uninterceptProtocol", &Protocol::UninterceptProtocol)
-      .SetMethod("isProtocolIntercepted", &Protocol::IsProtocolIntercepted);
+      .SetMethod("isProtocolIntercepted", &Protocol::IsProtocolIntercepted)
+      .Build();
 }
 
 const char* Protocol::GetTypeName() {
@@ -333,6 +342,7 @@ void Initialize(v8::Local<v8::Object> exports,
                 void* priv) {
   v8::Isolate* isolate = context->GetIsolate();
   gin_helper::Dictionary dict(isolate, exports);
+  dict.Set("Protocol", electron::api::Protocol::GetConstructor(context));
   dict.SetMethod("registerSchemesAsPrivileged", &RegisterSchemesAsPrivileged);
   dict.SetMethod("getStandardSchemes", &electron::api::GetStandardSchemes);
 }

+ 8 - 3
shell/browser/api/electron_api_protocol.h

@@ -12,6 +12,7 @@
 #include "gin/handle.h"
 #include "gin/wrappable.h"
 #include "shell/browser/net/electron_url_loader_factory.h"
+#include "shell/common/gin_helper/constructible.h"
 
 namespace electron {
 
@@ -37,15 +38,19 @@ enum class ProtocolError {
 };
 
 // Protocol implementation based on network services.
-class Protocol : public gin::Wrappable<Protocol> {
+class Protocol : public gin::Wrappable<Protocol>,
+                 public gin_helper::Constructible<Protocol> {
  public:
   static gin::Handle<Protocol> Create(v8::Isolate* isolate,
                                       ElectronBrowserContext* browser_context);
 
+  static gin::Handle<Protocol> New(gin_helper::ErrorThrower thrower);
+
   // gin::Wrappable
   static gin::WrapperInfo kWrapperInfo;
-  gin::ObjectTemplateBuilder GetObjectTemplateBuilder(
-      v8::Isolate* isolate) override;
+  static v8::Local<v8::ObjectTemplate> FillObjectTemplate(
+      v8::Isolate* isolate,
+      v8::Local<v8::ObjectTemplate> tmpl);
   const char* GetTypeName() override;
 
  private:

+ 18 - 3
shell/browser/api/electron_api_url_loader.cc

@@ -30,6 +30,7 @@
 #include "shell/browser/electron_browser_context.h"
 #include "shell/browser/javascript_environment.h"
 #include "shell/browser/net/asar/asar_url_loader_factory.h"
+#include "shell/browser/net/proxying_url_loader_factory.h"
 #include "shell/browser/protocol_registry.h"
 #include "shell/common/gin_converters/callback_converter.h"
 #include "shell/common/gin_converters/gurl_converter.h"
@@ -488,7 +489,10 @@ SimpleURLLoaderWrapper::GetURLLoaderFactoryForURL(const GURL& url) {
   // Explicitly handle intercepted protocols here, even though
   // ProxyingURLLoaderFactory would handle them later on, so that we can
   // correctly intercept file:// scheme URLs.
-  if (protocol_registry->IsProtocolIntercepted(url.scheme())) {
+  bool bypass_custom_protocol_handlers =
+      request_options_ & kBypassCustomProtocolHandlers;
+  if (!bypass_custom_protocol_handlers &&
+      protocol_registry->IsProtocolIntercepted(url.scheme())) {
     auto& protocol_handler =
         protocol_registry->intercept_handlers().at(url.scheme());
     mojo::PendingRemote<network::mojom::URLLoaderFactory> pending_remote =
@@ -497,7 +501,8 @@ SimpleURLLoaderWrapper::GetURLLoaderFactoryForURL(const GURL& url) {
     url_loader_factory = network::SharedURLLoaderFactory::Create(
         std::make_unique<network::WrapperPendingSharedURLLoaderFactory>(
             std::move(pending_remote)));
-  } else if (protocol_registry->IsProtocolRegistered(url.scheme())) {
+  } else if (!bypass_custom_protocol_handlers &&
+             protocol_registry->IsProtocolRegistered(url.scheme())) {
     auto& protocol_handler = protocol_registry->handlers().at(url.scheme());
     mojo::PendingRemote<network::mojom::URLLoaderFactory> pending_remote =
         ElectronURLLoaderFactory::Create(protocol_handler.first,
@@ -528,6 +533,10 @@ gin::Handle<SimpleURLLoaderWrapper> SimpleURLLoaderWrapper::Create(
   auto request = std::make_unique<network::ResourceRequest>();
   opts.Get("method", &request->method);
   opts.Get("url", &request->url);
+  if (!request->url.is_valid()) {
+    args->ThrowTypeError("Invalid URL");
+    return gin::Handle<SimpleURLLoaderWrapper>();
+  }
   request->site_for_cookies = net::SiteForCookies::FromUrl(request->url);
   opts.Get("referrer", &request->referrer);
   request->referrer_policy =
@@ -648,7 +657,7 @@ gin::Handle<SimpleURLLoaderWrapper> SimpleURLLoaderWrapper::Create(
 
   bool use_session_cookies = false;
   opts.Get("useSessionCookies", &use_session_cookies);
-  int options = 0;
+  int options = network::mojom::kURLLoadOptionSniffMimeType;
   if (!credentials_specified && !use_session_cookies) {
     // This is the default case, as well as the case when credentials is not
     // specified and useSessionCookies is false. credentials_mode will be
@@ -657,6 +666,11 @@ gin::Handle<SimpleURLLoaderWrapper> SimpleURLLoaderWrapper::Create(
     options |= network::mojom::kURLLoadOptionBlockAllCookies;
   }
 
+  bool bypass_custom_protocol_handlers = false;
+  opts.Get("bypassCustomProtocolHandlers", &bypass_custom_protocol_handlers);
+  if (bypass_custom_protocol_handlers)
+    options |= kBypassCustomProtocolHandlers;
+
   v8::Local<v8::Value> body;
   v8::Local<v8::Value> chunk_pipe_getter;
   if (opts.Get("body", &body)) {
@@ -738,6 +752,7 @@ void SimpleURLLoaderWrapper::OnResponseStarted(
   dict.Set("httpVersion", response_head.headers->GetHttpVersion());
   dict.Set("headers", response_head.headers.get());
   dict.Set("rawHeaders", response_head.raw_response_headers);
+  dict.Set("mimeType", response_head.mime_type);
   Emit("response-started", final_url, dict);
 }
 

+ 6 - 1
shell/browser/net/node_stream_loader.cc

@@ -85,7 +85,10 @@ void NodeStreamLoader::NotifyComplete(int result) {
     return;
   }
 
-  client_->OnComplete(network::URLLoaderCompletionStatus(result));
+  network::URLLoaderCompletionStatus status(result);
+  status.completion_time = base::TimeTicks::Now();
+  status.decoded_body_length = bytes_written_;
+  client_->OnComplete(status);
   delete this;
 }
 
@@ -126,6 +129,8 @@ void NodeStreamLoader::ReadMore() {
   // Hold the buffer until the write is done.
   buffer_.Reset(isolate_, buffer);
 
+  bytes_written_ += node::Buffer::Length(buffer);
+
   // Write buffer to mojo pipe asynchronously.
   is_reading_ = false;
   is_writing_ = true;

+ 2 - 0
shell/browser/net/node_stream_loader.h

@@ -81,6 +81,8 @@ class NodeStreamLoader : public network::mojom::URLLoader {
   // Whether we are in the middle of a stream.read().
   bool is_reading_ = false;
 
+  size_t bytes_written_ = 0;
+
   // When NotifyComplete is called while writing, we will save the result and
   // quit with it after the write is done.
   bool ended_ = false;

+ 17 - 12
shell/browser/net/proxying_url_loader_factory.cc

@@ -806,18 +806,23 @@ void ProxyingURLLoaderFactory::CreateLoaderAndStart(
   }
 
   // Check if user has intercepted this scheme.
-  auto it = intercepted_handlers_.find(request.url.scheme());
-  if (it != intercepted_handlers_.end()) {
-    mojo::PendingRemote<network::mojom::URLLoaderFactory> loader_remote;
-    this->Clone(loader_remote.InitWithNewPipeAndPassReceiver());
-
-    // <scheme, <type, handler>>
-    it->second.second.Run(
-        request, base::BindOnce(&ElectronURLLoaderFactory::StartLoading,
-                                std::move(loader), request_id, options, request,
-                                std::move(client), traffic_annotation,
-                                std::move(loader_remote), it->second.first));
-    return;
+  bool bypass_custom_protocol_handlers =
+      options & kBypassCustomProtocolHandlers;
+  if (!bypass_custom_protocol_handlers) {
+    auto it = intercepted_handlers_.find(request.url.scheme());
+    if (it != intercepted_handlers_.end()) {
+      mojo::PendingRemote<network::mojom::URLLoaderFactory> loader_remote;
+      this->Clone(loader_remote.InitWithNewPipeAndPassReceiver());
+
+      // <scheme, <type, handler>>
+      it->second.second.Run(
+          request,
+          base::BindOnce(&ElectronURLLoaderFactory::StartLoading,
+                         std::move(loader), request_id, options, request,
+                         std::move(client), traffic_annotation,
+                         std::move(loader_remote), it->second.first));
+      return;
+    }
   }
 
   // The loader of ServiceWorker forbids loading scripts from file:// URLs, and

+ 2 - 0
shell/browser/net/proxying_url_loader_factory.h

@@ -36,6 +36,8 @@
 
 namespace electron {
 
+const uint32_t kBypassCustomProtocolHandlers = 1 << 30;
+
 // This class is responsible for following tasks when NetworkService is enabled:
 // 1. handling intercepted protocols;
 // 2. implementing webRequest module;

+ 260 - 0
shell/common/gin_converters/net_converter.cc

@@ -15,16 +15,21 @@
 #include "base/values.h"
 #include "gin/converter.h"
 #include "gin/dictionary.h"
+#include "gin/object_template_builder.h"
 #include "net/cert/x509_certificate.h"
 #include "net/cert/x509_util.h"
 #include "net/http/http_response_headers.h"
 #include "net/http/http_version.h"
 #include "net/url_request/redirect_info.h"
+#include "services/network/public/cpp/data_element.h"
 #include "services/network/public/cpp/resource_request.h"
+#include "services/network/public/cpp/resource_request_body.h"
+#include "services/network/public/mojom/chunked_data_pipe_getter.mojom.h"
 #include "shell/browser/api/electron_api_data_pipe_holder.h"
 #include "shell/common/gin_converters/gurl_converter.h"
 #include "shell/common/gin_converters/std_converter.h"
 #include "shell/common/gin_converters/value_converter.h"
+#include "shell/common/gin_helper/promise.h"
 #include "shell/common/node_includes.h"
 
 namespace gin {
@@ -246,6 +251,246 @@ bool Converter<net::HttpRequestHeaders>::FromV8(v8::Isolate* isolate,
   return true;
 }
 
+class ChunkedDataPipeReadableStream
+    : public gin::Wrappable<ChunkedDataPipeReadableStream> {
+ public:
+  static gin::Handle<ChunkedDataPipeReadableStream> Create(
+      v8::Isolate* isolate,
+      network::ResourceRequestBody* request,
+      network::DataElementChunkedDataPipe* data_element) {
+    return gin::CreateHandle(isolate, new ChunkedDataPipeReadableStream(
+                                          isolate, request, data_element));
+  }
+
+  // gin::Wrappable
+  gin::ObjectTemplateBuilder GetObjectTemplateBuilder(
+      v8::Isolate* isolate) override {
+    return gin::Wrappable<
+               ChunkedDataPipeReadableStream>::GetObjectTemplateBuilder(isolate)
+        .SetMethod("read", &ChunkedDataPipeReadableStream::Read);
+  }
+
+  static gin::WrapperInfo kWrapperInfo;
+
+ private:
+  ChunkedDataPipeReadableStream(
+      v8::Isolate* isolate,
+      network::ResourceRequestBody* request,
+      network::DataElementChunkedDataPipe* data_element)
+      : isolate_(isolate),
+        resource_request_body_(request),
+        data_element_(data_element),
+        handle_watcher_(FROM_HERE,
+                        mojo::SimpleWatcher::ArmingPolicy::MANUAL,
+                        base::SequencedTaskRunner::GetCurrentDefault()) {}
+
+  ~ChunkedDataPipeReadableStream() override = default;
+
+  int Init() {
+    chunked_data_pipe_getter_.Bind(
+        data_element_->ReleaseChunkedDataPipeGetter());
+    for (auto& element : *resource_request_body_->elements_mutable()) {
+      if (element.type() ==
+              network::mojom::DataElement::Tag::kChunkedDataPipe &&
+          data_element_ == &element.As<network::DataElementChunkedDataPipe>()) {
+        element = network::DataElement(
+            network::DataElementBytes(std::vector<uint8_t>()));
+        break;
+      }
+    }
+    chunked_data_pipe_getter_.set_disconnect_handler(
+        base::BindOnce(&ChunkedDataPipeReadableStream::OnDataPipeGetterClosed,
+                       base::Unretained(this)));
+    chunked_data_pipe_getter_->GetSize(
+        base::BindOnce(&ChunkedDataPipeReadableStream::OnSizeReceived,
+                       base::Unretained(this)));
+    mojo::ScopedDataPipeProducerHandle data_pipe_producer;
+    mojo::ScopedDataPipeConsumerHandle data_pipe_consumer;
+    MojoResult result =
+        mojo::CreateDataPipe(nullptr, data_pipe_producer, data_pipe_consumer);
+    if (result != MOJO_RESULT_OK)
+      return net::ERR_INSUFFICIENT_RESOURCES;
+    chunked_data_pipe_getter_->StartReading(std::move(data_pipe_producer));
+    data_pipe_ = std::move(data_pipe_consumer);
+    return net::OK;
+  }
+
+  v8::Local<v8::Promise> Read(v8::Local<v8::ArrayBufferView> buf) {
+    gin_helper::Promise<int> promise(isolate_);
+    v8::Local<v8::Promise> handle = promise.GetHandle();
+
+    int status = ReadInternal(buf);
+
+    if (status == net::ERR_IO_PENDING) {
+      promise_ = std::move(promise);
+    } else {
+      if (status < 0)
+        std::move(promise).RejectWithErrorMessage(net::ErrorToString(status));
+      else
+        std::move(promise).Resolve(status);
+    }
+
+    return handle;
+  }
+
+  int ReadInternal(v8::Local<v8::ArrayBufferView> buf) {
+    if (!data_pipe_)
+      status_ = Init();
+    // If there was an error either passed to the ReadCallback or as a result of
+    // closing the DataPipeGetter pipe, fail the read.
+    if (status_ != net::OK)
+      return status_;
+
+    // Nothing else to do, if the entire body was read.
+    if (size_ && bytes_read_ == *size_) {
+      // This shouldn't be called if the stream was already completed.
+      DCHECK(!is_eof_);
+
+      is_eof_ = true;
+      return net::OK;
+    }
+
+    if (!handle_watcher_.IsWatching()) {
+      handle_watcher_.Watch(
+          data_pipe_.get(),
+          MOJO_HANDLE_SIGNAL_READABLE | MOJO_HANDLE_SIGNAL_PEER_CLOSED,
+          base::BindRepeating(&ChunkedDataPipeReadableStream::OnHandleReadable,
+                              base::Unretained(this)));
+    }
+
+    uint32_t num_bytes = buf->ByteLength();
+    if (size_ && num_bytes > *size_ - bytes_read_)
+      num_bytes = *size_ - bytes_read_;
+    MojoResult rv = data_pipe_->ReadData(
+        static_cast<void*>(static_cast<char*>(buf->Buffer()->Data()) +
+                           buf->ByteOffset()),
+        &num_bytes, MOJO_READ_DATA_FLAG_NONE);
+    if (rv == MOJO_RESULT_OK) {
+      bytes_read_ += num_bytes;
+      // Not needed for correctness, but this allows the consumer to send the
+      // final chunk and the end of stream message together, for protocols that
+      // allow it.
+      if (size_ && *size_ == bytes_read_)
+        is_eof_ = true;
+      return num_bytes;
+    }
+
+    if (rv == MOJO_RESULT_SHOULD_WAIT) {
+      handle_watcher_.ArmOrNotify();
+      buf_.Reset(isolate_, buf);
+      return net::ERR_IO_PENDING;
+    }
+
+    // The pipe was closed. If the size isn't known yet, could be a success or a
+    // failure.
+    if (!size_) {
+      // Need to keep the buffer around because its presence is used to indicate
+      // that there's a pending UploadDataStream read.
+      buf_.Reset(isolate_, buf);
+
+      handle_watcher_.Cancel();
+      data_pipe_.reset();
+      return net::ERR_IO_PENDING;
+    }
+
+    // |size_| was checked earlier, so if this point is reached, the pipe was
+    // closed before receiving all bytes.
+    DCHECK_LT(bytes_read_, *size_);
+
+    return net::ERR_FAILED;
+  }
+
+  void OnSizeReceived(int32_t status, uint64_t size) {
+    DCHECK(!size_);
+    DCHECK_EQ(net::OK, status_);
+
+    status_ = status;
+    if (status == net::OK) {
+      size_ = size;
+      if (size == bytes_read_) {
+        // Only set this as a final chunk if there's a read in progress. Setting
+        // it asynchronously could result in confusing consumers.
+        if (!buf_.IsEmpty())
+          is_eof_ = true;
+      } else if (size < bytes_read_ ||
+                 (!buf_.IsEmpty() && !data_pipe_.is_valid())) {
+        // If more data was received than was expected, or there's a pending
+        // read and data pipe was closed without passing in as many bytes as
+        // expected, the upload can't continue.  If there's no pending read but
+        // the pipe was closed, the closure and size difference will be noticed
+        // on the next read attempt.
+        status_ = net::ERR_FAILED;
+      }
+    }
+
+    // If this is done, and there's a pending read, complete the pending read.
+    // If there's not a pending read, either |status_| will be reported on the
+    // next read, the file will be marked as done, so ReadInternal() won't be
+    // called again.
+    if (!buf_.IsEmpty() && (is_eof_ || status_ != net::OK)) {
+      // |data_pipe_| isn't needed any more, and if it's still open, a close
+      // pipe message would cause issues, since this class normally only watches
+      // the pipe when there's a pending read.
+      handle_watcher_.Cancel();
+      data_pipe_.reset();
+      // Clear |buf_| as well, so it's only non-null while there's a pending
+      // read.
+      buf_.Reset();
+      chunked_data_pipe_getter_.reset();
+
+      OnReadCompleted(status_);
+
+      // |this| may have been deleted at this point.
+    }
+  }
+
+  void OnHandleReadable(MojoResult result) {
+    DCHECK(!buf_.IsEmpty());
+
+    v8::HandleScope handle_scope(isolate_);
+
+    v8::Local<v8::ArrayBufferView> buf = buf_.Get(isolate_);
+    buf_.Reset();
+
+    int rv = ReadInternal(buf);
+
+    if (rv != net::ERR_IO_PENDING)
+      OnReadCompleted(rv);
+
+    // |this| may have been deleted at this point.
+  }
+
+  void OnReadCompleted(int result) {
+    if (result < 0)
+      std::move(promise_).RejectWithErrorMessage(net::ErrorToString(result));
+    else
+      std::move(promise_).Resolve(result);
+  }
+
+  void OnDataPipeGetterClosed() {
+    // If the size hasn't been received yet, treat this as receiving an error.
+    // Otherwise, this will only be a problem if/when InitInternal() tries to
+    // start reading again, so do nothing.
+    if (status_ == net::OK && !size_)
+      OnSizeReceived(net::ERR_FAILED, 0);
+  }
+
+  v8::Isolate* isolate_;
+  int status_ = net::OK;
+  scoped_refptr<network::ResourceRequestBody> resource_request_body_;
+  network::DataElementChunkedDataPipe* data_element_;
+  mojo::Remote<network::mojom::ChunkedDataPipeGetter> chunked_data_pipe_getter_;
+  mojo::ScopedDataPipeConsumerHandle data_pipe_;
+  mojo::SimpleWatcher handle_watcher_;
+  absl::optional<uint64_t> size_;
+  uint64_t bytes_read_ = 0;
+  bool is_eof_ = false;
+  v8::Global<v8::ArrayBufferView> buf_;
+  gin_helper::Promise<int> promise_;
+};
+gin::WrapperInfo ChunkedDataPipeReadableStream::kWrapperInfo = {
+    gin::kEmbedderNativeGin};
+
 // static
 v8::Local<v8::Value> Converter<network::ResourceRequestBody>::ToV8(
     v8::Isolate* isolate,
@@ -288,6 +533,21 @@ v8::Local<v8::Value> Converter<network::ResourceRequestBody>::ToV8(
         upload_data.Set("dataPipe", holder);
         break;
       }
+      case network::mojom::DataElement::Tag::kChunkedDataPipe: {
+        upload_data.Set("type", "stream");
+        // ReleaseChunkedDataPipeGetter mutates the element, but unfortunately
+        // gin converters are only allowed const references, so we need to cast
+        // off the const here.
+        auto& mutable_element =
+            const_cast<network::DataElementChunkedDataPipe&>(
+                element.As<network::DataElementChunkedDataPipe>());
+        upload_data.Set(
+            "body",
+            ChunkedDataPipeReadableStream::Create(
+                isolate, const_cast<network::ResourceRequestBody*>(&val),
+                &mutable_element));
+        break;
+      }
       default:
         NOTREACHED() << "Found unsupported data element";
     }

+ 2 - 0
shell/common/gin_helper/promise.cc

@@ -17,6 +17,8 @@ PromiseBase::PromiseBase(v8::Isolate* isolate,
       context_(isolate, isolate->GetCurrentContext()),
       resolver_(isolate, handle) {}
 
+PromiseBase::PromiseBase() : isolate_(nullptr) {}
+
 PromiseBase::PromiseBase(PromiseBase&&) = default;
 
 PromiseBase::~PromiseBase() = default;

+ 1 - 0
shell/common/gin_helper/promise.h

@@ -30,6 +30,7 @@ class PromiseBase {
  public:
   explicit PromiseBase(v8::Isolate* isolate);
   PromiseBase(v8::Isolate* isolate, v8::Local<v8::Promise::Resolver> handle);
+  PromiseBase();
   ~PromiseBase();
 
   // disable copy

+ 12 - 0
spec/api-net-spec.ts

@@ -1438,6 +1438,18 @@ describe('net module', () => {
         expect(requestIsRedirected).to.be.true('The server should receive a request to the forward URL');
         expect(requestIsIntercepted).to.be.true('The request should be intercepted by the webRequest module');
       });
+
+      it('triggers webRequest handlers when bypassCustomProtocolHandlers', async () => {
+        let webRequestDetails: Electron.OnBeforeRequestListenerDetails | null = null;
+        const serverUrl = await respondOnce.toSingleURL((req, res) => res.end('hi'));
+        session.defaultSession.webRequest.onBeforeRequest((details, cb) => {
+          webRequestDetails = details;
+          cb({});
+        });
+        const body = await net.fetch(serverUrl, { bypassCustomProtocolHandlers: true }).then(r => r.text());
+        expect(body).to.equal('hi');
+        expect(webRequestDetails).to.have.property('url', serverUrl);
+      });
     });
 
     it('should throw when calling getHeader without a name', () => {

+ 429 - 5
spec/api-protocol-spec.ts

@@ -1,8 +1,9 @@
 import { expect } from 'chai';
 import { v4 } from 'uuid';
-import { protocol, webContents, WebContents, session, BrowserWindow, ipcMain } from 'electron/main';
+import { protocol, webContents, WebContents, session, BrowserWindow, ipcMain, net } from 'electron/main';
 import * as ChildProcess from 'child_process';
 import * as path from 'path';
+import * as url from 'url';
 import * as http from 'http';
 import * as fs from 'fs';
 import * as qs from 'querystring';
@@ -10,7 +11,7 @@ import * as stream from 'stream';
 import { EventEmitter, once } from 'events';
 import { closeAllWindows, closeWindow } from './lib/window-helpers';
 import { WebmGenerator } from './lib/video-helpers';
-import { listen } from './lib/spec-helpers';
+import { listen, defer, ifit } from './lib/spec-helpers';
 import { setTimeout } from 'timers/promises';
 
 const fixturesPath = path.resolve(__dirname, 'fixtures');
@@ -34,7 +35,9 @@ const postData = {
 };
 
 function getStream (chunkSize = text.length, data: Buffer | string = text) {
-  const body = new stream.PassThrough();
+  // allowHalfOpen required, otherwise Readable.toWeb gets confused and thinks
+  // the stream isn't done when the readable half ends.
+  const body = new stream.PassThrough({ allowHalfOpen: false });
 
   async function sendChunks () {
     await setTimeout(0); // the stream protocol API breaks if you send data immediately.
@@ -54,9 +57,12 @@ function getStream (chunkSize = text.length, data: Buffer | string = text) {
   sendChunks();
   return body;
 }
+function getWebStream (chunkSize = text.length, data: Buffer | string = text): ReadableStream<ArrayBufferView> {
+  return stream.Readable.toWeb(getStream(chunkSize, data)) as ReadableStream<ArrayBufferView>;
+}
 
 // A promise that can be resolved externally.
-function defer (): Promise<any> & {resolve: Function, reject: Function} {
+function deferPromise (): Promise<any> & {resolve: Function, reject: Function} {
   let promiseResolve: Function = null as unknown as Function;
   let promiseReject: Function = null as unknown as Function;
   const promise: any = new Promise((resolve, reject) => {
@@ -860,7 +866,7 @@ describe('protocol module', () => {
     });
 
     it('can have fetch working in it', async () => {
-      const requestReceived = defer();
+      const requestReceived = deferPromise();
       const server = http.createServer((req, res) => {
         res.end();
         server.close();
@@ -1093,4 +1099,422 @@ describe('protocol module', () => {
       }
     }
   });
+
+  describe('handle', () => {
+    afterEach(closeAllWindows);
+
+    it('receives requests to a custom scheme', async () => {
+      protocol.handle('test-scheme', (req) => new Response('hello ' + req.url));
+      defer(() => { protocol.unhandle('test-scheme'); });
+      const resp = await net.fetch('test-scheme://foo');
+      expect(resp.status).to.equal(200);
+    });
+
+    it('can be unhandled', async () => {
+      protocol.handle('test-scheme', (req) => new Response('hello ' + req.url));
+      defer(() => {
+        try {
+          // In case of failure, make sure we unhandle. But we should succeed
+          // :)
+          protocol.unhandle('test-scheme');
+        } catch (_ignored) { /* ignore */ }
+      });
+      const resp1 = await net.fetch('test-scheme://foo');
+      expect(resp1.status).to.equal(200);
+      protocol.unhandle('test-scheme');
+      await expect(net.fetch('test-scheme://foo')).to.eventually.be.rejectedWith(/ERR_UNKNOWN_URL_SCHEME/);
+    });
+
+    it('receives requests to an existing scheme', async () => {
+      protocol.handle('https', (req) => new Response('hello ' + req.url));
+      defer(() => { protocol.unhandle('https'); });
+      const body = await net.fetch('https://foo').then(r => r.text());
+      expect(body).to.equal('hello https://foo/');
+    });
+
+    it('receives requests to an existing scheme when navigating', async () => {
+      protocol.handle('https', (req) => new Response('hello ' + req.url));
+      defer(() => { protocol.unhandle('https'); });
+      const w = new BrowserWindow({ show: false });
+      await w.loadURL('https://localhost');
+      expect(await w.webContents.executeJavaScript('document.body.textContent')).to.equal('hello https://localhost/');
+    });
+
+    it('can send buffer body', async () => {
+      protocol.handle('test-scheme', (req) => new Response(Buffer.from('hello ' + req.url)));
+      defer(() => { protocol.unhandle('test-scheme'); });
+      const body = await net.fetch('test-scheme://foo').then(r => r.text());
+      expect(body).to.equal('hello test-scheme://foo');
+    });
+
+    it('can send stream body', async () => {
+      protocol.handle('test-scheme', () => new Response(getWebStream()));
+      defer(() => { protocol.unhandle('test-scheme'); });
+      const body = await net.fetch('test-scheme://foo').then(r => r.text());
+      expect(body).to.equal(text);
+    });
+
+    it('accepts urls with no hostname in non-standard schemes', async () => {
+      protocol.handle('test-scheme', (req) => new Response(req.url));
+      defer(() => { protocol.unhandle('test-scheme'); });
+      {
+        const body = await net.fetch('test-scheme://foo').then(r => r.text());
+        expect(body).to.equal('test-scheme://foo');
+      }
+      {
+        const body = await net.fetch('test-scheme:///foo').then(r => r.text());
+        expect(body).to.equal('test-scheme:///foo');
+      }
+      {
+        const body = await net.fetch('test-scheme://').then(r => r.text());
+        expect(body).to.equal('test-scheme://');
+      }
+    });
+
+    it('accepts urls with a port-like component in non-standard schemes', async () => {
+      protocol.handle('test-scheme', (req) => new Response(req.url));
+      defer(() => { protocol.unhandle('test-scheme'); });
+      {
+        const body = await net.fetch('test-scheme://foo:30').then(r => r.text());
+        expect(body).to.equal('test-scheme://foo:30');
+      }
+    });
+
+    it('normalizes urls in standard schemes', async () => {
+      // NB. 'app' is registered as a standard scheme in test setup.
+      protocol.handle('app', (req) => new Response(req.url));
+      defer(() => { protocol.unhandle('app'); });
+      {
+        const body = await net.fetch('app://foo').then(r => r.text());
+        expect(body).to.equal('app://foo/');
+      }
+      {
+        const body = await net.fetch('app:///foo').then(r => r.text());
+        expect(body).to.equal('app://foo/');
+      }
+      // NB. 'app' is registered with the default scheme type of 'host'.
+      {
+        const body = await net.fetch('app://foo:1234').then(r => r.text());
+        expect(body).to.equal('app://foo/');
+      }
+      await expect(net.fetch('app://')).to.be.rejectedWith('Invalid URL');
+    });
+
+    it('fails on URLs with a username', async () => {
+      // NB. 'app' is registered as a standard scheme in test setup.
+      protocol.handle('http', (req) => new Response(req.url));
+      defer(() => { protocol.unhandle('http'); });
+      await expect(contents.loadURL('http://x@foo:1234')).to.be.rejectedWith(/ERR_UNEXPECTED/);
+    });
+
+    it('normalizes http urls', async () => {
+      protocol.handle('http', (req) => new Response(req.url));
+      defer(() => { protocol.unhandle('http'); });
+      {
+        const body = await net.fetch('http://foo').then(r => r.text());
+        expect(body).to.equal('http://foo/');
+      }
+    });
+
+    it('can send errors', async () => {
+      protocol.handle('test-scheme', () => Response.error());
+      defer(() => { protocol.unhandle('test-scheme'); });
+      await expect(net.fetch('test-scheme://foo')).to.eventually.be.rejectedWith('net::ERR_FAILED');
+    });
+
+    it('handles a synchronous error in the handler', async () => {
+      protocol.handle('test-scheme', () => { throw new Error('test'); });
+      defer(() => { protocol.unhandle('test-scheme'); });
+      await expect(net.fetch('test-scheme://foo')).to.be.rejectedWith('net::ERR_UNEXPECTED');
+    });
+
+    it('handles an asynchronous error in the handler', async () => {
+      protocol.handle('test-scheme', () => Promise.reject(new Error('rejected promise')));
+      defer(() => { protocol.unhandle('test-scheme'); });
+      await expect(net.fetch('test-scheme://foo')).to.be.rejectedWith('net::ERR_UNEXPECTED');
+    });
+
+    it('correctly sets statusCode', async () => {
+      protocol.handle('test-scheme', () => new Response(null, { status: 201 }));
+      defer(() => { protocol.unhandle('test-scheme'); });
+      const resp = await net.fetch('test-scheme://foo');
+      expect(resp.status).to.equal(201);
+    });
+
+    it('correctly sets content-type and charset', async () => {
+      protocol.handle('test-scheme', () => new Response(null, { headers: { 'content-type': 'text/html; charset=testcharset' } }));
+      defer(() => { protocol.unhandle('test-scheme'); });
+      const resp = await net.fetch('test-scheme://foo');
+      expect(resp.headers.get('content-type')).to.equal('text/html; charset=testcharset');
+    });
+
+    it('can forward to http', async () => {
+      const server = http.createServer((req, res) => {
+        res.end(text);
+      });
+      defer(() => { server.close(); });
+      const { url } = await listen(server);
+
+      protocol.handle('test-scheme', () => net.fetch(url));
+      defer(() => { protocol.unhandle('test-scheme'); });
+      const body = await net.fetch('test-scheme://foo').then(r => r.text());
+      expect(body).to.equal(text);
+    });
+
+    it('can forward an http request with headers', async () => {
+      const server = http.createServer((req, res) => {
+        res.setHeader('foo', 'bar');
+        res.end(text);
+      });
+      defer(() => { server.close(); });
+      const { url } = await listen(server);
+
+      protocol.handle('test-scheme', (req) => net.fetch(url, { headers: req.headers }));
+      defer(() => { protocol.unhandle('test-scheme'); });
+
+      const resp = await net.fetch('test-scheme://foo');
+      expect(resp.headers.get('foo')).to.equal('bar');
+    });
+
+    it('can forward to file', async () => {
+      protocol.handle('test-scheme', () => net.fetch(url.pathToFileURL(path.join(__dirname, 'fixtures', 'hello.txt')).toString()));
+      defer(() => { protocol.unhandle('test-scheme'); });
+
+      const body = await net.fetch('test-scheme://foo').then(r => r.text());
+      expect(body.trimEnd()).to.equal('hello world');
+    });
+
+    it('can receive simple request body', async () => {
+      protocol.handle('test-scheme', (req) => new Response(req.body));
+      defer(() => { protocol.unhandle('test-scheme'); });
+      const body = await net.fetch('test-scheme://foo', {
+        method: 'POST',
+        body: 'foobar'
+      }).then(r => r.text());
+      expect(body).to.equal('foobar');
+    });
+
+    it('can receive stream request body', async () => {
+      protocol.handle('test-scheme', (req) => new Response(req.body));
+      defer(() => { protocol.unhandle('test-scheme'); });
+      const body = await net.fetch('test-scheme://foo', {
+        method: 'POST',
+        body: getWebStream(),
+        duplex: 'half' // https://github.com/microsoft/TypeScript/issues/53157
+      } as any).then(r => r.text());
+      expect(body).to.equal(text);
+    });
+
+    it('can receive multi-part postData from loadURL', async () => {
+      protocol.handle('test-scheme', (req) => new Response(req.body));
+      defer(() => { protocol.unhandle('test-scheme'); });
+      await contents.loadURL('test-scheme://foo', { postData: [{ type: 'rawData', bytes: Buffer.from('a') }, { type: 'rawData', bytes: Buffer.from('b') }] });
+      expect(await contents.executeJavaScript('document.documentElement.textContent')).to.equal('ab');
+    });
+
+    it('can receive file postData from loadURL', async () => {
+      protocol.handle('test-scheme', (req) => new Response(req.body));
+      defer(() => { protocol.unhandle('test-scheme'); });
+      await contents.loadURL('test-scheme://foo', { postData: [{ type: 'file', filePath: path.join(fixturesPath, 'hello.txt'), length: 'hello world\n'.length, offset: 0, modificationTime: 0 }] });
+      expect(await contents.executeJavaScript('document.documentElement.textContent')).to.equal('hello world\n');
+    });
+
+    it('can receive file postData from a form', async () => {
+      protocol.handle('test-scheme', (req) => new Response(req.body));
+      defer(() => { protocol.unhandle('test-scheme'); });
+      await contents.loadURL('data:text/html,<form action="test-scheme://foo" method=POST enctype="multipart/form-data"><input name=foo type=file>');
+      const { debugger: dbg } = contents;
+      dbg.attach();
+      const { root } = await dbg.sendCommand('DOM.getDocument');
+      const { nodeId: fileInputNodeId } = await dbg.sendCommand('DOM.querySelector', { nodeId: root.nodeId, selector: 'input' });
+      await dbg.sendCommand('DOM.setFileInputFiles', {
+        nodeId: fileInputNodeId,
+        files: [
+          path.join(fixturesPath, 'hello.txt')
+        ]
+      });
+      const navigated = once(contents, 'did-finish-load');
+      await contents.executeJavaScript('document.querySelector("form").submit()');
+      await navigated;
+      expect(await contents.executeJavaScript('document.documentElement.textContent')).to.match(/------WebKitFormBoundary.*\nContent-Disposition: form-data; name="foo"; filename="hello.txt"\nContent-Type: text\/plain\n\nhello world\n\n------WebKitFormBoundary.*--\n/);
+    });
+
+    it('can receive streaming fetch upload', async () => {
+      protocol.handle('no-cors', (req) => new Response(req.body));
+      defer(() => { protocol.unhandle('no-cors'); });
+      await contents.loadURL('no-cors://foo');
+      const fetchBodyResult = await contents.executeJavaScript(`
+        const stream = new ReadableStream({
+          async start(controller) {
+            controller.enqueue('hello world');
+            controller.close();
+          },
+        }).pipeThrough(new TextEncoderStream());
+        fetch(location.href, {method: 'POST', body: stream, duplex: 'half'}).then(x => x.text())
+      `);
+      expect(fetchBodyResult).to.equal('hello world');
+    });
+
+    it('can receive streaming fetch upload when a webRequest handler is present', async () => {
+      session.defaultSession.webRequest.onBeforeRequest((details, cb) => {
+        console.log('webRequest', details.url, details.method);
+        cb({});
+      });
+      defer(() => {
+        session.defaultSession.webRequest.onBeforeRequest(null);
+      });
+
+      protocol.handle('no-cors', (req) => {
+        console.log('handle', req.url, req.method);
+        return new Response(req.body);
+      });
+      defer(() => { protocol.unhandle('no-cors'); });
+      await contents.loadURL('no-cors://foo');
+      const fetchBodyResult = await contents.executeJavaScript(`
+        const stream = new ReadableStream({
+          async start(controller) {
+            controller.enqueue('hello world');
+            controller.close();
+          },
+        }).pipeThrough(new TextEncoderStream());
+        fetch(location.href, {method: 'POST', body: stream, duplex: 'half'}).then(x => x.text())
+      `);
+      expect(fetchBodyResult).to.equal('hello world');
+    });
+
+    it('can receive an error from streaming fetch upload', async () => {
+      protocol.handle('no-cors', (req) => new Response(req.body));
+      defer(() => { protocol.unhandle('no-cors'); });
+      await contents.loadURL('no-cors://foo');
+      const fetchBodyResult = await contents.executeJavaScript(`
+        const stream = new ReadableStream({
+          async start(controller) {
+            controller.error('test')
+          },
+        });
+        fetch(location.href, {method: 'POST', body: stream, duplex: 'half'}).then(x => x.text()).catch(err => err)
+      `);
+      expect(fetchBodyResult).to.be.an.instanceOf(Error);
+    });
+
+    it('gets an error from streaming fetch upload when the renderer dies', async () => {
+      let gotRequest: Function;
+      const receivedRequest = new Promise<Request>(resolve => { gotRequest = resolve; });
+      protocol.handle('no-cors', (req) => {
+        if (/fetch/.test(req.url)) gotRequest(req);
+        return new Response();
+      });
+      defer(() => { protocol.unhandle('no-cors'); });
+      await contents.loadURL('no-cors://foo');
+      contents.executeJavaScript(`
+        const stream = new ReadableStream({
+          async start(controller) {
+            window.controller = controller // no GC
+          },
+        });
+        fetch(location.href + '/fetch', {method: 'POST', body: stream, duplex: 'half'}).then(x => x.text()).catch(err => err)
+      `);
+      const req = await receivedRequest;
+      contents.destroy();
+      // Undo .destroy() for the next test
+      contents = (webContents as typeof ElectronInternal.WebContents).create({ sandbox: true });
+      await expect(req.body!.getReader().read()).to.eventually.be.rejectedWith('net::ERR_FAILED');
+    });
+
+    it('can bypass intercepeted protocol handlers', async () => {
+      protocol.handle('http', () => new Response('custom'));
+      defer(() => { protocol.unhandle('http'); });
+      const server = http.createServer((req, res) => {
+        res.end('default');
+      });
+      defer(() => server.close());
+      const { url } = await listen(server);
+      expect(await net.fetch(url, { bypassCustomProtocolHandlers: true }).then(r => r.text())).to.equal('default');
+    });
+
+    it('bypassing custom protocol handlers also bypasses new protocols', async () => {
+      protocol.handle('app', () => new Response('custom'));
+      defer(() => { protocol.unhandle('app'); });
+      await expect(net.fetch('app://foo', { bypassCustomProtocolHandlers: true })).to.be.rejectedWith('net::ERR_UNKNOWN_URL_SCHEME');
+    });
+
+    it('can forward to the original handler', async () => {
+      protocol.handle('http', (req) => net.fetch(req, { bypassCustomProtocolHandlers: true }));
+      defer(() => { protocol.unhandle('http'); });
+      const server = http.createServer((req, res) => {
+        res.end('hello');
+        server.close();
+      });
+      const { url } = await listen(server);
+      await contents.loadURL(url);
+      expect(await contents.executeJavaScript('document.documentElement.textContent')).to.equal('hello');
+    });
+
+    it('supports sniffing mime type', async () => {
+      protocol.handle('http', async (req) => {
+        return net.fetch(req, { bypassCustomProtocolHandlers: true });
+      });
+      defer(() => { protocol.unhandle('http'); });
+
+      const server = http.createServer((req, res) => {
+        if (/html/.test(req.url ?? '')) { res.end('<!doctype html><body>hi'); } else { res.end('hi'); }
+      });
+      const { url } = await listen(server);
+      defer(() => server.close());
+
+      {
+        await contents.loadURL(url);
+        const doc = await contents.executeJavaScript('document.documentElement.outerHTML');
+        expect(doc).to.match(/white-space: pre-wrap/);
+      }
+      {
+        await contents.loadURL(url + '?html');
+        const doc = await contents.executeJavaScript('document.documentElement.outerHTML');
+        expect(doc).to.equal('<html><head></head><body>hi</body></html>');
+      }
+    });
+
+    // TODO(nornagon): this test doesn't pass on Linux currently, investigate.
+    ifit(process.platform !== 'linux')('is fast', async () => {
+      // 128 MB of spaces.
+      const chunk = new Uint8Array(128 * 1024 * 1024);
+      chunk.fill(' '.charCodeAt(0));
+
+      const server = http.createServer((req, res) => {
+        // The sniffed mime type for the space-filled chunk will be
+        // text/plain, which chews up all its performance in the renderer
+        // trying to wrap lines. Setting content-type to text/html measures
+        // something closer to just the raw cost of getting the bytes over
+        // the wire.
+        res.setHeader('content-type', 'text/html');
+        res.end(chunk);
+      });
+      defer(() => server.close());
+      const { url } = await listen(server);
+
+      const rawTime = await (async () => {
+        await contents.loadURL(url); // warm
+        const begin = Date.now();
+        await contents.loadURL(url);
+        const end = Date.now();
+        return end - begin;
+      })();
+
+      // Fetching through an intercepted handler should not be too much slower
+      // than it would be if the protocol hadn't been intercepted.
+
+      protocol.handle('http', async (req) => {
+        return net.fetch(req, { bypassCustomProtocolHandlers: true });
+      });
+      defer(() => { protocol.unhandle('http'); });
+
+      const interceptedTime = await (async () => {
+        const begin = Date.now();
+        await contents.loadURL(url);
+        const end = Date.now();
+        return end - begin;
+      })();
+      expect(interceptedTime).to.be.lessThan(rawTime * 1.5);
+    });
+  });
 });

+ 114 - 2
spec/api-web-request-spec.ts

@@ -1,13 +1,16 @@
 import { expect } from 'chai';
 import * as http from 'http';
+import * as http2 from 'http2';
 import * as qs from 'querystring';
 import * as path from 'path';
+import * as fs from 'fs';
 import * as url from 'url';
 import * as WebSocket from 'ws';
 import { ipcMain, protocol, session, WebContents, webContents } from 'electron/main';
-import { Socket } from 'net';
-import { listen } from './lib/spec-helpers';
+import { AddressInfo, Socket } from 'net';
+import { listen, defer } from './lib/spec-helpers';
 import { once } from 'events';
+import { ReadableStream } from 'stream/web';
 
 const fixturesPath = path.resolve(__dirname, 'fixtures');
 
@@ -35,14 +38,35 @@ describe('webRequest module', () => {
     }
   });
   let defaultURL: string;
+  let http2URL: string;
+
+  const certPath = path.join(fixturesPath, 'certificates');
+  const h2server = http2.createSecureServer({
+    key: fs.readFileSync(path.join(certPath, 'server.key')),
+    cert: fs.readFileSync(path.join(certPath, 'server.pem'))
+  }, async (req, res) => {
+    if (req.method === 'POST') {
+      const chunks = [];
+      for await (const chunk of req) chunks.push(chunk);
+      res.end(Buffer.concat(chunks).toString('utf8'));
+    } else {
+      res.end('<html></html>');
+    }
+  });
 
   before(async () => {
     protocol.registerStringProtocol('cors', (req, cb) => cb(''));
     defaultURL = (await listen(server)).url + '/';
+    await new Promise<void>((resolve) => {
+      h2server.listen(0, '127.0.0.1', () => resolve());
+    });
+    http2URL = `https://127.0.0.1:${(h2server.address() as AddressInfo).port}/`;
+    console.log(http2URL);
   });
 
   after(() => {
     server.close();
+    h2server.close();
     protocol.unregisterProtocol('cors');
   });
 
@@ -50,6 +74,8 @@ describe('webRequest module', () => {
   // NB. sandbox: true is used because it makes navigations much (~8x) faster.
   before(async () => {
     contents = (webContents as typeof ElectronInternal.WebContents).create({ sandbox: true });
+    // const w = new BrowserWindow({webPreferences: {sandbox: true}})
+    // contents = w.webContents
     await contents.loadFile(path.join(fixturesPath, 'pages', 'fetch.html'));
   });
   after(() => contents.destroy());
@@ -161,6 +187,92 @@ describe('webRequest module', () => {
       });
       await expect(ajax(fileURL)).to.eventually.be.rejected();
     });
+
+    it('can handle a streaming upload', async () => {
+      // Streaming fetch uploads are only supported on HTTP/2, which is only
+      // supported over TLS, so...
+      session.defaultSession.setCertificateVerifyProc((req, cb) => cb(0));
+      defer(() => {
+        session.defaultSession.setCertificateVerifyProc(null);
+      });
+      const contents = (webContents as typeof ElectronInternal.WebContents).create({ sandbox: true });
+      defer(() => contents.close());
+      await contents.loadURL(http2URL);
+
+      ses.webRequest.onBeforeRequest((details, callback) => {
+        callback({});
+      });
+
+      const result = await contents.executeJavaScript(`
+        const stream = new ReadableStream({
+          async start(controller) {
+            controller.enqueue('hello world');
+            controller.close();
+          },
+        }).pipeThrough(new TextEncoderStream());
+        fetch("${http2URL}", {
+          method: 'POST',
+          body: stream,
+          duplex: 'half',
+        }).then(r => r.text())
+      `);
+      expect(result).to.equal('hello world');
+    });
+
+    it('can handle a streaming upload if the uploadData is read', async () => {
+      // Streaming fetch uploads are only supported on HTTP/2, which is only
+      // supported over TLS, so...
+      session.defaultSession.setCertificateVerifyProc((req, cb) => cb(0));
+      defer(() => {
+        session.defaultSession.setCertificateVerifyProc(null);
+      });
+      const contents = (webContents as typeof ElectronInternal.WebContents).create({ sandbox: true });
+      defer(() => contents.close());
+      await contents.loadURL(http2URL);
+
+      function makeStreamFromPipe (pipe: any): ReadableStream {
+        const buf = new Uint8Array(1024 * 1024 /* 1 MB */);
+        return new ReadableStream({
+          async pull (controller) {
+            try {
+              const rv = await pipe.read(buf);
+              if (rv > 0) {
+                controller.enqueue(buf.subarray(0, rv));
+              } else {
+                controller.close();
+              }
+            } catch (e) {
+              controller.error(e);
+            }
+          }
+        });
+      }
+
+      ses.webRequest.onBeforeRequest(async (details, callback) => {
+        const chunks = [];
+        for await (const chunk of makeStreamFromPipe((details.uploadData[0] as any).body)) { chunks.push(chunk); }
+        callback({});
+      });
+
+      const result = await contents.executeJavaScript(`
+        const stream = new ReadableStream({
+          async start(controller) {
+            controller.enqueue('hello world');
+            controller.close();
+          },
+        }).pipeThrough(new TextEncoderStream());
+        fetch("${http2URL}", {
+          method: 'POST',
+          body: stream,
+          duplex: 'half',
+        }).then(r => r.text())
+      `);
+
+      // NOTE: since the upload stream was consumed by the onBeforeRequest
+      // handler, it can't be used again to upload to the actual server.
+      // This is a limitation of the WebRequest API.
+      expect(result).to.equal('');
+    });
   });
 
   describe('webRequest.onBeforeSendHeaders', () => {

+ 1 - 0
typings/internal-ambient.d.ts

@@ -141,6 +141,7 @@ declare namespace NodeJS {
     hasUserActivation?: boolean;
     mode?: string;
     destination?: string;
+    bypassCustomProtocolHandlers?: boolean;
   };
   type ResponseHead = {
     statusCode: number;

+ 5 - 0
typings/internal-electron.d.ts

@@ -182,6 +182,11 @@ declare namespace Electron {
     setBackgroundThrottling(allowed: boolean): void;
   }
 
+  interface Protocol {
+    registerProtocol(scheme: string, handler: any): boolean;
+    interceptProtocol(scheme: string, handler: any): boolean;
+  }
+
   namespace Main {
     class BaseWindow extends Electron.BaseWindow {}
     class View extends Electron.View {}