diff --git a/docs/api/client-request.md b/docs/api/client-request.md index 81410a281816..f1cb4ef2c254 100644 --- a/docs/api/client-request.md +++ b/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 diff --git a/docs/api/net.md b/docs/api/net.md index 2fcf307d752c..f7e7da61940b 100644 --- a/docs/api/net.md +++ b/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` - 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. diff --git a/docs/api/session.md b/docs/api/session.md index ad0cb87ac3fc..85bc096ef89d 100644 --- a/docs/api/session.md +++ b/docs/api/session.md @@ -731,6 +731,43 @@ Returns `Promise` - 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` - 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 diff --git a/filenames.auto.gni b/filenames.auto.gni index 99b2a7437546..fdbd7f0dfd8d 100644 --- a/filenames.auto.gni +++ b/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", diff --git a/lib/browser/api/net-client-request.ts b/lib/browser/api/net-client-request.ts new file mode 100644 index 000000000000..a6ce4210ffdd --- /dev/null +++ b/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 = {}; + 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 } { + 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 } = { + 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 = 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 }; + _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) => { + const ret: Record = {}; + 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 }; + } +} diff --git a/lib/browser/api/net-fetch.ts b/lib/browser/api/net-fetch.ts new file mode 100644 index 000000000000..3b9bcb2be57a --- /dev/null +++ b/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 (): { promise: Promise; resolve: (x: T) => void; reject: (e: E) => void; } { + let res: (x: T) => void; + let rej: (e: E) => void; + const promise = new Promise((resolve, reject) => { + res = resolve; + rej = reject; + }); + + return { promise, resolve: res!, reject: rej! }; +} + +export function fetchWithSession (input: RequestInfo, init: RequestInit | undefined, session: SessionT): Promise { + const p = createDeferredPromise(); + 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; +} diff --git a/lib/browser/api/net.ts b/lib/browser/api/net.ts index 31845c7daf2d..a2a7198143bd 100644 --- a/lib/browser/api/net.ts +++ b/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 = {}; - 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 } { - 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 } = { - 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 = 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 }; - _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) => { - const ret: Record = {}; - 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 { + return session.defaultSession.fetch(input, init); +} + exports.isOnline = isOnline; Object.defineProperty(exports, 'online', { diff --git a/lib/browser/api/session.ts b/lib/browser/api/session.ts index 2f42b60f11c5..8184cd576474 100644 --- a/lib/browser/api/session.ts +++ b/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, diff --git a/shell/browser/api/electron_api_session.cc b/shell/browser/api/electron_api_session.cc index 8cef04382ee5..a16abd3b8230 100644 --- a/shell/browser/api/electron_api_session.cc +++ b/shell/browser/api/electron_api_session.cc @@ -1203,10 +1203,16 @@ gin::Handle Session::FromPartition(v8::Isolate* isolate, return CreateFrom(isolate, browser_context); } -gin::ObjectTemplateBuilder Session::GetObjectTemplateBuilder( - v8::Isolate* isolate) { - return gin_helper::EventEmitterMixin::GetObjectTemplateBuilder( - isolate) +// static +gin::Handle Session::New() { + gin_helper::ErrorThrower(JavascriptEnvironment::GetIsolate()) + .ThrowError("Session objects cannot be created with 'new'"); + return gin::Handle(); +} + +void Session::FillObjectTemplate(v8::Isolate* isolate, + v8::Local 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 exports, void* priv) { v8::Isolate* isolate = context->GetIsolate(); gin_helper::Dictionary dict(isolate, exports); + dict.Set("Session", Session::GetConstructor(context)); dict.SetMethod("fromPartition", &FromPartition); } diff --git a/shell/browser/api/electron_api_session.h b/shell/browser/api/electron_api_session.h index 45f3d82e4f90..df6071def420 100644 --- a/shell/browser/api/electron_api_session.h +++ b/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, public gin_helper::Pinnable, + public gin_helper::Constructible, public gin_helper::EventEmitterMixin, public gin_helper::CleanedUpAtExit, #if BUILDFLAG(ENABLE_BUILTIN_SPELLCHECKER) @@ -71,6 +73,7 @@ class Session : public gin::Wrappable, static gin::Handle CreateFrom( v8::Isolate* isolate, ElectronBrowserContext* browser_context); + static gin::Handle New(); // Dummy, do not use! static Session* FromBrowserContext(content::BrowserContext* context); @@ -83,8 +86,7 @@ class Session : public gin::Wrappable, // gin::Wrappable static gin::WrapperInfo kWrapperInfo; - gin::ObjectTemplateBuilder GetObjectTemplateBuilder( - v8::Isolate* isolate) override; + static void FillObjectTemplate(v8::Isolate*, v8::Local); const char* GetTypeName() override; // Methods. diff --git a/shell/browser/api/electron_api_url_loader.cc b/shell/browser/api/electron_api_url_loader.cc index e8575141e755..a20f335f5446 100644 --- a/shell/browser/api/electron_api_url_loader.cc +++ b/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 { *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 { + static bool FromV8(v8::Isolate* isolate, + v8::Local 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 { + static bool FromV8(v8::Isolate* isolate, + v8::Local 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::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::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; diff --git a/spec/api-net-spec.ts b/spec/api-net-spec.ts index d52efa268867..836ad4f75a25 100644 --- a/spec/api-net-spec.ts +++ b/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/); + }); + }); + }); }); diff --git a/typings/internal-ambient.d.ts b/typings/internal-ambient.d.ts index 1a6df928d8ef..caa73ec1eb8e 100644 --- a/typings/internal-ambient.d.ts +++ b/typings/internal-ambient.d.ts @@ -130,11 +130,13 @@ declare namespace NodeJS { url: string; extraHeaders?: Record; 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 };