Browse Source

feat: implement net.fetch (#36733)

Jeremy Rose 2 years ago
parent
commit
872d1fe05a

+ 12 - 3
docs/api/client-request.md

@@ -23,12 +23,14 @@ following properties:
     with which the request is associated. Defaults to the empty string. The
     `session` option supersedes `partition`. Thus if a `session` is explicitly
     specified, `partition` is ignored.
-  * `credentials` string (optional) - Can be `include` or `omit`. Whether to
-    send [credentials](https://fetch.spec.whatwg.org/#credentials) with this
+  * `credentials` string (optional) - Can be `include`, `omit` or
+    `same-origin`. Whether to send
+    [credentials](https://fetch.spec.whatwg.org/#credentials) with this
     request. If set to `include`, credentials from the session associated with
     the request will be used. If set to `omit`, credentials will not be sent
     with the request (and the `'login'` event will not be triggered in the
-    event of a 401). This matches the behavior of the
+    event of a 401). If set to `same-origin`, `origin` must also be specified.
+    This matches the behavior of the
     [fetch](https://fetch.spec.whatwg.org/#concept-request-credentials-mode)
     option of the same name. If this option is not specified, authentication
     data from the session will be sent, and cookies will not be sent (unless
@@ -49,6 +51,13 @@ following properties:
     [`request.followRedirect`](#requestfollowredirect) is invoked synchronously
     during the [`redirect`](#event-redirect) event.  Defaults to `follow`.
   * `origin` string (optional) - The origin URL of the request.
+  * `referrerPolicy` string (optional) - can be `""`, `no-referrer`,
+    `no-referrer-when-downgrade`, `origin`, `origin-when-cross-origin`,
+    `unsafe-url`, `same-origin`, `strict-origin`, or
+    `strict-origin-when-cross-origin`. Defaults to
+    `strict-origin-when-cross-origin`.
+  * `cache` string (optional) - can be `default`, `no-store`, `reload`,
+    `no-cache`, `force-cache` or `only-if-cached`.
 
 `options` properties such as `protocol`, `host`, `hostname`, `port` and `path`
 strictly follow the Node.js model as described in the

+ 38 - 0
docs/api/net.md

@@ -63,6 +63,44 @@ Creates a [`ClientRequest`](./client-request.md) instance using the provided
 The `net.request` method would be used to issue both secure and insecure HTTP
 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)
+
+Returns `Promise<GlobalResponse>` - see [Response](https://developer.mozilla.org/en-US/docs/Web/API/Response).
+
+Sends a request, similarly to how `fetch()` works in the renderer, using
+Chrome's network stack. This differs from Node's `fetch()`, which uses
+Node.js's HTTP stack.
+
+Example:
+
+```js
+async function example () {
+  const response = await net.fetch('https://my.app')
+  if (response.ok) {
+    const body = await response.json()
+    // ... use the result.
+  }
+}
+```
+
+This method will issue requests from the [default
+session](session.md#sessiondefaultsession). To send a `fetch` request from
+another session, use [ses.fetch()](session.md#sesfetchinput-init).
+
+See the MDN documentation for
+[`fetch()`](https://developer.mozilla.org/en-US/docs/Web/API/fetch) for more
+details.
+
+Limitations:
+
+* `net.fetch()` does not support the `data:` or `blob:` schemes.
+* The value of the `integrity` option is ignored.
+* The `.type` and `.url` values of the returned `Response` object are
+  incorrect.
+
 ### `net.isOnline()`
 
 Returns `boolean` - Whether there is currently internet connection.

+ 37 - 0
docs/api/session.md

@@ -731,6 +731,43 @@ Returns `Promise<void>` - Resolves when all connections are closed.
 
 **Note:** It will terminate / fail all requests currently in flight.
 
+#### `ses.fetch(input[, init])`
+
+* `input` string | [GlobalRequest](https://nodejs.org/api/globals.html#request)
+* `init` [RequestInit](https://developer.mozilla.org/en-US/docs/Web/API/fetch#options) (optional)
+
+Returns `Promise<GlobalResponse>` - see [Response](https://developer.mozilla.org/en-US/docs/Web/API/Response).
+
+Sends a request, similarly to how `fetch()` works in the renderer, using
+Chrome's network stack. This differs from Node's `fetch()`, which uses
+Node.js's HTTP stack.
+
+Example:
+
+```js
+async function example () {
+  const response = await net.fetch('https://my.app')
+  if (response.ok) {
+    const body = await response.json()
+    // ... use the result.
+  }
+}
+```
+
+See also [`net.fetch()`](net.md#netfetchinput-init), a convenience method which
+issues requests from the [default session](#sessiondefaultsession).
+
+See the MDN documentation for
+[`fetch()`](https://developer.mozilla.org/en-US/docs/Web/API/fetch) for more
+details.
+
+Limitations:
+
+* `net.fetch()` does not support the `data:` or `blob:` schemes.
+* The value of the `integrity` option is ignored.
+* The `.type` and `.url` values of the returned `Response` object are
+  incorrect.
+
 #### `ses.disableNetworkEmulation()`
 
 Disables any network emulation already active for the `session`. Resets to

+ 2 - 0
filenames.auto.gni

@@ -208,6 +208,8 @@ auto_filenames = {
     "lib/browser/api/message-channel.ts",
     "lib/browser/api/module-list.ts",
     "lib/browser/api/native-theme.ts",
+    "lib/browser/api/net-client-request.ts",
+    "lib/browser/api/net-fetch.ts",
     "lib/browser/api/net-log.ts",
     "lib/browser/api/net.ts",
     "lib/browser/api/notification.ts",

+ 522 - 0
lib/browser/api/net-client-request.ts

@@ -0,0 +1,522 @@
+import * as url from 'url';
+import { Readable, Writable } from 'stream';
+import { app } from 'electron/main';
+import type { ClientRequestConstructorOptions, UploadProgress } from 'electron/main';
+
+const {
+  isValidHeaderName,
+  isValidHeaderValue,
+  createURLLoader
+} = process._linkedBinding('electron_browser_net');
+const { Session } = process._linkedBinding('electron_browser_session');
+
+const kSupportedProtocols = new Set(['http:', 'https:']);
+
+// set of headers that Node.js discards duplicates for
+// see https://nodejs.org/api/http.html#http_message_headers
+const discardableDuplicateHeaders = new Set([
+  'content-type',
+  'content-length',
+  'user-agent',
+  'referer',
+  'host',
+  'authorization',
+  'proxy-authorization',
+  'if-modified-since',
+  'if-unmodified-since',
+  'from',
+  'location',
+  'max-forwards',
+  'retry-after',
+  'etag',
+  'last-modified',
+  'server',
+  'age',
+  'expires'
+]);
+
+class IncomingMessage extends Readable {
+  _shouldPush: boolean = false;
+  _data: (Buffer | null)[] = [];
+  _responseHead: NodeJS.ResponseHead;
+  _resume: (() => void) | null = null;
+
+  constructor (responseHead: NodeJS.ResponseHead) {
+    super();
+    this._responseHead = responseHead;
+  }
+
+  get statusCode () {
+    return this._responseHead.statusCode;
+  }
+
+  get statusMessage () {
+    return this._responseHead.statusMessage;
+  }
+
+  get headers () {
+    const filteredHeaders: Record<string, string | string[]> = {};
+    const { headers, rawHeaders } = this._responseHead;
+    for (const [name, values] of Object.entries(headers)) {
+      filteredHeaders[name] = discardableDuplicateHeaders.has(name) ? values[0] : values.join(', ');
+    }
+    const cookies = rawHeaders.filter(({ key }) => key.toLowerCase() === 'set-cookie').map(({ value }) => value);
+    // keep set-cookie as an array per Node.js rules
+    // see https://nodejs.org/api/http.html#http_message_headers
+    if (cookies.length) { filteredHeaders['set-cookie'] = cookies; }
+    return filteredHeaders;
+  }
+
+  get rawHeaders () {
+    const rawHeadersArr: string[] = [];
+    const { rawHeaders } = this._responseHead;
+    rawHeaders.forEach(header => {
+      rawHeadersArr.push(header.key, header.value);
+    });
+    return rawHeadersArr;
+  }
+
+  get httpVersion () {
+    return `${this.httpVersionMajor}.${this.httpVersionMinor}`;
+  }
+
+  get httpVersionMajor () {
+    return this._responseHead.httpVersion.major;
+  }
+
+  get httpVersionMinor () {
+    return this._responseHead.httpVersion.minor;
+  }
+
+  get rawTrailers () {
+    throw new Error('HTTP trailers are not supported');
+  }
+
+  get trailers () {
+    throw new Error('HTTP trailers are not supported');
+  }
+
+  _storeInternalData (chunk: Buffer | null, resume: (() => void) | null) {
+    // save the network callback for use in _pushInternalData
+    this._resume = resume;
+    this._data.push(chunk);
+    this._pushInternalData();
+  }
+
+  _pushInternalData () {
+    while (this._shouldPush && this._data.length > 0) {
+      const chunk = this._data.shift();
+      this._shouldPush = this.push(chunk);
+    }
+    if (this._shouldPush && this._resume) {
+      // Reset the callback, so that a new one is used for each
+      // batch of throttled data. Do this before calling resume to avoid a
+      // potential race-condition
+      const resume = this._resume;
+      this._resume = null;
+
+      resume();
+    }
+  }
+
+  _read () {
+    this._shouldPush = true;
+    this._pushInternalData();
+  }
+}
+
+/** Writable stream that buffers up everything written to it. */
+class SlurpStream extends Writable {
+  _data: Buffer;
+  constructor () {
+    super();
+    this._data = Buffer.alloc(0);
+  }
+
+  _write (chunk: Buffer, encoding: string, callback: () => void) {
+    this._data = Buffer.concat([this._data, chunk]);
+    callback();
+  }
+
+  data () { return this._data; }
+}
+
+class ChunkedBodyStream extends Writable {
+  _pendingChunk: Buffer | undefined;
+  _downstream?: NodeJS.DataPipe;
+  _pendingCallback?: (error?: Error) => void;
+  _clientRequest: ClientRequest;
+
+  constructor (clientRequest: ClientRequest) {
+    super();
+    this._clientRequest = clientRequest;
+  }
+
+  _write (chunk: Buffer, encoding: string, callback: () => void) {
+    if (this._downstream) {
+      this._downstream.write(chunk).then(callback, callback);
+    } else {
+      // the contract of _write is that we won't be called again until we call
+      // the callback, so we're good to just save a single chunk.
+      this._pendingChunk = chunk;
+      this._pendingCallback = callback;
+
+      // The first write to a chunked body stream begins the request.
+      this._clientRequest._startRequest();
+    }
+  }
+
+  _final (callback: () => void) {
+    this._downstream!.done();
+    callback();
+  }
+
+  startReading (pipe: NodeJS.DataPipe) {
+    if (this._downstream) {
+      throw new Error('two startReading calls???');
+    }
+    this._downstream = pipe;
+    if (this._pendingChunk) {
+      const doneWriting = (maybeError: Error | void) => {
+        // If the underlying request has been aborted, we honestly don't care about the error
+        // all work should cease as soon as we abort anyway, this error is probably a
+        // "mojo pipe disconnected" error (code=9)
+        if (this._clientRequest._aborted) return;
+
+        const cb = this._pendingCallback!;
+        delete this._pendingCallback;
+        delete this._pendingChunk;
+        cb(maybeError || undefined);
+      };
+      this._downstream.write(this._pendingChunk).then(doneWriting, doneWriting);
+    }
+  }
+}
+
+type RedirectPolicy = 'manual' | 'follow' | 'error';
+
+function parseOptions (optionsIn: ClientRequestConstructorOptions | string): NodeJS.CreateURLLoaderOptions & { redirectPolicy: RedirectPolicy, headers: Record<string, { name: string, value: string | string[] }> } {
+  const options: any = typeof optionsIn === 'string' ? url.parse(optionsIn) : { ...optionsIn };
+
+  let urlStr: string = options.url;
+
+  if (!urlStr) {
+    const urlObj: url.UrlObject = {};
+    const protocol = options.protocol || 'http:';
+    if (!kSupportedProtocols.has(protocol)) {
+      throw new Error('Protocol "' + protocol + '" not supported');
+    }
+    urlObj.protocol = protocol;
+
+    if (options.host) {
+      urlObj.host = options.host;
+    } else {
+      if (options.hostname) {
+        urlObj.hostname = options.hostname;
+      } else {
+        urlObj.hostname = 'localhost';
+      }
+
+      if (options.port) {
+        urlObj.port = options.port;
+      }
+    }
+
+    if (options.path && / /.test(options.path)) {
+      // The actual regex is more like /[^A-Za-z0-9\-._~!$&'()*+,;=/:@]/
+      // with an additional rule for ignoring percentage-escaped characters
+      // but that's a) hard to capture in a regular expression that performs
+      // well, and b) possibly too restrictive for real-world usage. That's
+      // why it only scans for spaces because those are guaranteed to create
+      // an invalid request.
+      throw new TypeError('Request path contains unescaped characters');
+    }
+    const pathObj = url.parse(options.path || '/');
+    urlObj.pathname = pathObj.pathname;
+    urlObj.search = pathObj.search;
+    urlObj.hash = pathObj.hash;
+    urlStr = url.format(urlObj);
+  }
+
+  const redirectPolicy = options.redirect || 'follow';
+  if (!['follow', 'error', 'manual'].includes(redirectPolicy)) {
+    throw new Error('redirect mode should be one of follow, error or manual');
+  }
+
+  if (options.headers != null && typeof options.headers !== 'object') {
+    throw new TypeError('headers must be an object');
+  }
+
+  const urlLoaderOptions: NodeJS.CreateURLLoaderOptions & { redirectPolicy: RedirectPolicy, headers: Record<string, { name: string, value: string | string[] }> } = {
+    method: (options.method || 'GET').toUpperCase(),
+    url: urlStr,
+    redirectPolicy,
+    headers: {},
+    body: null as any,
+    useSessionCookies: options.useSessionCookies,
+    credentials: options.credentials,
+    origin: options.origin,
+    referrerPolicy: options.referrerPolicy,
+    cache: options.cache
+  };
+  const headers: Record<string, string | string[]> = options.headers || {};
+  for (const [name, value] of Object.entries(headers)) {
+    if (!isValidHeaderName(name)) {
+      throw new Error(`Invalid header name: '${name}'`);
+    }
+    if (!isValidHeaderValue(value.toString())) {
+      throw new Error(`Invalid value for header '${name}': '${value}'`);
+    }
+    const key = name.toLowerCase();
+    urlLoaderOptions.headers[key] = { name, value };
+  }
+  if (options.session) {
+    if (!(options.session instanceof Session)) { throw new TypeError('`session` should be an instance of the Session class'); }
+    urlLoaderOptions.session = options.session;
+  } else if (options.partition) {
+    if (typeof options.partition === 'string') {
+      urlLoaderOptions.partition = options.partition;
+    } else {
+      throw new TypeError('`partition` should be a string');
+    }
+  }
+  return urlLoaderOptions;
+}
+
+export class ClientRequest extends Writable implements Electron.ClientRequest {
+  _started: boolean = false;
+  _firstWrite: boolean = false;
+  _aborted: boolean = false;
+  _chunkedEncoding: boolean | undefined;
+  _body: Writable | undefined;
+  _urlLoaderOptions: NodeJS.CreateURLLoaderOptions & { headers: Record<string, { name: string, value: string | string[] }> };
+  _redirectPolicy: RedirectPolicy;
+  _followRedirectCb?: () => void;
+  _uploadProgress?: { active: boolean, started: boolean, current: number, total: number };
+  _urlLoader?: NodeJS.URLLoader;
+  _response?: IncomingMessage;
+
+  constructor (options: ClientRequestConstructorOptions | string, callback?: (message: IncomingMessage) => void) {
+    super({ autoDestroy: true });
+
+    if (!app.isReady()) {
+      throw new Error('net module can only be used after app is ready');
+    }
+
+    if (callback) {
+      this.once('response', callback);
+    }
+
+    const { redirectPolicy, ...urlLoaderOptions } = parseOptions(options);
+    if (urlLoaderOptions.credentials === 'same-origin' && !urlLoaderOptions.origin) { throw new Error('credentials: same-origin requires origin to be set'); }
+    this._urlLoaderOptions = urlLoaderOptions;
+    this._redirectPolicy = redirectPolicy;
+  }
+
+  get chunkedEncoding () {
+    return this._chunkedEncoding || false;
+  }
+
+  set chunkedEncoding (value: boolean) {
+    if (this._started) {
+      throw new Error('chunkedEncoding can only be set before the request is started');
+    }
+    if (typeof this._chunkedEncoding !== 'undefined') {
+      throw new Error('chunkedEncoding can only be set once');
+    }
+    this._chunkedEncoding = !!value;
+    if (this._chunkedEncoding) {
+      this._body = new ChunkedBodyStream(this);
+      this._urlLoaderOptions.body = (pipe: NodeJS.DataPipe) => {
+        (this._body! as ChunkedBodyStream).startReading(pipe);
+      };
+    }
+  }
+
+  setHeader (name: string, value: string) {
+    if (typeof name !== 'string') {
+      throw new TypeError('`name` should be a string in setHeader(name, value)');
+    }
+    if (value == null) {
+      throw new Error('`value` required in setHeader("' + name + '", value)');
+    }
+    if (this._started || this._firstWrite) {
+      throw new Error('Can\'t set headers after they are sent');
+    }
+    if (!isValidHeaderName(name)) {
+      throw new Error(`Invalid header name: '${name}'`);
+    }
+    if (!isValidHeaderValue(value.toString())) {
+      throw new Error(`Invalid value for header '${name}': '${value}'`);
+    }
+
+    const key = name.toLowerCase();
+    this._urlLoaderOptions.headers[key] = { name, value };
+  }
+
+  getHeader (name: string) {
+    if (name == null) {
+      throw new Error('`name` is required for getHeader(name)');
+    }
+
+    const key = name.toLowerCase();
+    const header = this._urlLoaderOptions.headers[key];
+    return header && header.value as any;
+  }
+
+  removeHeader (name: string) {
+    if (name == null) {
+      throw new Error('`name` is required for removeHeader(name)');
+    }
+
+    if (this._started || this._firstWrite) {
+      throw new Error('Can\'t remove headers after they are sent');
+    }
+
+    const key = name.toLowerCase();
+    delete this._urlLoaderOptions.headers[key];
+  }
+
+  _write (chunk: Buffer, encoding: BufferEncoding, callback: () => void) {
+    this._firstWrite = true;
+    if (!this._body) {
+      this._body = new SlurpStream();
+      this._body.on('finish', () => {
+        this._urlLoaderOptions.body = (this._body as SlurpStream).data();
+        this._startRequest();
+      });
+    }
+    // TODO: is this the right way to forward to another stream?
+    this._body.write(chunk, encoding, callback);
+  }
+
+  _final (callback: () => void) {
+    if (this._body) {
+      // TODO: is this the right way to forward to another stream?
+      this._body.end(callback);
+    } else {
+      // end() called without a body, go ahead and start the request
+      this._startRequest();
+      callback();
+    }
+  }
+
+  _startRequest () {
+    this._started = true;
+    const stringifyValues = (obj: Record<string, { name: string, value: string | string[] }>) => {
+      const ret: Record<string, string> = {};
+      for (const k of Object.keys(obj)) {
+        const kv = obj[k];
+        ret[kv.name] = kv.value.toString();
+      }
+      return ret;
+    };
+    this._urlLoaderOptions.referrer = this.getHeader('referer') || '';
+    this._urlLoaderOptions.origin = this._urlLoaderOptions.origin || this.getHeader('origin') || '';
+    this._urlLoaderOptions.hasUserActivation = this.getHeader('sec-fetch-user') === '?1';
+    this._urlLoaderOptions.mode = this.getHeader('sec-fetch-mode') || '';
+    this._urlLoaderOptions.destination = this.getHeader('sec-fetch-dest') || '';
+    const opts = { ...this._urlLoaderOptions, extraHeaders: stringifyValues(this._urlLoaderOptions.headers) };
+    this._urlLoader = createURLLoader(opts);
+    this._urlLoader.on('response-started', (event, finalUrl, responseHead) => {
+      const response = this._response = new IncomingMessage(responseHead);
+      this.emit('response', response);
+    });
+    this._urlLoader.on('data', (event, data, resume) => {
+      this._response!._storeInternalData(Buffer.from(data), resume);
+    });
+    this._urlLoader.on('complete', () => {
+      if (this._response) { this._response._storeInternalData(null, null); }
+    });
+    this._urlLoader.on('error', (event, netErrorString) => {
+      const error = new Error(netErrorString);
+      if (this._response) this._response.destroy(error);
+      this._die(error);
+    });
+
+    this._urlLoader.on('login', (event, authInfo, callback) => {
+      const handled = this.emit('login', authInfo, callback);
+      if (!handled) {
+        // If there were no listeners, cancel the authentication request.
+        callback();
+      }
+    });
+
+    this._urlLoader.on('redirect', (event, redirectInfo, headers) => {
+      const { statusCode, newMethod, newUrl } = redirectInfo;
+      if (this._redirectPolicy === 'error') {
+        this._die(new Error('Attempted to redirect, but redirect policy was \'error\''));
+      } else if (this._redirectPolicy === 'manual') {
+        let _followRedirect = false;
+        this._followRedirectCb = () => { _followRedirect = true; };
+        try {
+          this.emit('redirect', statusCode, newMethod, newUrl, headers);
+        } finally {
+          this._followRedirectCb = undefined;
+          if (!_followRedirect && !this._aborted) {
+            this._die(new Error('Redirect was cancelled'));
+          }
+        }
+      } else if (this._redirectPolicy === 'follow') {
+        // Calling followRedirect() when the redirect policy is 'follow' is
+        // allowed but does nothing. (Perhaps it should throw an error
+        // though...? Since the redirect will happen regardless.)
+        try {
+          this._followRedirectCb = () => {};
+          this.emit('redirect', statusCode, newMethod, newUrl, headers);
+        } finally {
+          this._followRedirectCb = undefined;
+        }
+      } else {
+        this._die(new Error(`Unexpected redirect policy '${this._redirectPolicy}'`));
+      }
+    });
+
+    this._urlLoader.on('upload-progress', (event, position, total) => {
+      this._uploadProgress = { active: true, started: true, current: position, total };
+      this.emit('upload-progress', position, total); // Undocumented, for now
+    });
+
+    this._urlLoader.on('download-progress', (event, current) => {
+      if (this._response) {
+        this._response.emit('download-progress', current); // Undocumented, for now
+      }
+    });
+  }
+
+  followRedirect () {
+    if (this._followRedirectCb) {
+      this._followRedirectCb();
+    } else {
+      throw new Error('followRedirect() called, but was not waiting for a redirect');
+    }
+  }
+
+  abort () {
+    if (!this._aborted) {
+      process.nextTick(() => { this.emit('abort'); });
+    }
+    this._aborted = true;
+    this._die();
+  }
+
+  _die (err?: Error) {
+    // Node.js assumes that any stream which is ended is no longer capable of emitted events
+    // which is a faulty assumption for the case of an object that is acting like a stream
+    // (our urlRequest). If we don't emit here, this causes errors since we *do* expect
+    // that error events can be emitted after urlRequest.end().
+    if ((this as any)._writableState.destroyed && err) {
+      this.emit('error', err);
+    }
+
+    this.destroy(err);
+    if (this._urlLoader) {
+      this._urlLoader.cancel();
+      if (this._response) this._response.destroy(err);
+    }
+  }
+
+  getUploadProgress (): UploadProgress {
+    return this._uploadProgress ? { ...this._uploadProgress } : { active: false, started: false, current: 0, total: 0 };
+  }
+}

+ 116 - 0
lib/browser/api/net-fetch.ts

@@ -0,0 +1,116 @@
+import { net, IncomingMessage, Session as SessionT } from 'electron/main';
+import { Readable, Writable, isReadable } from 'stream';
+
+function createDeferredPromise<T, E extends Error = Error> (): { promise: Promise<T>; resolve: (x: T) => void; reject: (e: E) => void; } {
+  let res: (x: T) => void;
+  let rej: (e: E) => void;
+  const promise = new Promise<T>((resolve, reject) => {
+    res = resolve;
+    rej = reject;
+  });
+
+  return { promise, resolve: res!, reject: rej! };
+}
+
+export function fetchWithSession (input: RequestInfo, init: RequestInit | undefined, session: SessionT): Promise<Response> {
+  const p = createDeferredPromise<Response>();
+  let req: Request;
+  try {
+    req = new Request(input, init);
+  } catch (e: any) {
+    p.reject(e);
+    return p.promise;
+  }
+
+  if (req.signal.aborted) {
+    // 1. Abort the fetch() call with p, request, null, and
+    //    requestObject’s signal’s abort reason.
+    const error = (req.signal as any).reason ?? new DOMException('The operation was aborted.', 'AbortError');
+    p.reject(error);
+
+    if (req.body != null && isReadable(req.body as unknown as NodeJS.ReadableStream)) {
+      req.body.cancel(error).catch((err) => {
+        if (err.code === 'ERR_INVALID_STATE') {
+          // Node bug?
+          return;
+        }
+        throw err;
+      });
+    }
+
+    // 2. Return p.
+    return p.promise;
+  }
+
+  let locallyAborted = false;
+  req.signal.addEventListener(
+    'abort',
+    () => {
+      // 1. Set locallyAborted to true.
+      locallyAborted = true;
+
+      // 2. Abort the fetch() call with p, request, responseObject,
+      //    and requestObject’s signal’s abort reason.
+      const error = (req.signal as any).reason ?? new DOMException('The operation was aborted.', 'AbortError');
+      p.reject(error);
+      if (req.body != null && isReadable(req.body as unknown as NodeJS.ReadableStream)) {
+        req.body.cancel(error).catch((err) => {
+          if (err.code === 'ERR_INVALID_STATE') {
+            // Node bug?
+            return;
+          }
+          throw err;
+        });
+      }
+
+      r.abort();
+    },
+    { once: true }
+  );
+
+  const origin = req.headers.get('origin') ?? undefined;
+  // We can't set credentials to same-origin unless there's an origin set.
+  const credentials = req.credentials === 'same-origin' && !origin ? 'include' : req.credentials;
+
+  const r = net.request({
+    session,
+    method: req.method,
+    url: req.url,
+    origin,
+    credentials,
+    cache: req.cache,
+    referrerPolicy: req.referrerPolicy,
+    redirect: req.redirect
+  });
+
+  // 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);
+  }
+
+  for (const [k, v] of req.headers) {
+    r.setHeader(k, v);
+  }
+
+  r.on('response', (resp: IncomingMessage) => {
+    if (locallyAborted) return;
+    const headers = new Headers();
+    for (const [k, v] of Object.entries(resp.headers)) { headers.set(k, Array.isArray(v) ? v.join(', ') : v); }
+    const nullBodyStatus = [101, 204, 205, 304];
+    const body = nullBodyStatus.includes(resp.statusCode) || req.method === 'HEAD' ? null : Readable.toWeb(resp as unknown as Readable) as ReadableStream;
+    const rResp = new Response(body, {
+      headers,
+      status: resp.statusCode,
+      statusText: resp.statusMessage
+    });
+    p.resolve(rResp);
+  });
+
+  r.on('error', (err) => {
+    p.reject(err);
+  });
+
+  if (!req.body?.pipeTo(Writable.toWeb(r as unknown as Writable)).then(() => r.end())) { r.end(); }
+
+  return p.promise;
+}

+ 8 - 522
lib/browser/api/net.ts

@@ -1,531 +1,17 @@
-import * as url from 'url';
-import { Readable, Writable } from 'stream';
-import { app } from 'electron/main';
-import type { ClientRequestConstructorOptions, UploadProgress } from 'electron/main';
+import { IncomingMessage, session } from 'electron/main';
+import type { ClientRequestConstructorOptions } from 'electron/main';
+import { ClientRequest } from '@electron/internal/browser/api/net-client-request';
 
-const {
-  isOnline,
-  isValidHeaderName,
-  isValidHeaderValue,
-  createURLLoader
-} = process._linkedBinding('electron_browser_net');
-
-const kSupportedProtocols = new Set(['http:', 'https:']);
-
-// set of headers that Node.js discards duplicates for
-// see https://nodejs.org/api/http.html#http_message_headers
-const discardableDuplicateHeaders = new Set([
-  'content-type',
-  'content-length',
-  'user-agent',
-  'referer',
-  'host',
-  'authorization',
-  'proxy-authorization',
-  'if-modified-since',
-  'if-unmodified-since',
-  'from',
-  'location',
-  'max-forwards',
-  'retry-after',
-  'etag',
-  'last-modified',
-  'server',
-  'age',
-  'expires'
-]);
-
-class IncomingMessage extends Readable {
-  _shouldPush: boolean = false;
-  _data: (Buffer | null)[] = [];
-  _responseHead: NodeJS.ResponseHead;
-  _resume: (() => void) | null = null;
-
-  constructor (responseHead: NodeJS.ResponseHead) {
-    super();
-    this._responseHead = responseHead;
-  }
-
-  get statusCode () {
-    return this._responseHead.statusCode;
-  }
-
-  get statusMessage () {
-    return this._responseHead.statusMessage;
-  }
-
-  get headers () {
-    const filteredHeaders: Record<string, string | string[]> = {};
-    const { headers, rawHeaders } = this._responseHead;
-    for (const [name, values] of Object.entries(headers)) {
-      filteredHeaders[name] = discardableDuplicateHeaders.has(name) ? values[0] : values.join(', ');
-    }
-    const cookies = rawHeaders.filter(({ key }) => key.toLowerCase() === 'set-cookie').map(({ value }) => value);
-    // keep set-cookie as an array per Node.js rules
-    // see https://nodejs.org/api/http.html#http_message_headers
-    if (cookies.length) { filteredHeaders['set-cookie'] = cookies; }
-    return filteredHeaders;
-  }
-
-  get rawHeaders () {
-    const rawHeadersArr: string[] = [];
-    const { rawHeaders } = this._responseHead;
-    rawHeaders.forEach(header => {
-      rawHeadersArr.push(header.key, header.value);
-    });
-    return rawHeadersArr;
-  }
-
-  get httpVersion () {
-    return `${this.httpVersionMajor}.${this.httpVersionMinor}`;
-  }
-
-  get httpVersionMajor () {
-    return this._responseHead.httpVersion.major;
-  }
-
-  get httpVersionMinor () {
-    return this._responseHead.httpVersion.minor;
-  }
-
-  get rawTrailers () {
-    throw new Error('HTTP trailers are not supported');
-  }
-
-  get trailers () {
-    throw new Error('HTTP trailers are not supported');
-  }
-
-  _storeInternalData (chunk: Buffer | null, resume: (() => void) | null) {
-    // save the network callback for use in _pushInternalData
-    this._resume = resume;
-    this._data.push(chunk);
-    this._pushInternalData();
-  }
-
-  _pushInternalData () {
-    while (this._shouldPush && this._data.length > 0) {
-      const chunk = this._data.shift();
-      this._shouldPush = this.push(chunk);
-    }
-    if (this._shouldPush && this._resume) {
-      // Reset the callback, so that a new one is used for each
-      // batch of throttled data. Do this before calling resume to avoid a
-      // potential race-condition
-      const resume = this._resume;
-      this._resume = null;
-
-      resume();
-    }
-  }
-
-  _read () {
-    this._shouldPush = true;
-    this._pushInternalData();
-  }
-}
-
-/** Writable stream that buffers up everything written to it. */
-class SlurpStream extends Writable {
-  _data: Buffer;
-  constructor () {
-    super();
-    this._data = Buffer.alloc(0);
-  }
-
-  _write (chunk: Buffer, encoding: string, callback: () => void) {
-    this._data = Buffer.concat([this._data, chunk]);
-    callback();
-  }
-
-  data () { return this._data; }
-}
-
-class ChunkedBodyStream extends Writable {
-  _pendingChunk: Buffer | undefined;
-  _downstream?: NodeJS.DataPipe;
-  _pendingCallback?: (error?: Error) => void;
-  _clientRequest: ClientRequest;
-
-  constructor (clientRequest: ClientRequest) {
-    super();
-    this._clientRequest = clientRequest;
-  }
-
-  _write (chunk: Buffer, encoding: string, callback: () => void) {
-    if (this._downstream) {
-      this._downstream.write(chunk).then(callback, callback);
-    } else {
-      // the contract of _write is that we won't be called again until we call
-      // the callback, so we're good to just save a single chunk.
-      this._pendingChunk = chunk;
-      this._pendingCallback = callback;
-
-      // The first write to a chunked body stream begins the request.
-      this._clientRequest._startRequest();
-    }
-  }
-
-  _final (callback: () => void) {
-    this._downstream!.done();
-    callback();
-  }
-
-  startReading (pipe: NodeJS.DataPipe) {
-    if (this._downstream) {
-      throw new Error('two startReading calls???');
-    }
-    this._downstream = pipe;
-    if (this._pendingChunk) {
-      const doneWriting = (maybeError: Error | void) => {
-        // If the underlying request has been aborted, we honestly don't care about the error
-        // all work should cease as soon as we abort anyway, this error is probably a
-        // "mojo pipe disconnected" error (code=9)
-        if (this._clientRequest._aborted) return;
-
-        const cb = this._pendingCallback!;
-        delete this._pendingCallback;
-        delete this._pendingChunk;
-        cb(maybeError || undefined);
-      };
-      this._downstream.write(this._pendingChunk).then(doneWriting, doneWriting);
-    }
-  }
-}
-
-type RedirectPolicy = 'manual' | 'follow' | 'error';
-
-function parseOptions (optionsIn: ClientRequestConstructorOptions | string): NodeJS.CreateURLLoaderOptions & { redirectPolicy: RedirectPolicy, headers: Record<string, { name: string, value: string | string[] }> } {
-  const options: any = typeof optionsIn === 'string' ? url.parse(optionsIn) : { ...optionsIn };
-
-  let urlStr: string = options.url;
-
-  if (!urlStr) {
-    const urlObj: url.UrlObject = {};
-    const protocol = options.protocol || 'http:';
-    if (!kSupportedProtocols.has(protocol)) {
-      throw new Error('Protocol "' + protocol + '" not supported');
-    }
-    urlObj.protocol = protocol;
-
-    if (options.host) {
-      urlObj.host = options.host;
-    } else {
-      if (options.hostname) {
-        urlObj.hostname = options.hostname;
-      } else {
-        urlObj.hostname = 'localhost';
-      }
-
-      if (options.port) {
-        urlObj.port = options.port;
-      }
-    }
-
-    if (options.path && / /.test(options.path)) {
-      // The actual regex is more like /[^A-Za-z0-9\-._~!$&'()*+,;=/:@]/
-      // with an additional rule for ignoring percentage-escaped characters
-      // but that's a) hard to capture in a regular expression that performs
-      // well, and b) possibly too restrictive for real-world usage. That's
-      // why it only scans for spaces because those are guaranteed to create
-      // an invalid request.
-      throw new TypeError('Request path contains unescaped characters');
-    }
-    const pathObj = url.parse(options.path || '/');
-    urlObj.pathname = pathObj.pathname;
-    urlObj.search = pathObj.search;
-    urlObj.hash = pathObj.hash;
-    urlStr = url.format(urlObj);
-  }
-
-  const redirectPolicy = options.redirect || 'follow';
-  if (!['follow', 'error', 'manual'].includes(redirectPolicy)) {
-    throw new Error('redirect mode should be one of follow, error or manual');
-  }
-
-  if (options.headers != null && typeof options.headers !== 'object') {
-    throw new TypeError('headers must be an object');
-  }
-
-  const urlLoaderOptions: NodeJS.CreateURLLoaderOptions & { redirectPolicy: RedirectPolicy, headers: Record<string, { name: string, value: string | string[] }> } = {
-    method: (options.method || 'GET').toUpperCase(),
-    url: urlStr,
-    redirectPolicy,
-    headers: {},
-    body: null as any,
-    useSessionCookies: options.useSessionCookies,
-    credentials: options.credentials,
-    origin: options.origin
-  };
-  const headers: Record<string, string | string[]> = options.headers || {};
-  for (const [name, value] of Object.entries(headers)) {
-    if (!isValidHeaderName(name)) {
-      throw new Error(`Invalid header name: '${name}'`);
-    }
-    if (!isValidHeaderValue(value.toString())) {
-      throw new Error(`Invalid value for header '${name}': '${value}'`);
-    }
-    const key = name.toLowerCase();
-    urlLoaderOptions.headers[key] = { name, value };
-  }
-  if (options.session) {
-    // Weak check, but it should be enough to catch 99% of accidental misuses.
-    if (options.session.constructor && options.session.constructor.name === 'Session') {
-      urlLoaderOptions.session = options.session;
-    } else {
-      throw new TypeError('`session` should be an instance of the Session class');
-    }
-  } else if (options.partition) {
-    if (typeof options.partition === 'string') {
-      urlLoaderOptions.partition = options.partition;
-    } else {
-      throw new TypeError('`partition` should be a string');
-    }
-  }
-  return urlLoaderOptions;
-}
-
-export class ClientRequest extends Writable implements Electron.ClientRequest {
-  _started: boolean = false;
-  _firstWrite: boolean = false;
-  _aborted: boolean = false;
-  _chunkedEncoding: boolean | undefined;
-  _body: Writable | undefined;
-  _urlLoaderOptions: NodeJS.CreateURLLoaderOptions & { headers: Record<string, { name: string, value: string | string[] }> };
-  _redirectPolicy: RedirectPolicy;
-  _followRedirectCb?: () => void;
-  _uploadProgress?: { active: boolean, started: boolean, current: number, total: number };
-  _urlLoader?: NodeJS.URLLoader;
-  _response?: IncomingMessage;
-
-  constructor (options: ClientRequestConstructorOptions | string, callback?: (message: IncomingMessage) => void) {
-    super({ autoDestroy: true });
-
-    if (!app.isReady()) {
-      throw new Error('net module can only be used after app is ready');
-    }
-
-    if (callback) {
-      this.once('response', callback);
-    }
-
-    const { redirectPolicy, ...urlLoaderOptions } = parseOptions(options);
-    this._urlLoaderOptions = urlLoaderOptions;
-    this._redirectPolicy = redirectPolicy;
-  }
-
-  get chunkedEncoding () {
-    return this._chunkedEncoding || false;
-  }
-
-  set chunkedEncoding (value: boolean) {
-    if (this._started) {
-      throw new Error('chunkedEncoding can only be set before the request is started');
-    }
-    if (typeof this._chunkedEncoding !== 'undefined') {
-      throw new Error('chunkedEncoding can only be set once');
-    }
-    this._chunkedEncoding = !!value;
-    if (this._chunkedEncoding) {
-      this._body = new ChunkedBodyStream(this);
-      this._urlLoaderOptions.body = (pipe: NodeJS.DataPipe) => {
-        (this._body! as ChunkedBodyStream).startReading(pipe);
-      };
-    }
-  }
-
-  setHeader (name: string, value: string) {
-    if (typeof name !== 'string') {
-      throw new TypeError('`name` should be a string in setHeader(name, value)');
-    }
-    if (value == null) {
-      throw new Error('`value` required in setHeader("' + name + '", value)');
-    }
-    if (this._started || this._firstWrite) {
-      throw new Error('Can\'t set headers after they are sent');
-    }
-    if (!isValidHeaderName(name)) {
-      throw new Error(`Invalid header name: '${name}'`);
-    }
-    if (!isValidHeaderValue(value.toString())) {
-      throw new Error(`Invalid value for header '${name}': '${value}'`);
-    }
-
-    const key = name.toLowerCase();
-    this._urlLoaderOptions.headers[key] = { name, value };
-  }
-
-  getHeader (name: string) {
-    if (name == null) {
-      throw new Error('`name` is required for getHeader(name)');
-    }
-
-    const key = name.toLowerCase();
-    const header = this._urlLoaderOptions.headers[key];
-    return header && header.value as any;
-  }
-
-  removeHeader (name: string) {
-    if (name == null) {
-      throw new Error('`name` is required for removeHeader(name)');
-    }
-
-    if (this._started || this._firstWrite) {
-      throw new Error('Can\'t remove headers after they are sent');
-    }
-
-    const key = name.toLowerCase();
-    delete this._urlLoaderOptions.headers[key];
-  }
-
-  _write (chunk: Buffer, encoding: BufferEncoding, callback: () => void) {
-    this._firstWrite = true;
-    if (!this._body) {
-      this._body = new SlurpStream();
-      this._body.on('finish', () => {
-        this._urlLoaderOptions.body = (this._body as SlurpStream).data();
-        this._startRequest();
-      });
-    }
-    // TODO: is this the right way to forward to another stream?
-    this._body.write(chunk, encoding, callback);
-  }
-
-  _final (callback: () => void) {
-    if (this._body) {
-      // TODO: is this the right way to forward to another stream?
-      this._body.end(callback);
-    } else {
-      // end() called without a body, go ahead and start the request
-      this._startRequest();
-      callback();
-    }
-  }
-
-  _startRequest () {
-    this._started = true;
-    const stringifyValues = (obj: Record<string, { name: string, value: string | string[] }>) => {
-      const ret: Record<string, string> = {};
-      for (const k of Object.keys(obj)) {
-        const kv = obj[k];
-        ret[kv.name] = kv.value.toString();
-      }
-      return ret;
-    };
-    this._urlLoaderOptions.referrer = this.getHeader('referer') || '';
-    this._urlLoaderOptions.origin = this._urlLoaderOptions.origin || this.getHeader('origin') || '';
-    this._urlLoaderOptions.hasUserActivation = this.getHeader('sec-fetch-user') === '?1';
-    this._urlLoaderOptions.mode = this.getHeader('sec-fetch-mode') || '';
-    this._urlLoaderOptions.destination = this.getHeader('sec-fetch-dest') || '';
-    const opts = { ...this._urlLoaderOptions, extraHeaders: stringifyValues(this._urlLoaderOptions.headers) };
-    this._urlLoader = createURLLoader(opts);
-    this._urlLoader.on('response-started', (event, finalUrl, responseHead) => {
-      const response = this._response = new IncomingMessage(responseHead);
-      this.emit('response', response);
-    });
-    this._urlLoader.on('data', (event, data, resume) => {
-      this._response!._storeInternalData(Buffer.from(data), resume);
-    });
-    this._urlLoader.on('complete', () => {
-      if (this._response) { this._response._storeInternalData(null, null); }
-    });
-    this._urlLoader.on('error', (event, netErrorString) => {
-      const error = new Error(netErrorString);
-      if (this._response) this._response.destroy(error);
-      this._die(error);
-    });
-
-    this._urlLoader.on('login', (event, authInfo, callback) => {
-      const handled = this.emit('login', authInfo, callback);
-      if (!handled) {
-        // If there were no listeners, cancel the authentication request.
-        callback();
-      }
-    });
-
-    this._urlLoader.on('redirect', (event, redirectInfo, headers) => {
-      const { statusCode, newMethod, newUrl } = redirectInfo;
-      if (this._redirectPolicy === 'error') {
-        this._die(new Error('Attempted to redirect, but redirect policy was \'error\''));
-      } else if (this._redirectPolicy === 'manual') {
-        let _followRedirect = false;
-        this._followRedirectCb = () => { _followRedirect = true; };
-        try {
-          this.emit('redirect', statusCode, newMethod, newUrl, headers);
-        } finally {
-          this._followRedirectCb = undefined;
-          if (!_followRedirect && !this._aborted) {
-            this._die(new Error('Redirect was cancelled'));
-          }
-        }
-      } else if (this._redirectPolicy === 'follow') {
-        // Calling followRedirect() when the redirect policy is 'follow' is
-        // allowed but does nothing. (Perhaps it should throw an error
-        // though...? Since the redirect will happen regardless.)
-        try {
-          this._followRedirectCb = () => {};
-          this.emit('redirect', statusCode, newMethod, newUrl, headers);
-        } finally {
-          this._followRedirectCb = undefined;
-        }
-      } else {
-        this._die(new Error(`Unexpected redirect policy '${this._redirectPolicy}'`));
-      }
-    });
-
-    this._urlLoader.on('upload-progress', (event, position, total) => {
-      this._uploadProgress = { active: true, started: true, current: position, total };
-      this.emit('upload-progress', position, total); // Undocumented, for now
-    });
-
-    this._urlLoader.on('download-progress', (event, current) => {
-      if (this._response) {
-        this._response.emit('download-progress', current); // Undocumented, for now
-      }
-    });
-  }
-
-  followRedirect () {
-    if (this._followRedirectCb) {
-      this._followRedirectCb();
-    } else {
-      throw new Error('followRedirect() called, but was not waiting for a redirect');
-    }
-  }
-
-  abort () {
-    if (!this._aborted) {
-      process.nextTick(() => { this.emit('abort'); });
-    }
-    this._aborted = true;
-    this._die();
-  }
-
-  _die (err?: Error) {
-    // Node.js assumes that any stream which is ended is no longer capable of emitted events
-    // which is a faulty assumption for the case of an object that is acting like a stream
-    // (our urlRequest). If we don't emit here, this causes errors since we *do* expect
-    // that error events can be emitted after urlRequest.end().
-    if ((this as any)._writableState.destroyed && err) {
-      this.emit('error', err);
-    }
-
-    this.destroy(err);
-    if (this._urlLoader) {
-      this._urlLoader.cancel();
-      if (this._response) this._response.destroy(err);
-    }
-  }
-
-  getUploadProgress (): UploadProgress {
-    return this._uploadProgress ? { ...this._uploadProgress } : { active: false, started: false, current: 0, total: 0 };
-  }
-}
+const { isOnline } = process._linkedBinding('electron_browser_net');
 
 export function request (options: ClientRequestConstructorOptions | string, callback?: (message: IncomingMessage) => void) {
   return new ClientRequest(options, callback);
 }
 
+export function fetch (input: RequestInfo, init?: RequestInit): Promise<Response> {
+  return session.defaultSession.fetch(input, init);
+}
+
 exports.isOnline = isOnline;
 
 Object.defineProperty(exports, 'online', {

+ 6 - 1
lib/browser/api/session.ts

@@ -1,4 +1,9 @@
-const { fromPartition } = process._linkedBinding('electron_browser_session');
+import { fetchWithSession } from '@electron/internal/browser/api/net-fetch';
+const { fromPartition, Session } = process._linkedBinding('electron_browser_session');
+
+Session.prototype.fetch = function (input: RequestInfo, init?: RequestInit) {
+  return fetchWithSession(input, init, this);
+};
 
 export default {
   fromPartition,

+ 13 - 5
shell/browser/api/electron_api_session.cc

@@ -1203,10 +1203,16 @@ gin::Handle<Session> Session::FromPartition(v8::Isolate* isolate,
   return CreateFrom(isolate, browser_context);
 }
 
-gin::ObjectTemplateBuilder Session::GetObjectTemplateBuilder(
-    v8::Isolate* isolate) {
-  return gin_helper::EventEmitterMixin<Session>::GetObjectTemplateBuilder(
-             isolate)
+// static
+gin::Handle<Session> Session::New() {
+  gin_helper::ErrorThrower(JavascriptEnvironment::GetIsolate())
+      .ThrowError("Session objects cannot be created with 'new'");
+  return gin::Handle<Session>();
+}
+
+void Session::FillObjectTemplate(v8::Isolate* isolate,
+                                 v8::Local<v8::ObjectTemplate> templ) {
+  gin::ObjectTemplateBuilder(isolate, "Session", templ)
       .SetMethod("resolveProxy", &Session::ResolveProxy)
       .SetMethod("getCacheSize", &Session::GetCacheSize)
       .SetMethod("clearCache", &Session::ClearCache)
@@ -1276,7 +1282,8 @@ gin::ObjectTemplateBuilder Session::GetObjectTemplateBuilder(
       .SetProperty("protocol", &Session::Protocol)
       .SetProperty("serviceWorkers", &Session::ServiceWorkerContext)
       .SetProperty("webRequest", &Session::WebRequest)
-      .SetProperty("storagePath", &Session::GetPath);
+      .SetProperty("storagePath", &Session::GetPath)
+      .Build();
 }
 
 const char* Session::GetTypeName() {
@@ -1307,6 +1314,7 @@ void Initialize(v8::Local<v8::Object> exports,
                 void* priv) {
   v8::Isolate* isolate = context->GetIsolate();
   gin_helper::Dictionary dict(isolate, exports);
+  dict.Set("Session", Session::GetConstructor(context));
   dict.SetMethod("fromPartition", &FromPartition);
 }
 

+ 4 - 2
shell/browser/api/electron_api_session.h

@@ -17,6 +17,7 @@
 #include "shell/browser/event_emitter_mixin.h"
 #include "shell/browser/net/resolve_proxy_helper.h"
 #include "shell/common/gin_helper/cleaned_up_at_exit.h"
+#include "shell/common/gin_helper/constructible.h"
 #include "shell/common/gin_helper/error_thrower.h"
 #include "shell/common/gin_helper/function_template_extensions.h"
 #include "shell/common/gin_helper/pinnable.h"
@@ -57,6 +58,7 @@ namespace api {
 
 class Session : public gin::Wrappable<Session>,
                 public gin_helper::Pinnable<Session>,
+                public gin_helper::Constructible<Session>,
                 public gin_helper::EventEmitterMixin<Session>,
                 public gin_helper::CleanedUpAtExit,
 #if BUILDFLAG(ENABLE_BUILTIN_SPELLCHECKER)
@@ -71,6 +73,7 @@ class Session : public gin::Wrappable<Session>,
   static gin::Handle<Session> CreateFrom(
       v8::Isolate* isolate,
       ElectronBrowserContext* browser_context);
+  static gin::Handle<Session> New();  // Dummy, do not use!
 
   static Session* FromBrowserContext(content::BrowserContext* context);
 
@@ -83,8 +86,7 @@ class Session : public gin::Wrappable<Session>,
 
   // gin::Wrappable
   static gin::WrapperInfo kWrapperInfo;
-  gin::ObjectTemplateBuilder GetObjectTemplateBuilder(
-      v8::Isolate* isolate) override;
+  static void FillObjectTemplate(v8::Isolate*, v8::Local<v8::ObjectTemplate>);
   const char* GetTypeName() override;
 
   // Methods.

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

@@ -32,6 +32,8 @@
 #include "shell/common/gin_helper/dictionary.h"
 #include "shell/common/gin_helper/object_template_builder.h"
 #include "shell/common/node_includes.h"
+#include "third_party/blink/public/common/loader/referrer_utils.h"
+#include "third_party/blink/public/mojom/fetch/fetch_api_request.mojom.h"
 
 namespace gin {
 
@@ -59,15 +61,84 @@ struct Converter<network::mojom::CredentialsMode> {
       *out = network::mojom::CredentialsMode::kOmit;
     else if (mode == "include")
       *out = network::mojom::CredentialsMode::kInclude;
+    else if (mode == "same-origin")
+      // Note: This only makes sense if the request specifies the "origin"
+      // option.
+      *out = network::mojom::CredentialsMode::kSameOrigin;
     else
-      // "same-origin" is technically a member of this enum as well, but it
-      // doesn't make sense in the context of `net.request()`, so don't convert
-      // it.
       return false;
     return true;
   }
 };
 
+template <>
+struct Converter<blink::mojom::FetchCacheMode> {
+  static bool FromV8(v8::Isolate* isolate,
+                     v8::Local<v8::Value> val,
+                     blink::mojom::FetchCacheMode* out) {
+    std::string cache;
+    if (!ConvertFromV8(isolate, val, &cache))
+      return false;
+    if (cache == "default") {
+      *out = blink::mojom::FetchCacheMode::kDefault;
+    } else if (cache == "no-store") {
+      *out = blink::mojom::FetchCacheMode::kNoStore;
+    } else if (cache == "reload") {
+      *out = blink::mojom::FetchCacheMode::kBypassCache;
+    } else if (cache == "no-cache") {
+      *out = blink::mojom::FetchCacheMode::kValidateCache;
+    } else if (cache == "force-cache") {
+      *out = blink::mojom::FetchCacheMode::kForceCache;
+    } else if (cache == "only-if-cached") {
+      *out = blink::mojom::FetchCacheMode::kOnlyIfCached;
+    } else {
+      return false;
+    }
+    return true;
+  }
+};
+
+template <>
+struct Converter<net::ReferrerPolicy> {
+  static bool FromV8(v8::Isolate* isolate,
+                     v8::Local<v8::Value> val,
+                     net::ReferrerPolicy* out) {
+    std::string referrer_policy;
+    if (!ConvertFromV8(isolate, val, &referrer_policy))
+      return false;
+    if (base::CompareCaseInsensitiveASCII(referrer_policy, "no-referrer") ==
+        0) {
+      *out = net::ReferrerPolicy::NO_REFERRER;
+    } else if (base::CompareCaseInsensitiveASCII(
+                   referrer_policy, "no-referrer-when-downgrade") == 0) {
+      *out = net::ReferrerPolicy::CLEAR_ON_TRANSITION_FROM_SECURE_TO_INSECURE;
+    } else if (base::CompareCaseInsensitiveASCII(referrer_policy, "origin") ==
+               0) {
+      *out = net::ReferrerPolicy::ORIGIN;
+    } else if (base::CompareCaseInsensitiveASCII(
+                   referrer_policy, "origin-when-cross-origin") == 0) {
+      *out = net::ReferrerPolicy::ORIGIN_ONLY_ON_TRANSITION_CROSS_ORIGIN;
+    } else if (base::CompareCaseInsensitiveASCII(referrer_policy,
+                                                 "unsafe-url") == 0) {
+      *out = net::ReferrerPolicy::NEVER_CLEAR;
+    } else if (base::CompareCaseInsensitiveASCII(referrer_policy,
+                                                 "same-origin") == 0) {
+      *out = net::ReferrerPolicy::CLEAR_ON_TRANSITION_CROSS_ORIGIN;
+    } else if (base::CompareCaseInsensitiveASCII(referrer_policy,
+                                                 "strict-origin") == 0) {
+      *out = net::ReferrerPolicy::
+          ORIGIN_CLEAR_ON_TRANSITION_FROM_SECURE_TO_INSECURE;
+    } else if (referrer_policy == "" ||
+               base::CompareCaseInsensitiveASCII(
+                   referrer_policy, "strict-origin-when-cross-origin") == 0) {
+      *out = net::ReferrerPolicy::REDUCE_GRANULARITY_ON_TRANSITION_CROSS_ORIGIN;
+    } else {
+      return false;
+    }
+    return true;
+  }
+};
+
 }  // namespace gin
 
 namespace electron::api {
@@ -401,6 +472,9 @@ gin::Handle<SimpleURLLoaderWrapper> SimpleURLLoaderWrapper::Create(
   opts.Get("url", &request->url);
   request->site_for_cookies = net::SiteForCookies::FromUrl(request->url);
   opts.Get("referrer", &request->referrer);
+  request->referrer_policy =
+      blink::ReferrerUtils::GetDefaultNetReferrerPolicy();
+  opts.Get("referrerPolicy", &request->referrer_policy);
   std::string origin;
   opts.Get("origin", &origin);
   if (!origin.empty()) {
@@ -484,6 +558,36 @@ gin::Handle<SimpleURLLoaderWrapper> SimpleURLLoaderWrapper::Create(
     }
   }
 
+  blink::mojom::FetchCacheMode cache_mode =
+      blink::mojom::FetchCacheMode::kDefault;
+  opts.Get("cache", &cache_mode);
+  switch (cache_mode) {
+    case blink::mojom::FetchCacheMode::kNoStore:
+      request->load_flags |= net::LOAD_DISABLE_CACHE;
+      break;
+    case blink::mojom::FetchCacheMode::kValidateCache:
+      request->load_flags |= net::LOAD_VALIDATE_CACHE;
+      break;
+    case blink::mojom::FetchCacheMode::kBypassCache:
+      request->load_flags |= net::LOAD_BYPASS_CACHE;
+      break;
+    case blink::mojom::FetchCacheMode::kForceCache:
+      request->load_flags |= net::LOAD_SKIP_CACHE_VALIDATION;
+      break;
+    case blink::mojom::FetchCacheMode::kOnlyIfCached:
+      request->load_flags |=
+          net::LOAD_ONLY_FROM_CACHE | net::LOAD_SKIP_CACHE_VALIDATION;
+      break;
+    case blink::mojom::FetchCacheMode::kUnspecifiedOnlyIfCachedStrict:
+      request->load_flags |= net::LOAD_ONLY_FROM_CACHE;
+      break;
+    case blink::mojom::FetchCacheMode::kDefault:
+      break;
+    case blink::mojom::FetchCacheMode::kUnspecifiedForceCacheMiss:
+      request->load_flags |= net::LOAD_ONLY_FROM_CACHE | net::LOAD_BYPASS_CACHE;
+      break;
+  }
+
   bool use_session_cookies = false;
   opts.Get("useSessionCookies", &use_session_cookies);
   int options = 0;

+ 100 - 1
spec/api-net-spec.ts

@@ -1610,7 +1610,15 @@ describe('net module', () => {
         response.statusMessage = 'OK';
         response.end();
       });
-      const urlRequest = net.request(serverUrl);
+      // The referrerPolicy must be unsafe-url because the referrer's origin
+      // doesn't match the loaded page. With the default referrer policy
+      // (strict-origin-when-cross-origin), the request will be canceled by the
+      // network service when the referrer header is invalid.
+      // See:
+      // - https://source.chromium.org/chromium/chromium/src/+/main:net/url_request/url_request.cc;l=682-683;drc=ae587fa7cd2e5cc308ce69353ee9ce86437e5d41
+      // - https://source.chromium.org/chromium/chromium/src/+/main:services/network/public/mojom/network_context.mojom;l=316-318;drc=ae5c7fcf09509843c1145f544cce3a61874b9698
+      // - https://w3c.github.io/webappsec-referrer-policy/#determine-requests-referrer
+      const urlRequest = net.request({ url: serverUrl, referrerPolicy: 'unsafe-url' });
       urlRequest.setHeader('referer', referrerURL);
       urlRequest.end();
 
@@ -2035,4 +2043,95 @@ describe('net module', () => {
       await collectStreamBody(await getResponse(urlRequest));
     });
   });
+
+  describe('net.fetch', () => {
+    // NB. there exist much more comprehensive tests for fetch() in the form of
+    // the WPT: https://github.com/web-platform-tests/wpt/tree/master/fetch
+    // It's possible to run these tests against net.fetch(), but the test
+    // harness to do so is quite complex and hasn't been munged to smoothly run
+    // inside the Electron test runner yet.
+    //
+    // In the meantime, here are some tests for basic functionality and
+    // Electron-specific behavior.
+
+    describe('basic', () => {
+      it('can fetch http urls', async () => {
+        const serverUrl = await respondOnce.toSingleURL((request, response) => {
+          response.end('test');
+        });
+        const resp = await net.fetch(serverUrl);
+        expect(resp.ok).to.be.true();
+        expect(await resp.text()).to.equal('test');
+      });
+
+      it('can upload a string body', async () => {
+        const serverUrl = await respondOnce.toSingleURL((request, response) => {
+          request.on('data', chunk => response.write(chunk));
+          request.on('end', () => response.end());
+        });
+        const resp = await net.fetch(serverUrl, {
+          method: 'POST',
+          body: 'anchovies'
+        });
+        expect(await resp.text()).to.equal('anchovies');
+      });
+
+      it('can read response as an array buffer', async () => {
+        const serverUrl = await respondOnce.toSingleURL((request, response) => {
+          request.on('data', chunk => response.write(chunk));
+          request.on('end', () => response.end());
+        });
+        const resp = await net.fetch(serverUrl, {
+          method: 'POST',
+          body: 'anchovies'
+        });
+        expect(new TextDecoder().decode(new Uint8Array(await resp.arrayBuffer()))).to.equal('anchovies');
+      });
+
+      it('can read response as form data', async () => {
+        const serverUrl = await respondOnce.toSingleURL((request, response) => {
+          response.setHeader('content-type', 'application/x-www-form-urlencoded');
+          response.end('foo=bar');
+        });
+        const resp = await net.fetch(serverUrl);
+        const result = await resp.formData();
+        expect(result.get('foo')).to.equal('bar');
+      });
+
+      it('should be able to use a session cookie store', async () => {
+        const serverUrl = await respondOnce.toSingleURL((request, response) => {
+          response.statusCode = 200;
+          response.statusMessage = 'OK';
+          response.setHeader('x-cookie', request.headers.cookie!);
+          response.end();
+        });
+        const sess = session.fromPartition(`cookie-tests-${Math.random()}`);
+        const cookieVal = `${Date.now()}`;
+        await sess.cookies.set({
+          url: serverUrl,
+          name: 'wild_cookie',
+          value: cookieVal
+        });
+        const response = await sess.fetch(serverUrl, {
+          credentials: 'include'
+        });
+        expect(response.headers.get('x-cookie')).to.equal(`wild_cookie=${cookieVal}`);
+      });
+
+      it('should reject promise on DNS failure', async () => {
+        const r = net.fetch('https://i.do.not.exist');
+        await expect(r).to.be.rejectedWith(/ERR_NAME_NOT_RESOLVED/);
+      });
+
+      it('should reject body promise when stream fails', async () => {
+        const serverUrl = await respondOnce.toSingleURL((request, response) => {
+          response.write('first chunk');
+          setTimeout(() => response.destroy());
+        });
+        const r = await net.fetch(serverUrl);
+        expect(r.status).to.equal(200);
+        await expect(r.text()).to.be.rejectedWith(/ERR_INCOMPLETE_CHUNKED_ENCODING/);
+      });
+    });
+  });
 });

+ 4 - 2
typings/internal-ambient.d.ts

@@ -130,11 +130,13 @@ declare namespace NodeJS {
     url: string;
     extraHeaders?: Record<string, string>;
     useSessionCookies?: boolean;
-    credentials?: 'include' | 'omit';
+    credentials?: 'include' | 'omit' | 'same-origin';
     body: Uint8Array | BodyFunc;
     session?: Electron.Session;
     partition?: string;
     referrer?: string;
+    referrerPolicy?: string;
+    cache?: string;
     origin?: string;
     hasUserActivation?: boolean;
     mode?: string;
@@ -224,7 +226,7 @@ declare namespace NodeJS {
     _linkedBinding(name: 'electron_browser_power_save_blocker'): { powerSaveBlocker: Electron.PowerSaveBlocker };
     _linkedBinding(name: 'electron_browser_push_notifications'): { pushNotifications: Electron.PushNotifications };
     _linkedBinding(name: 'electron_browser_safe_storage'): { safeStorage: Electron.SafeStorage };
-    _linkedBinding(name: 'electron_browser_session'): typeof Electron.Session;
+    _linkedBinding(name: 'electron_browser_session'): {fromPartition: typeof Electron.Session.fromPartition, Session: typeof Electron.Session};
     _linkedBinding(name: 'electron_browser_screen'): { createScreen(): Electron.Screen };
     _linkedBinding(name: 'electron_browser_system_preferences'): { systemPreferences: Electron.SystemPreferences };
     _linkedBinding(name: 'electron_browser_tray'): { Tray: Electron.Tray };