Skip to content

fix(php-wasm/web): do not build a request body stream for HEAD in tcpOverFetch#3632

Open
erseco wants to merge 1 commit into
WordPress:trunkfrom
erseco:fix/tcp-over-fetch-head-body
Open

fix(php-wasm/web): do not build a request body stream for HEAD in tcpOverFetch#3632
erseco wants to merge 1 commit into
WordPress:trunkfrom
erseco:fix/tcp-over-fetch-head-body

Conversation

@erseco
Copy link
Copy Markdown

@erseco erseco commented May 13, 2026

Closes #3631

Problem

RawBytesFetch.parseHttpRequest() in packages/php-wasm/web/src/lib/tcp-over-fetch-websocket.ts (line 643) builds an outbound ReadableStream body for every parsed request whose method is not exactly 'GET', then constructs:

new Request(url.toString(), {
    method: parsedHeaders.method,
    headers: parsedHeaders.headers,
    body: outboundBodyStream,
    duplex: outboundBodyStream ? 'half' : undefined,
});

The Fetch spec forbids GET and HEAD from carrying a request body, so for HEAD this raises:

TypeError: Failed to construct 'Request': Request with GET/HEAD method cannot have body.

That exception surfaces as an uncaught promise rejection at the top of parseHttpRequest (tcp-over-fetch-websocket.ts:736 in the current minified bundle). Whatever cURL call emitted the HEAD probe is left in a half-state — the GET request that follows still completes but the buffered bytes ride on top of corrupted internal state.

Where this trips in practice

Moodle Playground (a Moodle build that uses @php-wasm/web) reports that downloads through Moodle's built-in URL downloader repository (repository_url) all silently fail because Moodle's cURL emits HEAD whenever CURLOPT_NOBODY is set — a common path used for cheap size pre-checks and during redirect follow-ups. Local form-upload paths are unaffected because they never traverse tcpOverFetch.

Console excerpt from the bug report:

Uncaught (in promise) TypeError: Failed to construct 'Request': Request with GET/HEAD method cannot have body.
    at _L.parseHttpRequest (tcp-over-fetch-websocket.ts:736:19)
    at async nt.fetchOverTLS (tcp-over-fetch-websocket.ts:329:4)

Downstream tracking:

  • ateeducacion/moodle-playground#93 (merged) — temporary downstream esbuild-string-replace patch against the published @php-wasm/web bundle, to be removed once this PR lands and @php-wasm/web is bumped.

Fix

Mirror the GET branch and also exclude HEAD from the body-building path. One-line change in tcp-over-fetch-websocket.ts:

-		if (parsedHeaders.method !== 'GET') {
+		// GET and HEAD are forbidden by the Fetch spec from carrying a request body.
+		if (parsedHeaders.method !== 'GET' && parsedHeaders.method !== 'HEAD') {

Tests

Added a parseHttpRequest should not build a body stream for HEAD requests case in packages/php-wasm/web/src/test/tcp-over-fetch-websocket.spec.ts, mirroring the existing GET-shaped tests in the same describe('RawBytesFetch', ...) block. Without the fix, the assertion expect(request.body).toBeNull() fails because new Request('http://host/probe', { method: 'HEAD', body: <stream> }) throws before the assertion runs.

Local CI not run here (the fork is shallow); please rely on the action workflow.

Manual verification

Reproduced the URL-downloader regression in Moodle Playground, confirmed it still fails on WordPress/wordpress-playground@trunk, then verified it goes away after rebuilding @php-wasm/web with this patch applied. The chrome-devtools test was the built-in URL downloader Moodle repository (<img class="fp-repo-icon" src=".../repository_url/.../icon">): pasting a remote image URL into the file picker now downloads and renders correctly, whereas before this fix it produced a broken-thumbnail icon and a 404 from draftfile.php.

…OverFetch

`RawBytesFetch.parseHttpRequest()` builds an outbound `ReadableStream` body
for every parsed request whose method is not exactly `'GET'`, then
constructs `new Request(url, { method, body })`. The Fetch spec forbids
GET and HEAD from carrying a request body, so for HEAD this raises:

    TypeError: Failed to construct 'Request':
    Request with GET/HEAD method cannot have body.

The exception surfaces as an uncaught promise rejection from
`tcp-over-fetch-websocket.fetchOverTLS` and corrupts the cURL call that
emitted the HEAD probe. cURL emits HEAD whenever `CURLOPT_NOBODY` is set
— a common path used by libraries for cheap size pre-checks and by some
CMS plugins during URL-import flows.

Reported from a Moodle build that runs on `@php-wasm/web` (Moodle
Playground): downloads through Moodle's built-in URL repository all
silently failed because the HEAD probe issued before each GET threw the
above TypeError, leaving cURL in a half-state. Mirroring the GET branch
to also exclude HEAD makes the error go away and unblocks the download
chain. See:

  - ateeducacion/moodle-playground#92  (root cause analysis)
  - ateeducacion/moodle-playground#93  (downstream string-replace workaround)

Adds a `parseHttpRequest` test that asserts `request.body === null`
for a parsed HEAD request, mirroring the existing GET-shaped tests in
the same `describe('RawBytesFetch', ...)` block.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

[Aspect] Browser [Package][@php-wasm] Web [Type] Bug An existing feature does not function as intended

Projects

None yet

Development

Successfully merging this pull request may close these issues.

tcp-over-fetch: parseHttpRequest builds a body for HEAD requests → browser throws "Request with HEAD method cannot have body"

1 participant