Skip to content

Fix Safari failing to make cross-origin HTTP requests - #3440

Merged
brandonpayton merged 18 commits into
trunkfrom
beta_tester_safari_prod_only
Apr 15, 2026
Merged

Fix Safari failing to make cross-origin HTTP requests#3440
brandonpayton merged 18 commits into
trunkfrom
beta_tester_safari_prod_only

Conversation

@ashfame

@ashfame ashfame commented Mar 28, 2026

Copy link
Copy Markdown
Member

Motivation for the change, related issues

Problem:
In Safari, WordPress beta tester plugin fails to offer the correct updates that are available after updating the channels to Beta/RCs in its settings. This happen because, the request to api.wordpress.org doesn't go through.

Root cause:
Safari does not support ReadableStream as a fetch() request body, throwing "NotSupportedError: ReadableStream uploading is not supported". fetchWithCorsProxy used ReadableStream.tee() to duplicate the body for the direct-fetch + CORS-proxy-fallback pattern, which broke all POST requests (e.g. wp_version_check to api.wordpress.org) in Safari.

Implementation details

For browsers that support it, keep the original streaming teeRequest() direct-fetch + CORS-proxy-fallback flow. For Safari (no ReadableStream upload support), buffer the request body into an ArrayBuffer so it can be reused for both the direct fetch attempt and the proxy retry.

The issue is confirmed to fix with this PR, but the issue only surfaces locally when ran like the following:

npx nx build playground-website
cd dist/packages/playground/wasm-wordpress-net
php -S 127.0.0.1:7777 -t .

The issue doesn't surface when running npm run dev, not sure why.

Testing Instructions (or ideally a Blueprint)

Checkout this branch locally & run with:

npx nx build playground-website
cd dist/packages/playground/wasm-wordpress-net
php -S 127.0.0.1:7777 -t .
  • Open http://localhost:7777/?plugin=wordpress-beta-tester in Safari browser.
  • Go to Dashboard > Updates, only Nightly is offered.
  • Go to Tools > Beta Testing
  • Select Bleeding-edge and then Beta/RC, save.
  • Now go to Dashboard > Updates, 7.0-RC2 will be offered for upgrade.
@ashfame ashfame self-assigned this Mar 28, 2026
@adamziel

Copy link
Copy Markdown
Collaborator

Let's still use the body stream when supported, no need to make it worse for other browsers.

@adamziel

Copy link
Copy Markdown
Collaborator

Alternatively, we could try to emulate stream support using XHR. This matters when uploading big files.

* attempt and the CORS proxy fallback. We buffer into an ArrayBuffer
* instead of using ReadableStream.tee() because Safari does not support
* ReadableStream as a fetch() request body ("ReadableStream uploading
* is not supported").

@brandonpayton brandonpayton Mar 28, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fortunately, it looks like "ReadableStream request bodies" are part the WebKit Interop 2026 plans:
https://webkit.org/blog/17818/announcing-interop-2026/#fetch-uploads-and-ranges

ReadableStream request bodies enable true streaming uploads, letting you upload large files or real-time data without loading everything into memory first:

ashfame added 2 commits March 31, 2026 21:18
Safari does not support ReadableStream as a fetch() request body,
throwing "NotSupportedError: ReadableStream uploading is not supported".
fetchWithCorsProxy used ReadableStream.tee() to duplicate the body for
the direct-fetch + CORS-proxy-fallback pattern, which broke all POST
requests (e.g. wp_version_check to api.wordpress.org) in Safari.

Buffer the request body into an ArrayBuffer instead so it can be reused
for both fetch attempts.

Made-with: Cursor
…pport

Instead of unconditionally buffering the request body into an ArrayBuffer
for all browsers, detect at runtime whether the browser supports
ReadableStream as a fetch() request body. Use the streaming teeRequest()
approach when supported (Chrome, Firefox) and fall back to ArrayBuffer
buffering only when not (Safari).

Addresses review feedback to avoid degrading streaming performance for
browsers that already support it.

Made-with: Cursor
@ashfame
ashfame force-pushed the beta_tester_safari_prod_only branch from b427a3f to 4367a2f Compare March 31, 2026 15:33
ashfame added 2 commits March 31, 2026 22:27
Add a beforeAll hook that triggers the one-time ReadableStream body
support detection before any per-test fetch mocks are installed, so
the detection's internal fetch() call doesn't consume mock responses.

Made-with: Cursor
@ashfame
ashfame marked this pull request as ready for review March 31, 2026 18:43
@ashfame
ashfame requested review from a team, Copilot and zaerl March 31, 2026 18:43

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR addresses Safari failures for cross-origin POST requests by avoiding ReadableStream request bodies when unsupported, enabling the direct-fetch + CORS-proxy-fallback flow to work in Safari.

Changes:

  • Add runtime feature detection for ReadableStream as a fetch() request body and cache the result.
  • For non-supporting browsers, buffer the request body into an ArrayBuffer to reuse it across direct fetch and proxy retry.
  • Update tests to “pre-warm” the feature detection cache to avoid consuming per-test mocked fetch responses.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 4 comments.

File Description
packages/php-wasm/web-service-worker/src/fetch-with-cors-proxy.ts Adds ReadableStream upload support detection and an ArrayBuffer buffering fallback for Safari before retrying via CORS proxy.
packages/php-wasm/web-service-worker/src/fetch-with-cors-proxy.spec.ts Adds suite-level warmup to keep feature detection from interfering with per-test fetch mocks.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread packages/php-wasm/web-service-worker/src/fetch-with-cors-proxy.ts Outdated
Comment thread packages/php-wasm/web-service-worker/src/fetch-with-cors-proxy.ts
Comment thread packages/php-wasm/web-service-worker/src/fetch-with-cors-proxy.ts Outdated
ashfame and others added 4 commits April 1, 2026 01:54
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
After teeRequest/buffering, requestObject's body stream may be
locked. Defensively use directRequest (which carries identical
metadata) in the catch block to avoid relying on the original
request's body state.

Made-with: Cursor
Export a test-only resetStreamBodySupportedForTesting() function so
the cached feature detection can be cleared between test runs. Add
three tests that force the non-streaming path by rejecting the
detection probe, covering buffered POST bodies for both the direct
fetch and CORS proxy fallback, as well as bodyless GET requests.

Made-with: Cursor
@ashfame
ashfame requested a review from Copilot March 31, 2026 20:25

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 2 out of 2 changed files in this pull request and generated 4 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread packages/php-wasm/web-service-worker/src/fetch-with-cors-proxy.ts Outdated
Comment thread packages/php-wasm/web-service-worker/src/fetch-with-cors-proxy.ts Outdated
Comment thread packages/php-wasm/web-service-worker/src/fetch-with-cors-proxy.ts Outdated
Comment thread packages/php-wasm/web-service-worker/src/fetch-with-cors-proxy.ts Outdated
Reduces public API surface by grouping test utilities under a
clearly internal __testing export object.

Made-with: Cursor
@ashfame
ashfame requested a review from brandonpayton March 31, 2026 20:39
controller.close();
},
});
await fetch('data:a/a,', {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a reason we need to use a meaningless MIME type here? If so, let's note why in a comment. If not, maybe we can remove the type altogether. The data URL spec says it is totally optional, and fetch('data:,') works fine in Firefox, Chrome, and Safari for me.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You are right. I didn't realise mime type was optional. Good to know. Tested 👍

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@ashfame I didn't know/remember it was optional either, before looking at this case.

Comment on lines +100 to +102
* fetch attempt and the CORS proxy fallback. Safari does not
* support ReadableStream as a fetch() request body
* ("ReadableStream uploading is not supported").

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wherever we note this, let's:

This way we can feel comfortable removing the workaround when it is no longer needed.

const [teedDirect, teedProxy] = await teeRequest(requestObject);
directRequest = teedDirect;
corsProxyBody = teedProxy.body;
} else {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a reason we shouldn't move this if/else into the teeRequest() utility?

If we solve this problem in the utility function, it will be solved wherever teeRequest() is used. teeRequest() is not currently used elsewhere in this repo, but I think the workaround belongs there, unless there is a reason not to move it.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Very good point! I can refactor it in a follow up PR so that the fix is not still waiting to land. Sounds good?

Comment on lines +164 to +168
const rootUrl = new URL(import.meta.url);
rootUrl.pathname = '';
rootUrl.search = '';
rootUrl.hash = '';
const corsProxyUrlObj = new URL(corsProxyUrl, rootUrl.toString());

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we need to clear all the fields on the rootUrl? Wouldn't the URL() constructor be able to make sense of the root URL on its own?

For example, in the browser dev tools console, new URL('a', new URL('http://happycode.net/?stuff#huzzah')).href evaluates to 'http://happycode.net/a'.

I think this can be as simple as:

if (
	useStreaming &&
	body &&
	new URL(corsProxyUrlObj, import.meta.url).protocol === 'http:'
) {

I know the rootUrl changes are pre-existing code that was moved. But I think we can either simplify while we're moving around what I think is unnecessary code, or maybe we should just leave the code alone except for adding useStreaming to the original if.

There may be something I'm missing, but that is my read here.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, seems alright to do. I have made the change.

ashfame added 4 commits April 6, 2026 14:37
Centralize ReadableStream upload capability probing and body preparation in a reusable utility so callers get Safari buffering behavior automatically. Update CORS proxy and tests to assert streaming vs buffered branches explicitly.

Made-with: Cursor
Align fetch-with-cors-proxy test names and comments with the new prepareRequestForRetry flow so they no longer refer to removed teeRequest/duplexSafeFetch internals.

Made-with: Cursor
@ashfame

ashfame commented Apr 6, 2026

Copy link
Copy Markdown
Member Author

@brandonpayton Suggested refactoring done, ready for re-review.

@ashfame
ashfame requested a review from brandonpayton April 6, 2026 09:03

@brandonpayton brandonpayton left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@ashfame I don't understand why we are getting rid of the teeRequest() util here and left a question about that.

directRequest,
retryBody: corsProxyBody,
useStreamingBody,
} = await prepareRequestForRetry(requestObject);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why are we replacing teeRequest() here?

If we are worried about unnecessarily duplicating a buffer in the streaming case, could we make a request object with a property getter that duplicates it on demand?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think you might be suggesting wrapping the tee'd result in a request-like object with a getter for the retry body? The tee/buffer still has to happen eagerly before the first fetch() consumes the stream, so the getter wouldn't defer any work — it'd just be a different way to access the same pre-computed value. We also need useStreamingBody surfaced for the HTTP/1.1 CORS proxy buffering (lines 104-111), which depends on corsProxyUrl the caller has.

Let me know if I'm misreading your suggestion though!

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@ashfame @adamziel is there any reason we couldn't just use Request#clone() here instead of our own teeRequest() utility (or utili-tee)?

From the MDN docs:

In fact, the main reason clone() exists is to allow multiple uses of body objects (when they are one-use only.)

It sounds like this should cover both cases, browsers that support streamed body properties and those that do not.

Is there a catch? If not, would this problem be solved if we just used that instead?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@brandonpayton Safari would still not support sending ReadableStream as a fetch() req body. So the fix would still be required. If you are wondering about just swapping the underlying utility, we can't because we need to modify the url and body of the request, which clone() would not let us do.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I'm sorry, @ashfame. You're right that Request objects are immutable, and we need to create another Request with the CORS proxy URL. I should have noticed. My earlier suggestion about moving the logic into teeRequest() also seems misguided because I was only thinking about cloning the body, not about how we'd need to create a whole new Request to target a different URL.

I just have a feeling this could be much simpler and clearer.

Unfortunately, browser support here is a bit odd. Neither Safari nor Firefox support streaming Request bodies, but what they support is inconsistent between them.

Safari

Even though Safari doesn't support passing a ReadableStream as a body to fetch(), the "fetch" events I see in the Safari Service Worker do have a body property that is a ReadableStream.

As a test in Safari because it was near at hand, I made this request in the browser dev tools :
r = new Request('https://brandonpayton.github.io/wasm-posix-kernel/', { method: 'POST', body: new ArrayBuffer(100) })

The type of r.body was ReadableStream.

Then I opened the service worker dev tools, set a breakpoint on the fetch event, switched back to the original dev tools, and called fetch(r).

The request received by Service Worker also has a body that is a ReadableStream.

You can also create a request like:
r = new Request('https://brandonpayton.github.io/wasm-posix-kernel/', { method: 'POST', body: stream, duplex: 'half' })

But if you try to pass it to fetch() there is an "Unhandled Promise Rejection: NotSupportedError: ReadableStream uploading is not supported" error.

Firefox

In Firefox, with
r = new Request('https://brandonpayton.github.io/wasm-posix-kernel/', { method: 'POST', body: new ArrayBuffer(100) }), r.body doesn't seem to exist at all, so we cannot use things like `new Response(request.body).arrayBuffer().

What we can use for both Firefox and Safari to get access to a request's body is r.arrayBuffer().

How to support Chrome, Safari, and Firefox

It seems like the main things we need to make are:

  1. Find out whether the browser supports passing a ReadableStream request body to fetch(), as this PR is already doing.
  2. Use request.clone() to make have a second request for retry. This should cover all browsers, either teeing a ReadableStream body or duplicating a body buffer for use in a second possible request.
  3. If we can use streaming request body, just pass along clonedRequest.body to the second fetch() and set the duplex: 'half' property.
  4. If we can't use streaming request body, get the body as an ArrayBuffer via clonedRequest.arrayBuffer() and pass that as the body.
  • We cannot use new Response(clonedRequest.body).arrayBuffer() because Firefox does not expose a clonedRequest.body property today.
  • Note: AFAICT, it can be tricky to answer whether a request had a body to begin with. Perhaps if the buffer is zero-length and there is no content-type header, we can ignore the body altogether.

I think this can be done without teeRequest() or the shared preparation utility (which I originally implicitly encouraged by asking why more shouldn't be done within teeRequest()). I think doing it all here in sequence is probably clearer than having a prepare-for-retry helper.

What do you think?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Spot on! Much simpler implementation now. Thanks for being so meticulous!

…browser retry

The prepareRequestForRetry utility relied on request.body (a ReadableStream)
to detect and handle request bodies, which breaks on Firefox (where
request.body is undefined) and Safari (where ReadableStream uploads throw).

Inline the simpler cross-browser approach directly in fetchWithCorsProxy:
clone the request before the first fetch, then extract the body from the
clone for retry via arrayBuffer() (Safari/Firefox) or ReadableStream (Chrome).

Made-with: Cursor

@brandonpayton brandonpayton left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for working on this, @ashfame!

I think the tests need some work in a follow-up PR. I left a note about that. But this looks like a good change. It's simple to reason about and looks like it solves the issue.

*/
describe('non-streaming fallback (Safari/Firefox)', () => {
beforeEach(() => {
__testing.resetStreamBodySupported();

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm concerned that we are just assuming/implying streaming is detected as unsupported due to mocks. The tests would be stronger if they positively assert that streaming is detected as unsupported. And since we have access to the utils here, that is possible.

Let's move the detection and mocking into the the beforeEach() so each of these tests asserts that streaming is unsupported prior to running.

it('handles GET requests with no body in non-streaming mode', async () => {
const fetchMock = vi
.spyOn(globalThis, 'fetch')
.mockResolvedValue(new Response('ok'));

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How is this in non-streaming mode if we are doing this above?

beforeEach(() => {
			__testing.resetStreamBodySupported();
		});

We aren't mocking anything for streaming detection, and if the tests are running on Node.js / V8, won't this mean that this test runs when streaming body is detected as supported?

Note: this comment becomes irrelevant if we make force a streaming-body-unsupported condition in the beforeEach().

@brandonpayton
brandonpayton merged commit f826594 into trunk Apr 15, 2026
47 checks passed
@brandonpayton
brandonpayton deleted the beta_tester_safari_prod_only branch April 15, 2026 05:07
ashfame added a commit that referenced this pull request Apr 15, 2026
Follow-up to #3440. The non-streaming fallback tests relied on
implicit mock ordering to simulate an unsupported streaming
environment. On Node.js/V8 (where streaming IS supported), the
GET test was not actually exercising the non-streaming path.

Force `supportsReadableStreamBody()` to return `false` via a new
`__testing.setStreamBodySupported()` setter and assert it in
beforeEach, so every test positively confirms it runs in
non-streaming mode.

Made-with: Cursor
brandonpayton pushed a commit that referenced this pull request Jul 17, 2026
## Summary

Follow-up to #3440, specifically addressing Brandon's comments [the
request to assert unsupported streaming in each fallback
test](#3440 (comment))
and [the concern that resetting the cache allows Node.js/V8 to detect
streaming
support](#3440 (comment)).

- The non-streaming fallback suite now sets the cached
`supportsReadableStreamBody()` result to `false` in `beforeEach()` and
asserts that value before every test runs.
- The suite resets that test-only cache in `afterEach()`, keeping the
test state hermetic.
- The proxy-retry test now correctly expects only direct-fetch and
proxy-fetch calls, because no detection probe runs when unsupported mode
is already cached.

## Test plan

- [x] `npm exec nx test php-wasm-web-service-worker
--testFile=fetch-with-cors-proxy.spec.ts`
- [x] `npm exec nx lint php-wasm-web-service-worker`
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

[Priority] High [Type] Bug An existing feature does not function as intended

4 participants