diff --git a/lib/fetch/constants.js b/lib/fetch/constants.js index 2eff7596968..75c9265e8c1 100644 --- a/lib/fetch/constants.js +++ b/lib/fetch/constants.js @@ -86,9 +86,12 @@ const subresource = [ '' ] +const corsSafeListedResponseHeaderNames = [] // TODO + module.exports = { subresource, forbiddenResponseHeaderNames, + corsSafeListedResponseHeaderNames, forbiddenMethods, requestBodyHeader, referrerPolicy, diff --git a/lib/fetch/headers.js b/lib/fetch/headers.js index 7a59f15371e..a5dd5b7a413 100644 --- a/lib/fetch/headers.js +++ b/lib/fetch/headers.js @@ -346,11 +346,12 @@ class Headers { const callback = args[0] const thisArg = args[1] - for (let index = 0; index < this[kHeadersList].length; index += 2) { + const clone = this[kHeadersList].slice() + for (let index = 0; index < clone.length; index += 2) { callback.call( thisArg, - this[kHeadersList][index + 1], - this[kHeadersList][index], + clone[index + 1], + clone[index], this ) } diff --git a/lib/fetch/index.js b/lib/fetch/index.js index 8cd7219594e..945a609985f 100644 --- a/lib/fetch/index.js +++ b/lib/fetch/index.js @@ -5,6 +5,7 @@ const { Response, makeNetworkError, + makeAppropriateNetworkError, filterResponse, makeResponse } = require('./response') @@ -33,7 +34,9 @@ const { createDeferredPromise, isBlobLike, CORBCheck, - sameOrigin + sameOrigin, + isCancelled, + isAborted } = require('./util') const { kState, kHeaders, kGuard, kRealm } = require('./symbols') const { AbortError } = require('../core/errors') @@ -52,6 +55,7 @@ const { Readable, pipeline } = require('stream') const { isErrored, isReadable } = require('../core/util') const { dataURLProcessor } = require('./dataURL') const { kIsMockActive } = require('../mock/mock-symbols') +const { TransformStream } = require('stream/web') /** @type {import('buffer').resolveObjectURL} */ let resolveObjectURL @@ -62,19 +66,30 @@ class Fetch extends EE { super() this.dispatcher = dispatcher - this.terminated = null this.connection = null this.dump = false + this.state = 'ongoing' } - terminate ({ reason, aborted } = {}) { - if (this.terminated) { + terminate (reason) { + if (this.state !== 'ongoing') { return } - this.terminated = { aborted, reason } + this.state = 'terminated' this.connection?.destroy(reason) + this.emit('terminated', reason) + } + + abort () { + if (this.state !== 'ongoing') { + return + } + + const reason = new AbortError() + this.state = 'aborted' + this.connection?.destroy(reason) this.emit('terminated', reason) } } @@ -99,8 +114,6 @@ async function fetch (...args) { const resource = args[0] const init = args.length >= 1 ? args[1] ?? {} : {} - const context = new Fetch(this) - // 1. Let p be a new promise. const p = createDeferredPromise() @@ -115,7 +128,7 @@ async function fetch (...args) { // 4. If requestObject’s signal’s aborted flag is set, then: if (requestObject.signal.aborted) { // 1. Abort fetch with p, request, and null. - abortFetch.call(context, p, request, null) + abortFetch(p, request, null) // 2. Return p. return p.promise @@ -140,7 +153,10 @@ async function fetch (...args) { // 9. Let locallyAborted be false. let locallyAborted = false - // 10. Add the following abort steps to requestObject’s signal: + // 10. Let controller be null. + let controller = null + + // 11. Add the following abort steps to requestObject’s signal: requestObject.signal.addEventListener( 'abort', () => { @@ -148,21 +164,25 @@ async function fetch (...args) { locallyAborted = true // 2. Abort fetch with p, request, and responseObject. - abortFetch.call(context, p, request, responseObject) + abortFetch(p, request, responseObject) - // 3. Terminate the ongoing fetch with the aborted flag set. - context.terminate({ aborted: true }) + // 3. If controller is not null, then abort controller. + if (controller != null) { + controller.abort() + } }, { once: true } ) - // 11. Let handleFetchDone given response response be to finalize and + // 12. Let handleFetchDone given response response be to finalize and // report timing with response, globalObject, and "fetch". const handleFetchDone = (response) => finalizeAndReportTiming(response, 'fetch') - // 12. Fetch request with processResponseEndOfBody set to handleFetchDone, - // and processResponse given response being these substeps: + // 13. Set controller to the result of calling fetch given request, + // with processResponseEndOfBody set to handleFetchDone, and processResponse + // given response being these substeps: + const processResponse = (response) => { // 1. If locallyAborted is true, terminate these substeps. if (locallyAborted) { @@ -172,7 +192,7 @@ async function fetch (...args) { // 2. If response’s aborted flag is set, then abort fetch with p, // request, and responseObject, and terminate these substeps. if (response.aborted) { - abortFetch.call(context, p, request, responseObject) + abortFetch(p, request, responseObject) return } @@ -198,17 +218,14 @@ async function fetch (...args) { p.resolve(responseObject) } - fetching - .call(context, { - request, - processResponseEndOfBody: handleFetchDone, - processResponse - }) - .catch((err) => { - p.reject(err) - }) + controller = fetching({ + request, + processResponseEndOfBody: handleFetchDone, + processResponse, + dispatcher: this // undici + }) - // 13. Return p. + // 14. Return p. return p.promise } @@ -329,7 +346,8 @@ function fetching ({ processResponse, processResponseEndOfBody, processResponseConsumeBody, - useParallelQueue = false + useParallelQueue = false, + dispatcher // undici }) { // 1. Let taskDestination be null. let taskDestination = null @@ -371,6 +389,7 @@ function fetching ({ // task destination is taskDestination, // and cross-origin isolated capability is crossOriginIsolatedCapability. const fetchParams = { + controller: new Fetch(dispatcher), request, timingInfo, processRequestBodyChunkLength, @@ -406,7 +425,10 @@ function fetching ({ request.origin = request.client?.origin } - // 10. If request’s policy container is "client", then: + // 10. If all of the following conditions are true: + // TODO + + // 11. If request’s policy container is "client", then: if (request.policyContainer === 'client') { // 1. If request’s client is non-null, then set request’s policy // container to a clone of request’s client’s policy container. [HTML] @@ -421,7 +443,7 @@ function fetching ({ } } - // 11. If request’s header list does not contain `Accept`, then: + // 12. If request’s header list does not contain `Accept`, then: if (!request.headersList.has('accept')) { // 1. Let value be `*/*`. const value = '*/*' @@ -442,38 +464,37 @@ function fetching ({ request.headersList.append('accept', value) } - // 12. If request’s header list does not contain `Accept-Language`, then + // 13. If request’s header list does not contain `Accept-Language`, then // user agents should append `Accept-Language`/an appropriate value to // request’s header list. if (!request.headersList.has('accept-language')) { request.headersList.append('accept-language', '*') } - // 13. If request’s priority is null, then use request’s initiator and + // 14. If request’s priority is null, then use request’s initiator and // destination appropriately in setting request’s priority to a // user-agent-defined object. if (request.priority === null) { // TODO } - // 14. If request is a subresource request, then: + // 15. If request is a subresource request, then: if (subresource.includes(request.destination)) { - // 1. Let record be a new fetch record consisting of request and this - // instance of the fetch algorithm. - // TODO - // 2. Append record to request’s client’s fetch group list of fetch - // records. // TODO } - // 15. Run main fetch given fetchParams. - return mainFetch.call(this, fetchParams) + // 16. Run main fetch given fetchParams. + mainFetch(fetchParams) + .catch(err => { + fetchParams.controller.terminate(err) + }) + + // 17. Return fetchParam's controller + return fetchParams.controller } // https://fetch.spec.whatwg.org/#concept-main-fetch async function mainFetch (fetchParams, recursive = false) { - const context = this - // 1. Let request be fetchParams’s request. const request = fetchParams.request @@ -548,8 +569,7 @@ async function mainFetch (fetchParams, recursive = false) { request.responseTainting = 'basic' // 2. Return the result of running scheme fetch given fetchParams. - return await schemeFetch - .call(this, fetchParams) + return await schemeFetch(fetchParams) } // request’s mode is "same-origin" @@ -573,8 +593,7 @@ async function mainFetch (fetchParams, recursive = false) { // 3. Let noCorsResponse be the result of running scheme fetch given // fetchParams. - const noCorsResponse = await schemeFetch - .call(this, fetchParams) + const noCorsResponse = await schemeFetch(fetchParams) // 4. If noCorsResponse is a filtered response or the CORB check with // request and noCorsResponse returns allowed, then return noCorsResponse. @@ -609,9 +628,7 @@ async function mainFetch (fetchParams, recursive = false) { request.responseTainting = 'cors' // 2. Return the result of running HTTP fetch given fetchParams. - return await httpFetch - .call(this, fetchParams) - .catch((err) => makeNetworkError(err)) + return await httpFetch(fetchParams) })() } @@ -699,7 +716,7 @@ async function mainFetch (fetchParams, recursive = false) { nullBodyStatus.includes(internalResponse.status)) ) { internalResponse.body = null - context.dump = true + fetchParams.controller.dump = true } // 20. If request’s integrity metadata is not the empty string, then: @@ -707,7 +724,7 @@ async function mainFetch (fetchParams, recursive = false) { // 1. Let processBodyError be this step: run fetch finale given fetchParams // and a network error. const processBodyError = (reason) => - fetchFinale.call(context, fetchParams, makeNetworkError(reason)) + fetchFinale(fetchParams, makeNetworkError(reason)) // 2. If request’s response tainting is "opaque", or response’s body is null, // then run processBodyError and abort these steps. @@ -730,7 +747,7 @@ async function mainFetch (fetchParams, recursive = false) { response.body = safelyExtractBody(bytes)[0] // 3. Run fetch finale given fetchParams and response. - fetchFinale.call(context, fetchParams, response) + fetchFinale(fetchParams, response) } // 4. Fully read response’s body given processBody and processBodyError. @@ -741,15 +758,13 @@ async function mainFetch (fetchParams, recursive = false) { } } else { // 21. Otherwise, run fetch finale given fetchParams and response. - fetchFinale.call(context, fetchParams, response) + fetchFinale(fetchParams, response) } } // https://fetch.spec.whatwg.org/#concept-scheme-fetch // given a fetch params fetchParams async function schemeFetch (fetchParams) { - const context = this - // let request be fetchParams’s request const { request } = fetchParams @@ -782,12 +797,10 @@ async function schemeFetch (fetchParams) { case 'blob:': { resolveObjectURL ??= require('buffer').resolveObjectURL - context.on('terminated', onRequestAborted) - // 1. Run these steps, but abort when the ongoing fetch is terminated: - // 1a. Let blob be request’s current URL’s blob URL entry’s object. - // https://w3c.github.io/FileAPI/#blob-url-entry - // P.S. Thank God this method is available in node. + // 1. Let blob be request’s current URL’s blob URL entry’s object. + // https://w3c.github.io/FileAPI/#blob-url-entry + // P.S. Thank God this method is available in node. const currentURL = requestCurrentURL(request) // https://github.com/web-platform-tests/wpt/blob/7b0ebaccc62b566a1965396e5be7bb2bc06f841f/FileAPI/url/resources/fetch-tests.js#L52-L56 @@ -798,42 +811,29 @@ async function schemeFetch (fetchParams) { const blob = resolveObjectURL(currentURL.toString()) - // 2a. If request’s method is not `GET` or blob is not a Blob object, then return a network error. [FILEAPI] + // 2. If request’s method is not `GET` or blob is not a Blob object, then return a network error. [FILEAPI] if (request.method !== 'GET' || !isBlobLike(blob)) { return makeNetworkError('invalid method') } - // 3a. Let response be a new response whose status message is `OK`. + // 3. Let response be a new response whose status message is `OK`. const response = makeResponse({ statusText: 'OK', urlList: [currentURL] }) - // 4a. Append (`Content-Length`, blob’s size attribute value) to response’s header list. + // 4. Append (`Content-Length`, blob’s size attribute value) to response’s header list. response.headersList.set('content-length', `${blob.size}`) - // 5a. Append (`Content-Type`, blob’s type attribute value) to response’s header list. + // 5. Append (`Content-Type`, blob’s type attribute value) to response’s header list. response.headersList.set('content-type', blob.type) - // 6a. Set response’s body to the result of performing the read operation on blob. + // 6. Set response’s body to the result of performing the read operation on blob. + // TODO (fix): This needs to read? response.body = extractBody(blob)[0] - // since the request has not been aborted, we can safely remove the listener. - context.off('terminated', onRequestAborted) - - // 7a. Return response. + // 7. Return response. return response - // 2. If aborted, then: - function onRequestAborted () { - // 1. Let aborted be the termination’s aborted flag. - const aborted = context.terminated.aborted - - // 2. If aborted is set, then return an aborted network error. - if (aborted) { - return makeNetworkError(new AbortError()) - } - - // 3. Return a network error. - return makeNetworkError(context.terminated.reason) - } + // 2. If aborted, then return the appropriate network error for fetchParams. + // TODO } case 'data:': { // 1. Let dataURLStruct be the result of running the @@ -876,7 +876,7 @@ async function schemeFetch (fetchParams) { headersList: [ 'content-type', contentType ], - body: dataURLStruct.body + body: extractBody(dataURLStruct.body)[0] }) } case 'file:': { @@ -888,8 +888,7 @@ async function schemeFetch (fetchParams) { case 'https:': { // Return the result of running HTTP fetch given fetchParams. - return await httpFetch - .call(this, fetchParams) + return await httpFetch(fetchParams) .catch((err) => makeNetworkError(err)) } default: { @@ -907,14 +906,12 @@ function finalizeResponse (fetchParams, response) { // task to run fetchParams’s process response done given response, with // fetchParams’s task destination. if (fetchParams.processResponseDone != null) { - fetchParams.processResponseDone(response) + queueMicrotask(() => fetchParams.processResponseDone(response)) } } // https://fetch.spec.whatwg.org/#fetch-finale -function fetchFinale (fetchParams, response) { - const context = this - +async function fetchFinale (fetchParams, response) { // 1. If response is a network error, then: if (response.type === 'error') { // 1. Set response’s URL list to « fetchParams’s request’s URL list[0] ». @@ -928,40 +925,79 @@ function fetchFinale (fetchParams, response) { } // 2. Let processResponseEndOfBody be the following steps: - // TODO + const processResponseEndOfBody = () => { + // 1. Set fetchParams’s request’s done flag. + fetchParams.request.done = true + + // If fetchParams’s process response end-of-body is not null, + // then queue a fetch task to run fetchParams’s process response + // end-of-body given response with fetchParams’s task destination. + if (fetchParams.processResponseEndOfBody != null) { + queueMicrotask(() => fetchParams.processResponseEndOfBody(response)) + } + } // 3. If fetchParams’s process response is non-null, then queue a fetch task // to run fetchParams’s process response given response, with fetchParams’s // task destination. if (fetchParams.processResponse != null) { - fetchParams.processResponse(response) + queueMicrotask(() => fetchParams.processResponse(response)) } - // 4. If fetchParams’s process response is non-null, then queue a fetch task - // to run fetchParams’s process response given response, with fetchParams’s - // task destination. - // TODO + // 4. If response’s body is null, then run processResponseEndOfBody. + if (response.body == null) { + processResponseEndOfBody() + } else { + // 5. Otherwise: - // 5. If response’s body is null, then run processResponseEndOfBody. - // TODO + // 1. Let transformStream be a new a TransformStream. - // 6. Otherwise: - // TODO + // 2. Let identityTransformAlgorithm be an algorithm which, given chunk, + // enqueues chunk in transformStream. + const identityTransformAlgorithm = (chunk, controller) => { + controller.enqueue(chunk) + } - // 7. If fetchParams’s process response consume body is non-null, then: - // TODO + // 3. Set up transformStream with transformAlgorithm set to identityTransformAlgorithm + // and flushAlgorithm set to processResponseEndOfBody. + const transformStream = new TransformStream({ + start () {}, + transform: identityTransformAlgorithm, + flush: processResponseEndOfBody + }) - // TODO: This is a workaround. Until the above has been implemented, i.e. - // we need to either fully consume the body or terminate the fetch. - if (response.type === 'error') { - context.terminate({ reason: response.error }) + // 4. Set response’s body to the result of piping response’s body through transformStream. + response.body = { stream: response.body.stream.pipeThrough(transformStream) } + } + + // 6. If fetchParams’s process response consume body is non-null, then: + if (fetchParams.processResponseConsumeBody != null) { + // 1. Let processBody given nullOrBytes be this step: run fetchParams’s + // process response consume body given response and nullOrBytes. + const processBody = (nullOrBytes) => fetchParams.processResponseConsumeBody(response, nullOrBytes) + + // 2. Let processBodyError be this step: run fetchParams’s process + // response consume body given response and failure. + const processBodyError = (failure) => fetchParams.processResponseConsumeBody(response, failure) + + // 3. If response’s body is null, then queue a fetch task to run processBody + // given null, with fetchParams’s task destination. + if (response.body == null) { + queueMicrotask(() => processBody(null)) + } else { + // 4. Otherwise, fully read response’s body given processBody, processBodyError, + // and fetchParams’s task destination. + try { + processBody(await response.body.stream.arrayBuffer()) + } catch (err) { + processBodyError(err) + } + } } } // https://fetch.spec.whatwg.org/#http-fetch async function httpFetch (fetchParams) { - const context = this - // 1. Let request be fetchParams’s request. const request = fetchParams.request @@ -992,10 +1028,7 @@ async function httpFetch (fetchParams) { // 3. Set response and actualResponse to the result of running // HTTP-network-or-cache fetch given fetchParams. - actualResponse = response = await httpNetworkOrCacheFetch.call( - this, - fetchParams - ) + actualResponse = response = await httpNetworkOrCacheFetch(fetchParams) // 4. If request’s response tainting is "cors" and a CORS check // for request and response returns failure, then return a network error. @@ -1035,7 +1068,7 @@ async function httpFetch (fetchParams) { // and the connection uses HTTP/2, then user agents may, and are even // encouraged to, transmit an RST_STREAM frame. // See, https://github.com/whatwg/fetch/issues/1288 - context.connection.destroy() + fetchParams.controller.connection.destroy() // 2. Switch on request’s redirect mode: if (request.redirect === 'error') { @@ -1051,7 +1084,7 @@ async function httpFetch (fetchParams) { } else if (request.redirect === 'follow') { // Set response to the result of running HTTP-redirect fetch given // fetchParams and response. - response = await httpRedirectFetch.call(this, fetchParams, response) + response = await httpRedirectFetch(fetchParams, response) } else { assert(false) } @@ -1190,7 +1223,7 @@ async function httpRedirectFetch (fetchParams, response) { setRequestReferrerPolicyOnRedirect(request, actualResponse) // 19. Return the result of running main fetch given fetchParams and true. - return mainFetch.call(this, fetchParams, true) + return mainFetch(fetchParams, true) } // https://fetch.spec.whatwg.org/#http-network-or-cache-fetch @@ -1199,8 +1232,6 @@ async function httpNetworkOrCacheFetch ( isAuthenticationFetch = false, isNewConnectionFetch = false ) { - const context = this - // 1. Let request be fetchParams’s request. const request = fetchParams.request @@ -1409,8 +1440,7 @@ async function httpNetworkOrCacheFetch ( // 2. Let forwardResponse be the result of running HTTP-network fetch // given httpFetchParams, includeCredentials, and isNewConnectionFetch. - const forwardResponse = await httpNetworkFetch.call( - this, + const forwardResponse = await httpNetworkFetch( httpFetchParams, includeCredentials, isNewConnectionFetch @@ -1471,18 +1501,9 @@ async function httpNetworkOrCacheFetch ( // 2. ??? - // 3. If the ongoing fetch is terminated, then: - if (context.terminated) { - // 1. Let aborted be the termination’s aborted flag. - const aborted = context.terminated.aborted - - // 2. If aborted is set, then return an aborted network error. - if (aborted) { - return makeNetworkError(new AbortError()) - } - - // 3. Return a network error. - return makeNetworkError(context.terminated.reason) + // 3. If fetchParams is canceled, then return the appropriate network error for fetchParams. + if (isCancelled(fetchParams)) { + return makeAppropriateNetworkError(fetchParams) } // 4. Prompt the end user as appropriate in request’s window and store @@ -1506,18 +1527,9 @@ async function httpNetworkOrCacheFetch ( ) { // then: - // 1. If the ongoing fetch is terminated, then: - if (context.terminated) { - // 1. Let aborted be the termination’s aborted flag. - const aborted = context.terminated.aborted - - // 2. If aborted is set, then return an aborted network error. - if (aborted) { - return makeNetworkError(new AbortError()) - } - - // 3. Return a network error. - return makeNetworkError(context.terminated.reason) + // 1. If fetchParams is canceled, then return the appropriate network error for fetchParams. + if (isCancelled(fetchParams)) { + return makeAppropriateNetworkError(fetchParams) } // 2. Set response to the result of running HTTP-network-or-cache @@ -1526,10 +1538,9 @@ async function httpNetworkOrCacheFetch ( // TODO (spec): The spec doesn't specify this but we need to cancel // the active response before we can start a new one. // https://github.com/whatwg/fetch/issues/1293 - context.connection.destroy() + fetchParams.controller.connection.destroy() - response = await httpNetworkOrCacheFetch.call( - this, + response = await httpNetworkOrCacheFetch( fetchParams, isAuthenticationFetch, true @@ -1551,11 +1562,9 @@ async function httpNetworkFetch ( includeCredentials = false, forceNewConnection = false ) { - const context = this - - assert(!context.connection || context.connection.destroyed) + assert(!fetchParams.controller.connection || fetchParams.controller.connection.destroyed) - context.connection = { + fetchParams.controller.connection = { abort: null, destroyed: false, destroy (err) { @@ -1659,21 +1668,18 @@ async function httpNetworkFetch ( // To transmit request’s body body, run these steps: const requestBody = (async function * () { - try { - // 1. If body is null and fetchParams’s process request end-of-body is - // non-null, then queue a fetch task given fetchParams’s process request - // end-of-body and fetchParams’s task destination. - if (request.body === null) { - fetchParams.processEndOfBody?.() - return - } - + // 1. If body is null and fetchParams’s process request end-of-body is + // non-null, then queue a fetch task given fetchParams’s process request + // end-of-body and fetchParams’s task destination. + if (request.body == null && fetchParams.processRequestEndOfBody) { + queueMicrotask(() => fetchParams.processRequestEndOfBody()) + } else if (request.body != null) { // 2. Otherwise, if body is non-null: // 1. Let processBodyChunk given bytes be these steps: - for await (const bytes of request.body.stream) { + const processBodyChunk = async function * (bytes) { // 1. If the ongoing fetch is terminated, then abort these steps. - if (context.terminated) { + if (isCancelled(fetchParams)) { return } @@ -1682,33 +1688,48 @@ async function httpNetworkFetch ( // 3. If fetchParams’s process request body is non-null, then run // fetchParams’s process request body given bytes’s length. - fetchParams.processRequestBody?.(bytes.byteLength) + fetchParams.processRequestBodyChunkLength?.(bytes.byteLength) } // 2. Let processEndOfBody be these steps: + const processEndOfBody = () => { + // 1. If fetchParams is canceled, then abort these steps. + if (isCancelled(fetchParams)) { + return + } - // 1. If the ongoing fetch is terminated, then abort these steps. - if (context.terminated) { - return + // 2. If fetchParams’s process request end-of-body is non-null, + // then run fetchParams’s process request end-of-body. + if (fetchParams.processRequestEndOfBody) { + fetchParams.processRequestEndOfBody() + } } - // 2. If fetchParams’s process request end-of-body is non-null, - // then run fetchParams’s process request end-of-body. - fetchParams.processRequestEndOfBody?.() - } catch (e) { // 3. Let processBodyError given e be these steps: + const processBodyError = (e) => { + // 1. If fetchParams is canceled, then abort these steps. + if (isCancelled(fetchParams)) { + return + } - // 1. If the ongoing fetch is terminated, then abort these steps. - if (context.terminated) { - return + // 2. If e is an "AbortError" DOMException, then abort fetchParams’s controller. + if (e.name === 'AbortError') { + fetchParams.controller.abort() + } else { + fetchParams.controller.terminate(e) + } } - // 2. If e is an "AbortError" DOMException, then terminate the ongoing fetch with the aborted flag set. - // 3. Otherwise, terminate the ongoing fetch. - context.terminate({ - aborted: e.name === 'AbortError', - reason: e - }) + // 4. Incrementally read request’s body given processBodyChunk, processEndOfBody, + // processBodyError, and fetchParams’s task destination. + try { + for await (const bytes of request.body.stream) { + yield * processBodyChunk(bytes) + } + processEndOfBody() + } catch (err) { + processBodyError(err) + } } })() @@ -1716,25 +1737,17 @@ async function httpNetworkFetch ( const { body, status, statusText, headersList } = await dispatch({ body: requestBody }) const iterator = body[Symbol.asyncIterator]() - context.next = () => iterator.next() + fetchParams.controller.next = () => iterator.next() response = makeResponse({ status, statusText, headersList }) } catch (err) { // 10. If aborted, then: if (err.name === 'AbortError') { - // 1. Let aborted be the termination’s aborted flag. - const aborted = this.terminated.aborted - - // 2. If connection uses HTTP/2, then transmit an RST_STREAM frame. - this.connection.destroy() + // 1. If connection uses HTTP/2, then transmit an RST_STREAM frame. + fetchParams.controller.connection.destroy() - // 3. If aborted is set, then return an aborted network error. - if (aborted) { - return makeNetworkError(new AbortError()) - } - - // 4. Return a network error. - return makeNetworkError(this.terminated.reason) + // 2. Return the appropriate network error for fetchParams. + return makeAppropriateNetworkError(fetchParams) } return makeNetworkError(err) @@ -1743,13 +1756,13 @@ async function httpNetworkFetch ( // 11. Let pullAlgorithm be an action that resumes the ongoing fetch // if it is suspended. const pullAlgorithm = () => { - context.resume() + fetchParams.controller.resume() } - // 12. Let cancelAlgorithm be an action that terminates the ongoing - // fetch with the aborted flag set. + // 12. Let cancelAlgorithm be an algorithm that aborts fetchParams’s + // controller. const cancelAlgorithm = () => { - context.terminate({ aborted: true }) + fetchParams.controller.abort() } // 13. Let highWaterMark be a non-negative, non-NaN number, chosen by @@ -1771,7 +1784,7 @@ async function httpNetworkFetch ( const stream = new ReadableStream( { async start (controller) { - context.controller = controller + fetchParams.controller.controller = controller }, async pull (controller) { await pullAlgorithm(controller) @@ -1805,8 +1818,8 @@ async function httpNetworkFetch ( // 19. Run these steps in parallel: // 1. Run these steps, but abort when fetchParams is canceled: - context.on('terminated', onAborted) - context.resume = async () => { + fetchParams.controller.on('terminated', onAborted) + fetchParams.controller.resume = async () => { // 1. While true while (true) { // 1-3. See onData... @@ -1815,10 +1828,10 @@ async function httpNetworkFetch ( // codings and bytes. let bytes try { - const { done, value } = await context.next() + const { done, value } = await fetchParams.controller.next() bytes = done ? undefined : value } catch (err) { - if (context.ended && !timingInfo.encodedBodySize) { + if (fetchParams.controller.ended && !timingInfo.encodedBodySize) { // zlib doesn't like empty streams. bytes = undefined } else { @@ -1831,7 +1844,14 @@ async function httpNetworkFetch ( // body is done normally and stream is readable, then close // stream, finalize response for fetchParams and response, and // abort these in-parallel steps. - context.controller.close() + try { + fetchParams.controller.controller.close() + } catch (err) { + // TODO (fix): How/Why can this happen? Do we have a bug? + if (!/Controller is already closed/.test(err)) { + throw err + } + } finalizeResponse(fetchParams, response) @@ -1841,25 +1861,25 @@ async function httpNetworkFetch ( // 5. Increase timingInfo’s decoded body size by bytes’s length. timingInfo.decodedBodySize += bytes?.byteLength ?? 0 - // 6. If bytes is failure, then terminate the ongoing fetch. + // 6. If bytes is failure, then terminate fetchParams’s controller. if (bytes instanceof Error) { - context.terminate({ reason: bytes }) + fetchParams.controller.terminate(bytes) return } // 7. Enqueue a Uint8Array wrapping an ArrayBuffer containing bytes // into stream. - context.controller.enqueue(new Uint8Array(bytes)) + fetchParams.controller.controller.enqueue(new Uint8Array(bytes)) // 8. If stream is errored, then terminate the ongoing fetch. if (isErrored(stream)) { - context.terminate() + fetchParams.controller.terminate() return } // 9. If stream doesn’t need more data ask the user agent to suspend // the ongoing fetch. - if (!context.controller.desiredSize) { + if (!fetchParams.controller.controller.desiredSize) { return } } @@ -1867,22 +1887,19 @@ async function httpNetworkFetch ( // 2. If aborted, then: function onAborted (reason) { - // 1. Let aborted be the termination’s aborted flag. - const aborted = this.terminated.aborted - - // 2. If aborted is set, then: - if (aborted) { + // 2. If fetchParams is aborted, then: + if (isAborted(fetchParams)) { // 1. Set response’s aborted flag. response.aborted = true // 2. If stream is readable, error stream with an "AbortError" DOMException. if (isReadable(stream)) { - this.controller.error(new AbortError()) + fetchParams.controller.controller.error(new AbortError()) } } else { // 3. Otherwise, if stream is readable, error stream with a TypeError. if (isReadable(stream)) { - this.controller.error(new TypeError('terminated', { + fetchParams.controller.controller.error(new TypeError('terminated', { cause: reason instanceof Error ? reason : undefined })) } @@ -1890,7 +1907,7 @@ async function httpNetworkFetch ( // 4. If connection uses HTTP/2, then transmit an RST_STREAM frame. // 5. Otherwise, the user agent should close connection unless it would be bad for performance to do so. - this.connection.destroy() + fetchParams.controller.connection.destroy() } // 20. Return response. @@ -1898,28 +1915,27 @@ async function httpNetworkFetch ( async function dispatch ({ body }) { const url = requestCurrentURL(request) - return new Promise((resolve, reject) => context.dispatcher.dispatch( + return new Promise((resolve, reject) => fetchParams.controller.dispatcher.dispatch( { path: url.pathname + url.search, origin: url.origin, method: request.method, - body: context.dispatcher[kIsMockActive] ? request.body && request.body.source : body, + body: fetchParams.controller.dispatcher[kIsMockActive] ? request.body && request.body.source : body, headers: request.headersList, maxRedirections: 0 }, { body: null, abort: null, - context, onConnect (abort) { // TODO (fix): Do we need connection here? - const { connection } = this.context + const { connection } = fetchParams.controller if (connection.destroyed) { abort(new AbortError()) } else { - context.on('terminated', abort) + fetchParams.controller.on('terminated', abort) this.abort = connection.abort = abort } }, @@ -1974,7 +1990,7 @@ async function httpNetworkFetch ( }, onData (chunk) { - if (this.context.dump) { + if (fetchParams.controller.dump) { return } @@ -1998,22 +2014,22 @@ async function httpNetworkFetch ( onComplete () { if (this.abort) { - context.off('terminated', this.abort) + fetchParams.controller.off('terminated', this.abort) } - context.ended = true + fetchParams.controller.ended = true this.body.push(null) }, onError (error) { if (this.abort) { - context.off('terminated', this.abort) + fetchParams.controller.off('terminated', this.abort) } this.body?.destroy(error) - this.context.terminate({ reason: error }) + fetchParams.controller.terminate(error) reject(makeNetworkError(error)) } diff --git a/lib/fetch/response.js b/lib/fetch/response.js index 00aa439c7dd..64fc3170dac 100644 --- a/lib/fetch/response.js +++ b/lib/fetch/response.js @@ -1,14 +1,16 @@ 'use strict' const { Headers, HeadersList, fill } = require('./headers') +const { AbortError } = require('../core/errors') const { extractBody, cloneBody, mixinBody } = require('./body') const util = require('../core/util') const { kEnumerableProperty } = util -const { responseURL, isValidReasonPhrase, toUSVString } = require('./util') +const { responseURL, isValidReasonPhrase, toUSVString, isCancelled, isAborted } = require('./util') const { redirectStatus, nullBodyStatus, - forbiddenResponseHeaderNames + forbiddenResponseHeaderNames, + corsSafeListedResponseHeaderNames } = require('./constants') const { kState, kHeaders, kGuard, kRealm } = require('./symbols') const { kHeadersList } = require('../core/symbols') @@ -337,7 +339,6 @@ function cloneResponse (response) { function makeResponse (init) { return { - internalResponse: null, aborted: false, rangeRequested: false, timingAllowPassed: false, @@ -369,6 +370,48 @@ function makeNetworkError (reason) { }) } +function makeFilteredResponse (response, state) { + state = { + internalResponse: response, + ...state + } + + return new Proxy(response, { + get (target, p) { + return p in state ? state[p] : target[p] + }, + set (target, p, value) { + assert(!(p in state)) + target[p] = value + return true + } + }) +} + +function makeFilteredHeadersList (headersList, filter) { + return new Proxy(headersList, { + get (target, prop) { + // Override methods used by Headers class. + if (prop === 'get' || prop === 'has') { + return (name) => filter(name) ? target[prop](name) : undefined + } else if (prop === 'slice') { + return (...args) => { + assert(args.length === 0) + const arr = [] + for (let index = 0; index < target.length; index += 2) { + if (filter(target[index])) { + arr.push(target[index], target[index + 1]) + } + } + return arr + } + } else { + return target[prop] + } + } + }) +} + // https://fetch.spec.whatwg.org/#concept-filtered-response function filterResponse (response, type) { // Set response to the following filtered response with response as its @@ -378,18 +421,9 @@ function filterResponse (response, type) { // and header list excludes any headers in internal response’s header list // whose name is a forbidden response-header name. - const headers = [] - for (let n = 0; n < response.headersList.length; n += 2) { - if (!forbiddenResponseHeaderNames.includes(response.headersList[n])) { - headers.push(response.headersList[n + 0], response.headersList[n + 1]) - } - } - - return makeResponse({ - ...response, - internalResponse: response, - headersList: new HeadersList(...headers), - type: 'basic' + return makeFilteredResponse(response, { + type: 'basic', + headersList: makeFilteredHeadersList(response.headersList, (name) => !forbiddenResponseHeaderNames.includes(name)) }) } else if (type === 'cors') { // A CORS filtered response is a filtered response whose type is "cors" @@ -397,22 +431,18 @@ function filterResponse (response, type) { // list whose name is not a CORS-safelisted response-header name, given // internal response’s CORS-exposed header-name list. - // TODO: This is not correct... - return makeResponse({ - ...response, - internalResponse: response, - type: 'cors' + return makeFilteredResponse(response, { + type: 'cors', + headersList: makeFilteredHeadersList(response.headersList, (name) => !corsSafeListedResponseHeaderNames.includes(name)) }) } else if (type === 'opaque') { // An opaque filtered response is a filtered response whose type is // "opaque", URL list is the empty list, status is 0, status message // is the empty byte sequence, header list is empty, and body is null. - return makeResponse({ - ...response, - internalResponse: response, + return makeFilteredResponse(response, { type: 'opaque', - urlList: [], + urlList: Object.freeze([]), status: 0, statusText: '', body: null @@ -422,13 +452,11 @@ function filterResponse (response, type) { // is "opaqueredirect", status is 0, status message is the empty byte // sequence, header list is empty, and body is null. - return makeResponse({ - ...response, - internalResponse: response, + return makeFilteredResponse(response, { type: 'opaqueredirect', status: 0, statusText: '', - headersList: new HeadersList(), + headersList: makeFilteredHeadersList(response.headersList, () => false), body: null }) } else { @@ -436,4 +464,22 @@ function filterResponse (response, type) { } } -module.exports = { makeNetworkError, makeResponse, filterResponse, Response } +// https://fetch.spec.whatwg.org/#appropriate-network-error +function makeAppropriateNetworkError (fetchParams) { + // 1. Assert: fetchParams is canceled. + assert(isCancelled(fetchParams)) + + // 2. Return an aborted network error if fetchParams is aborted; + // otherwise return a network error. + return isAborted(fetchParams) + ? makeNetworkError(new AbortError()) + : makeNetworkError(fetchParams.controller.terminated.reason) +} + +module.exports = { + makeNetworkError, + makeResponse, + makeAppropriateNetworkError, + filterResponse, + Response +} diff --git a/lib/fetch/util.js b/lib/fetch/util.js index 4e6e79838f3..3b708e3346f 100644 --- a/lib/fetch/util.js +++ b/lib/fetch/util.js @@ -333,11 +333,22 @@ function createDeferredPromise () { return { promise, resolve: res, reject: rej } } +function isAborted (fetchParams) { + return fetchParams.controller.state === 'aborted' +} + +function isCancelled (fetchParams) { + return fetchParams.controller.state === 'aborted' || + fetchParams.controller.state === 'terminated' +} + class ServiceWorkerGlobalScope {} // dummy class Window {} // dummy class EnvironmentSettingsObject {} // dummy module.exports = { + isAborted, + isCancelled, ServiceWorkerGlobalScope, Window, EnvironmentSettingsObject,