diff --git a/packages/php-wasm/web-service-worker/src/fetch-with-cors-proxy.spec.ts b/packages/php-wasm/web-service-worker/src/fetch-with-cors-proxy.spec.ts index e628e0a0ee7..1a229629390 100644 --- a/packages/php-wasm/web-service-worker/src/fetch-with-cors-proxy.spec.ts +++ b/packages/php-wasm/web-service-worker/src/fetch-with-cors-proxy.spec.ts @@ -1,8 +1,38 @@ -import { afterEach, describe, expect, it, vi } from 'vitest'; +import { + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from 'vitest'; import { fetchWithCorsProxy } from './fetch-with-cors-proxy'; import { FirewallInterferenceError } from './firewall-interference-error'; +import { __testing, prepareRequestForRetry } from './utils'; describe('fetchWithCorsProxy', () => { + beforeAll(async () => { + // Pre-warm the one-time ReadableStream body feature detection cache so + // its internal fetch() probe doesn't consume test-specific mock calls. + const tempMock = vi + .spyOn(globalThis, 'fetch') + .mockResolvedValue(new Response('')); + const stream = new ReadableStream({ + start(controller) { + controller.close(); + }, + }); + const request = new Request('https://warmup.invalid/', { + method: 'POST', + body: stream, + // @ts-expect-error duplex is required for streaming bodies + duplex: 'half', + }); + await prepareRequestForRetry(request); + tempMock.mockRestore(); + }); + afterEach(() => { vi.restoreAllMocks(); }); @@ -185,7 +215,7 @@ describe('fetchWithCorsProxy', () => { duplex: 'half', }); - // No corsProxyUrl → direct fetch, no tee/clone involved. + // No corsProxyUrl → direct fetch, no retry-preparation pipeline involved. await fetchWithCorsProxy(request); expect(fetchMock).toHaveBeenCalledTimes(1); @@ -230,8 +260,7 @@ describe('fetchWithCorsProxy', () => { expect(proxyRequest.url).toBe( 'http://localhost:5400/cors-proxy/?url=https://example.com/api' ); - // The buffered content should survive the tee → clone → buffer - // pipeline intact. + // The content should survive request preparation and proxy retry intact. expect(await new Response(proxyRequest.body).text()).toBe( 'upload payload' ); @@ -278,7 +307,7 @@ describe('fetchWithCorsProxy', () => { expect(await new Response(proxyRequest.body).text()).toBe(body); }); - it('forwards init to duplexSafeFetch in the CORS proxy retry path', async () => { + it('applies init values in the CORS proxy retry path', async () => { const corsProxyHeaders = new Headers(); corsProxyHeaders.set('X-Playground-Cors-Proxy', 'true'); @@ -289,8 +318,8 @@ describe('fetchWithCorsProxy', () => { new Response('proxied', { headers: corsProxyHeaders }) ); - // When input is a string, init builds the initial Request and - // is also forwarded to duplexSafeFetch in the retry path. + // When input is a string, init builds the initial request used in + // direct fetch and proxy retry preparation. const response = await fetchWithCorsProxy( 'https://example.com/api', { method: 'POST', body: 'form data' }, @@ -302,9 +331,126 @@ describe('fetchWithCorsProxy', () => { expect(proxyRequest.url).toBe( 'http://localhost:5400/cors-proxy/?url=https://example.com/api' ); - // The body from init should survive the tee → clone → buffer - // pipeline. + // The body from init should survive request preparation and retry. expect(await new Response(proxyRequest.body).text()).toBe('form data'); expect(await response.text()).toBe('proxied'); }); + + /** + * These tests do not prove streaming vs buffered upload from the `Response` + * (that is not exposed). They also cannot use `fetch` mock `Request.body`: + * even when our code buffers to an `ArrayBuffer` and passes it into + * `new Request()`, the Fetch API still exposes `request.body` as a + * `ReadableStream`, same as the tee/streaming path — so `instanceof + * ArrayBuffer` on outgoing requests is not a valid signal in this runtime. + * + * Instead, the Safari (buffer) *preparation* path is selected by mocking the + * first `fetch` (the `supportsReadableStreamBody` probe) to reject; then we + * assert call counts, URLs, and that body bytes round-trip correctly. + * + * The GET case has no request body, so there is no probe and no tee vs + * buffer fork; only direct-fetch behavior is exercised. + */ + describe('non-streaming fallback (Safari)', () => { + beforeEach(() => { + __testing.resetStreamBodySupported(); + }); + + /** + * Helper that sets up a fetch mock whose first call (the + * ReadableStream body feature-detection probe) always rejects, + * forcing the non-streaming/buffering code path. + */ + function mockFetchWithoutStreamingSupport( + ...responses: Array + ) { + const mock = vi.spyOn(globalThis, 'fetch'); + // Detection probe — reject to simulate Safari. + mock.mockRejectedValueOnce( + new TypeError('ReadableStream uploading is not supported') + ); + for (const r of responses) { + if (r instanceof Error) { + mock.mockRejectedValueOnce(r); + } else { + mock.mockResolvedValueOnce(r); + } + } + return mock; + } + + it('buffers the POST body as ArrayBuffer for the direct fetch', async () => { + const fetchMock = mockFetchWithoutStreamingSupport( + new Response('ok') + ); + + const request = new Request('https://example.com/api', { + method: 'POST', + body: 'buffered payload', + }); + + await fetchWithCorsProxy( + request, + undefined, + 'https://proxy.test/?url=' + ); + + // detection + direct fetch + expect(fetchMock).toHaveBeenCalledTimes(2); + const directReq = fetchMock.mock.calls[1][0] as Request; + expect(directReq.url).toBe('https://example.com/api'); + expect(await new Response(directReq.body).text()).toBe( + 'buffered payload' + ); + }); + + it('preserves the buffered body through to the CORS proxy fallback', async () => { + const corsProxyHeaders = new Headers(); + corsProxyHeaders.set('X-Playground-Cors-Proxy', 'true'); + + const fetchMock = mockFetchWithoutStreamingSupport( + new Error('CORS'), + new Response('proxied', { headers: corsProxyHeaders }) + ); + + const request = new Request('https://example.com/upload', { + method: 'POST', + body: 'safari payload', + }); + + const response = await fetchWithCorsProxy( + request, + undefined, + 'https://proxy.test/?url=' + ); + + // detection + direct + proxy + expect(fetchMock).toHaveBeenCalledTimes(3); + const proxyReq = fetchMock.mock.calls[2][0] as Request; + expect(proxyReq.url).toBe( + 'https://proxy.test/?url=https://example.com/upload' + ); + expect(await new Response(proxyReq.body).text()).toBe( + 'safari payload' + ); + expect(await response.text()).toBe('proxied'); + }); + + it('handles GET requests with no body in non-streaming mode', async () => { + const fetchMock = vi + .spyOn(globalThis, 'fetch') + .mockResolvedValue(new Response('ok')); + + await fetchWithCorsProxy( + 'https://example.com/page', + undefined, + 'https://proxy.test/?url=' + ); + + // No body means no stream-support probe, even with a reset cache. + expect(fetchMock).toHaveBeenCalledTimes(1); + const directReq = fetchMock.mock.calls[0][0] as Request; + expect(directReq.url).toBe('https://example.com/page'); + }); + }); }); diff --git a/packages/php-wasm/web-service-worker/src/fetch-with-cors-proxy.ts b/packages/php-wasm/web-service-worker/src/fetch-with-cors-proxy.ts index 5a9eac418d8..4238da39147 100644 --- a/packages/php-wasm/web-service-worker/src/fetch-with-cors-proxy.ts +++ b/packages/php-wasm/web-service-worker/src/fetch-with-cors-proxy.ts @@ -1,4 +1,4 @@ -import { cloneRequest, teeRequest } from './utils'; +import { cloneRequest, prepareRequestForRetry } from './utils'; import { FirewallInterferenceError } from './firewall-interference-error'; const CORS_PROXY_HEADER = 'X-Playground-Cors-Proxy'; @@ -54,9 +54,11 @@ 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 { + directRequest, + retryBody: corsProxyBody, + useStreamingBody, + } = await prepareRequestForRetry(requestObject); try { return await fetch(directRequest); @@ -64,7 +66,7 @@ export async function fetchWithCorsProxy( // 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 +89,29 @@ 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 | null = - corsProxyRequest.body; - if (body && new URL(corsProxyUrlObj).protocol === 'http:') { + let body = corsProxyBody; + if ( + useStreamingBody && + body && + new URL(corsProxyUrl, import.meta.url).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' }), @@ -132,7 +125,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 ); diff --git a/packages/php-wasm/web-service-worker/src/utils.spec.ts b/packages/php-wasm/web-service-worker/src/utils.spec.ts index ffca7d77517..f5465a98544 100644 --- a/packages/php-wasm/web-service-worker/src/utils.spec.ts +++ b/packages/php-wasm/web-service-worker/src/utils.spec.ts @@ -1,6 +1,8 @@ import { + __testing, cloneRequest, getRequestHeaders, + prepareRequestForRetry, removeContentSecurityPolicyDirective, } from './utils'; @@ -108,3 +110,73 @@ describe('removeContentSecurityPolicyDirective', () => { expect(filteredCspHeader).toBe("default-src 'self';"); }); }); + +describe('prepareRequestForRetry', () => { + beforeEach(() => { + __testing.resetStreamBodySupported(); + vi.restoreAllMocks(); + }); + + it('tees the body when streaming uploads are supported', async () => { + const fetchMock = vi + .spyOn(globalThis, 'fetch') + .mockResolvedValue(new Response('')); + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode('payload')); + controller.close(); + }, + }); + const request = new Request('https://example.com/upload', { + method: 'POST', + body: stream, + // @ts-expect-error duplex is required for streaming bodies + duplex: 'half', + }); + + const { directRequest, retryBody, useStreamingBody } = + await prepareRequestForRetry(request); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(useStreamingBody).toBe(true); + expect(retryBody).toBeInstanceOf(ReadableStream); + expect(await new Response(directRequest.body).text()).toBe('payload'); + expect(await new Response(retryBody).text()).toBe('payload'); + }); + + it('buffers the body when streaming uploads are not supported', async () => { + const fetchMock = vi + .spyOn(globalThis, 'fetch') + .mockRejectedValue( + new TypeError('ReadableStream uploading is not supported') + ); + const request = new Request('https://example.com/upload', { + method: 'POST', + body: 'buffered payload', + }); + + const { directRequest, retryBody, useStreamingBody } = + await prepareRequestForRetry(request); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(useStreamingBody).toBe(false); + expect(retryBody).toBeInstanceOf(ArrayBuffer); + expect(await new Response(directRequest.body).text()).toBe( + 'buffered payload' + ); + expect(await new Response(retryBody).text()).toBe('buffered payload'); + }); + + it('returns the original request when there is no body', async () => { + const fetchMock = vi.spyOn(globalThis, 'fetch'); + const request = new Request('https://example.com/page'); + + const { directRequest, retryBody, useStreamingBody } = + await prepareRequestForRetry(request); + + expect(fetchMock).not.toHaveBeenCalled(); + expect(directRequest).toBe(request); + expect(retryBody).toBeNull(); + expect(useStreamingBody).toBe(false); + }); +}); diff --git a/packages/php-wasm/web-service-worker/src/utils.ts b/packages/php-wasm/web-service-worker/src/utils.ts index af2544524e9..7c98f1934c8 100644 --- a/packages/php-wasm/web-service-worker/src/utils.ts +++ b/packages/php-wasm/web-service-worker/src/utils.ts @@ -152,7 +152,7 @@ export async function convertFetchEventToPHPRequest(event: FetchEvent) { } } - return new Response(responseBody, { + return new Response(responseBody as BodyInit | null, { headers: phpResponse.headers, status: phpResponse.httpStatusCode, }); @@ -267,20 +267,92 @@ export async function cloneRequest( }); } +// Cached result of supportsReadableStreamBody(); undefined means not probed yet. +let streamBodySupported: boolean | undefined; + +/** @internal Test-only utilities — not part of the public API. */ +export const __testing = { + resetStreamBodySupported(): void { + streamBodySupported = undefined; + }, +}; + /** - * Tee a request to ensure the body stream is not consumed - * when executing or cloning the request. + * Prepares a request for one direct fetch attempt and one retry. + * + * When streaming request bodies are supported, we tee the body stream so both + * attempts can read it. Otherwise (e.g. Safari), we buffer the body to support + * retries without ReadableStream upload support. * * @param request - * @returns + * @returns `directRequest` for the first fetch, `retryBody` for a retry (or null if no + * body), and `useStreamingBody` (whether the body was teed as streams vs buffered). */ -export async function teeRequest( - request: Request -): Promise<[Request, Request]> { +export async function prepareRequestForRetry(request: Request): Promise<{ + directRequest: Request; + retryBody: ArrayBuffer | ReadableStream | null; + useStreamingBody: boolean; +}> { if (!request.body) { - return [request, request]; + return { + directRequest: request, + retryBody: null, + useStreamingBody: false, + }; + } + + const useStreamingBody = await supportsReadableStreamBody(); + if (useStreamingBody) { + const [directRequest, retryRequest] = + await splitRequestBodyStream(request); + return { + directRequest, + retryBody: retryRequest.body, + useStreamingBody: true, + }; } - const [body1, body2] = request.body.tee(); + + /** + * As of April 2026, Safari does not currently support using a ReadableStream + * as a fetch() request body ("ReadableStream uploading is not supported"). + * Buffer the body so we can reuse it for the direct fetch and a retry. + * Safari support is in progress via Interop 2026; see + * https://web.dev/blog/interop-2026#fetch_uploads_and_ranges + */ + const bufferedBody = await new Response(request.body).arrayBuffer(); + return { + directRequest: await cloneRequest(request, { body: bufferedBody }), + retryBody: bufferedBody, + useStreamingBody: false, + }; +} + +async function supportsReadableStreamBody(): Promise { + if (streamBodySupported !== undefined) { + return streamBodySupported; + } + try { + const stream = new ReadableStream({ + start(controller) { + controller.close(); + }, + }); + await fetch('data:,', { + method: 'POST', + body: stream, + duplex: 'half', + } as RequestInit); + streamBodySupported = true; + } catch { + streamBodySupported = false; + } + return streamBodySupported; +} + +async function splitRequestBodyStream( + request: Request +): Promise<[Request, Request]> { + const [body1, body2] = request.body!.tee(); return [ await cloneRequest(request, { body: body1, duplex: 'half' }), await cloneRequest(request, { body: body2, duplex: 'half' }),