Repository: electron/electron
Pull request: feat: [net] add "priority" option to net.request

#### Description of Change

<!--
Thank you for your Pull Request. Please provide a description above and review
the requirements below.

Contributors guide: https://github.com/electron/electron/blob/main/CONTRIBUTING.md
-->
Add the priority and priorityIncremental options to customize the Priority header of requests sent by net.request.
Currently all requests sent by net.request have a priority header with value `u=4, i`, this maybe unexpected. We want it fully customizable and match the Chrome exactly if possible.

Close #46680

#### Checklist
<!-- Remove items that do not apply. For completed items, change [ ] to [x]. -->

- [x] PR description included and stakeholders cc'd
- [x] `npm test` passes
- [x] tests are [changed or added](https://github.com/electron/electron/blob/main/docs/development/testing.md)
- [x] relevant documentation, tutorials, templates and examples are changed or added
- [x] [PR release notes](https://github.com/electron/clerk/blob/main/README.md) describe the change in a way relevant to app developers, and are [capitalized, punctuated, and past tense](https://github.com/electron/clerk/blob/main/README.md#examples).

#### Release Notes

Notes: Added the priority and priorityIncremental options to net.request()


Changed files:
filename: docs/api/client-request.md
patch: @@ -60,6 +60,10 @@ following properties:
     `strict-origin-when-cross-origin`.
   * `cache` string (optional) - can be `default`, `no-store`, `reload`,
     `no-cache`, `force-cache` or `only-if-cached`.
+  * `priority` string (optional) - can be `throttled`, `idle`, `lowest`,
+    `low`, `medium`, or `highest`. Defaults to `idle`.
+  * `priorityIncremental` boolean (optional) - the incremental loading flag as part
+    of HTTP extensible priorities (RFC 9218). Default is `true`.
 
 `options` properties such as `protocol`, `host`, `hostname`, `port` and `path`
 strictly follow the Node.js model as described in the

filename: lib/common/api/net-client-request.ts
patch: @@ -288,8 +288,12 @@ function parseOptions (optionsIn: ClientRequestConstructorOptions | string): Nod
     origin: options.origin,
     referrerPolicy: options.referrerPolicy,
     cache: options.cache,
-    allowNonHttpProtocols: Object.hasOwn(options, kAllowNonHttpProtocols)
+    allowNonHttpProtocols: Object.hasOwn(options, kAllowNonHttpProtocols),
+    priority: options.priority
   };
+  if ('priorityIncremental' in options) {
+    urlLoaderOptions.priorityIncremental = options.priorityIncremental;
+  }
   const headers: Record<string, string | string[]> = options.headers || {};
   for (const [name, value] of Object.entries(headers)) {
     validateHeader(name, value);

filename: shell/common/api/electron_api_url_loader.cc
patch: @@ -638,6 +638,24 @@ gin::Handle<SimpleURLLoaderWrapper> SimpleURLLoaderWrapper::Create(
       break;
   }
 
+  if (std::string priority; opts.Get("priority", &priority)) {
+    static constexpr auto Lookup =
+        base::MakeFixedFlatMap<std::string_view, net::RequestPriority>({
+            {"throttled", net::THROTTLED},
+            {"idle", net::IDLE},
+            {"lowest", net::LOWEST},
+            {"low", net::LOW},
+            {"medium", net::MEDIUM},
+            {"highest", net::HIGHEST},
+        });
+    if (auto iter = Lookup.find(priority); iter != Lookup.end())
+      request->priority = iter->second;
+  }
+  if (bool priorityIncremental = request->priority_incremental;
+      opts.Get("priorityIncremental", &priorityIncremental)) {
+    request->priority_incremental = priorityIncremental;
+  }
+
   const bool use_session_cookies =
       opts.ValueOrDefault("useSessionCookies", false);
   int options = network::mojom::kURLLoadOptionSniffMimeType;

filename: spec/api-net-spec.ts
patch: @@ -1,15 +1,19 @@
-import { net, ClientRequest, ClientRequestConstructorOptions, utilityProcess } from 'electron/main';
+import { net, session, ClientRequest, ClientRequestConstructorOptions, utilityProcess } from 'electron/main';
 
 import { expect } from 'chai';
 
 import { once } from 'node:events';
+import * as fs from 'node:fs';
 import * as http from 'node:http';
+import * as http2 from 'node:http2';
 import * as path from 'node:path';
 import { setTimeout } from 'node:timers/promises';
 
 import { collectStreamBody, collectStreamBodyBuffer, getResponse, kOneKiloByte, kOneMegaByte, randomBuffer, randomString, respondNTimes, respondOnce } from './lib/net-helpers';
+import { listen, defer } from './lib/spec-helpers';
 
 const utilityFixturePath = path.resolve(__dirname, 'fixtures', 'api', 'utility-process', 'api-net-spec.js');
+const fixturesPath = path.resolve(__dirname, 'fixtures');
 
 async function itUtility (name: string, fn?: Function, args?: {[key:string]: any}) {
   it(`${name} in utility process`, async () => {
@@ -46,6 +50,34 @@ describe('net module', () => {
     }
   });
 
+  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 if (req.method === 'GET' && req.headers[':path'] === '/get') {
+      res.end(JSON.stringify({
+        headers: req.headers
+      }));
+    } else {
+      res.end('<html></html>');
+    }
+  });
+
+  before(async () => {
+    http2URL = (await listen(h2server)).url + '/';
+  });
+
+  after(() => {
+    h2server.close();
+  });
+
   for (const test of [itIgnoringArgs, itUtility]) {
     describe('HTTP basics', () => {
       test('should be able to issue a basic GET request', async () => {
@@ -1615,4 +1647,45 @@ describe('net module', () => {
       });
     });
   }
+
+  for (const test of [itIgnoringArgs]) {
+    describe('ClientRequest API', () => {
+      for (const [priorityName, urgency] of Object.entries({
+        throttled: 'u=5',
+        idle: 'u=4',
+        lowest: '',
+        low: 'u=2',
+        medium: 'u=1',
+        highest: 'u=0'
+      })) {
+        for (const priorityIncremental of [true, false]) {
+          test(`should set priority to ${priorityName}/${priorityIncremental} if requested`, async () => {
+            // Priority header is available on HTTP/2, which is only
+            // supported over TLS, so...
+            session.defaultSession.setCertificateVerifyProc((req, cb) => cb(0));
+            defer(() => {
+              session.defaultSession.setCertificateVerifyProc(null);
+            });
+
+            const urlRequest = net.request({
+              url: `${http2URL}get`,
+              priority: priorityName as any,
+              priorityIncremental
+            });
+            const response = await getResponse(urlRequest);
+            const data = JSON.parse(await collectStreamBody(response));
+            let expectedPriority = urgency;
+            if (priorityIncremental) {
+              expectedPriority = expectedPriority ? expectedPriority + ', i' : 'i';
+            }
+            if (expectedPriority === '') {
+              expect(data.headers.priority).to.be.undefined();
+            } else {
+              expect(data.headers.priority).to.be.a('string').and.equal(expectedPriority);
+            }
+          }, { priorityName, urgency, priorityIncremental });
+        }
+      }
+    });
+  }
 });

filename: typings/internal-ambient.d.ts
patch: @@ -177,6 +177,8 @@ declare namespace NodeJS {
     mode?: string;
     destination?: string;
     bypassCustomProtocolHandlers?: boolean;
+    priority?: 'throttled' | 'idle' | 'lowest' | 'low' | 'medium' | 'highest';
+    priorityIncremental?: boolean;
   };
   type ResponseHead = {
     statusCode: number;