-
Notifications
You must be signed in to change notification settings - Fork 406
Fix Safari failing to make cross-origin HTTP requests #3440
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: trunk
Are you sure you want to change the base?
Changes from 8 commits
ac46d66
4367a2f
28c4044
0e6f502
ade6477
d87e35e
db5485e
a4d807e
d79d665
f6176bc
2499f16
b84d8da
5391a96
3109ab0
de69cb8
ac2f483
0a5ac58
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -3,6 +3,38 @@ import { FirewallInterferenceError } from './firewall-interference-error'; | |
|
|
||
| const CORS_PROXY_HEADER = 'X-Playground-Cors-Proxy'; | ||
|
|
||
| let streamBodySupported: boolean | undefined; | ||
ashfame marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
|
|
||
| /** | ||
| * @internal Exposed only for tests that need to exercise both the | ||
| * streaming (tee) and non-streaming (buffer) code paths. | ||
| */ | ||
| export function resetStreamBodySupportedForTesting(): void { | ||
| streamBodySupported = undefined; | ||
| } | ||
|
|
||
ashfame marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| async function supportsReadableStreamBody(): Promise<boolean> { | ||
| if (streamBodySupported !== undefined) { | ||
| return streamBodySupported; | ||
| } | ||
| try { | ||
ashfame marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| const stream = new ReadableStream({ | ||
| start(controller) { | ||
| controller.close(); | ||
| }, | ||
| }); | ||
| await fetch('data:a/a,', { | ||
|
||
| method: 'POST', | ||
| body: stream, | ||
| duplex: 'half', | ||
| } as RequestInit); | ||
| streamBodySupported = true; | ||
| } catch { | ||
| streamBodySupported = false; | ||
| } | ||
| return streamBodySupported; | ||
| } | ||
|
|
||
| export async function fetchWithCorsProxy( | ||
| input: RequestInfo, | ||
| init?: RequestInit, | ||
|
|
@@ -54,17 +86,44 @@ export async function fetchWithCorsProxy( | |
| return await fetch(requestObject); | ||
| } | ||
|
|
||
| // Tee the request to avoid consuming the request body stream on the initial | ||
| // fetch() so that we can retry through the cors proxy. | ||
| const [directRequest, corsProxyRequest] = await teeRequest(requestObject); | ||
| const useStreaming = await supportsReadableStreamBody(); | ||
|
|
||
| let directRequest: Request; | ||
| let corsProxyBody: ArrayBuffer | ReadableStream<Uint8Array> | null; | ||
|
|
||
| if (useStreaming) { | ||
| const [teedDirect, teedProxy] = await teeRequest(requestObject); | ||
| directRequest = teedDirect; | ||
| corsProxyBody = teedProxy.body; | ||
| } else { | ||
ashfame marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| /** | ||
| * Buffer the request body so it can be reused across the direct | ||
| * fetch attempt and the CORS proxy fallback. Safari does not | ||
| * support ReadableStream as a fetch() request body | ||
| * ("ReadableStream uploading is not supported"). | ||
|
||
| */ | ||
| let bufferedBody: ArrayBuffer | null = null; | ||
| if (requestObject.body) { | ||
| bufferedBody = await new Response(requestObject.body).arrayBuffer(); | ||
| } | ||
| if (bufferedBody !== null) { | ||
| directRequest = await cloneRequest(requestObject, { | ||
| body: bufferedBody, | ||
| }); | ||
| } else { | ||
| // No body to buffer; reuse the original request without overriding `body`. | ||
| directRequest = requestObject; | ||
| } | ||
| corsProxyBody = bufferedBody; | ||
| } | ||
|
|
||
| try { | ||
| return await fetch(directRequest); | ||
| } catch { | ||
| // If the developer has explicitly allowed the request to pass the | ||
| // credentials headers with the X-Cors-Proxy-Allowed-Request-Headers header, | ||
| // then let's include those credentials in the fetch() request. | ||
| const headers = new Headers(corsProxyRequest.headers); | ||
| const headers = new Headers(directRequest.headers); | ||
| const corsProxyAllowedHeaders = | ||
| headers.get('x-cors-proxy-allowed-request-headers')?.split(',') || | ||
| []; | ||
|
|
@@ -87,38 +146,34 @@ export async function fetchWithCorsProxy( | |
| } | ||
|
|
||
| /** | ||
| * Buffer the cors proxy request body into an ArrayBuffer if talking to a `http://` URL. | ||
| * | ||
| * Streaming request bodies don't work with the local dev server, which uses http:// | ||
| * as a protocol. However, with a streamed request body, Chrome silently upgrades a | ||
| * HTTP/1.1 request to HTTP/2. However, our HTTP/1.1-only local dev server still replies | ||
| * with a HTTP/1.1 response. Chrome then treats the request as failed with an | ||
| * ERR_ALPN_NEGOTIATION_FAILED error. | ||
| * When using streaming bodies, buffer into an ArrayBuffer if the | ||
| * CORS proxy uses `http:`. Streaming request bodies cause Chrome | ||
| * to silently upgrade HTTP/1.1 to HTTP/2, but an HTTP/1.1-only | ||
| * server replies with HTTP/1.1, triggering ERR_ALPN_NEGOTIATION_FAILED. | ||
| * | ||
| * Inferring the HTTP version from the URL protocol is unreliable and will fail | ||
| * if the CORS proxy is hosted on a `https://` URL that speaks HTTP < 2. This is | ||
| * a recognized limitation of the CORS proxy feature. If you host it on an `https://` URL, | ||
| * make sure to use HTTP/2. | ||
| * | ||
| * See: https://developer.chrome.com/docs/capabilities/web-apis/fetch-streaming-requests | ||
| * @see https://developer.chrome.com/docs/capabilities/web-apis/fetch-streaming-requests | ||
| */ | ||
|
|
||
| // In development, corsProxyUrl may be /cors-proxy/. We need to resolve the absolute URL | ||
| // to access the protocol. | ||
| const rootUrl = new URL(import.meta.url); | ||
| rootUrl.pathname = ''; | ||
| rootUrl.search = ''; | ||
| rootUrl.hash = ''; | ||
| const corsProxyUrlObj = new URL(corsProxyUrl, rootUrl.toString()); | ||
|
|
||
| let body: ArrayBuffer | ReadableStream<Uint8Array> | null = | ||
| corsProxyRequest.body; | ||
| if (body && new URL(corsProxyUrlObj).protocol === 'http:') { | ||
| body = await new Response(body).arrayBuffer(); | ||
| let body = corsProxyBody; | ||
| if (useStreaming && body) { | ||
| // In development, corsProxyUrl may be /cors-proxy/. We need to resolve the absolute URL | ||
| // to access the protocol. | ||
| const rootUrl = new URL(import.meta.url); | ||
| rootUrl.pathname = ''; | ||
| rootUrl.search = ''; | ||
| rootUrl.hash = ''; | ||
| const corsProxyUrlObj = new URL(corsProxyUrl, rootUrl.toString()); | ||
|
||
| if (corsProxyUrlObj.protocol === 'http:') { | ||
| body = await new Response(body).arrayBuffer(); | ||
| } | ||
| } | ||
|
|
||
| const newRequest = await cloneRequest(corsProxyRequest, { | ||
| url: `${corsProxyUrl}${requestObject.url}`, | ||
| const newRequest = await cloneRequest(directRequest, { | ||
| url: `${corsProxyUrl}${directRequest.url}`, | ||
| headers, | ||
| body, | ||
| ...(requestIntendsToPassCredentials && { credentials: 'include' }), | ||
ashfame marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
@@ -132,7 +187,7 @@ export async function fetchWithCorsProxy( | |
| // came from a network firewall rather than the actual CORS proxy. | ||
| if (!response.headers.has(CORS_PROXY_HEADER)) { | ||
| throw new FirewallInterferenceError( | ||
| requestObject.url, | ||
| directRequest.url, | ||
| response.status, | ||
| response.statusText | ||
| ); | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.