Skip to content

Commit eb1efe0

Browse files
fix(miniflare): preserve request body length in dispatchFetch (#15906)
1 parent 7bb6eae commit eb1efe0

4 files changed

Lines changed: 66 additions & 10 deletions

File tree

‎.changeset/tidy-bodies-count.md‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"miniflare": patch
3+
---
4+
5+
Preserve the request body length in `Miniflare#dispatchFetch()`
6+
7+
Previously, a request with a known-length body (e.g. a string) was sent to the Worker chunked, without a `Content-Length`. Passing its `request.body` to `R2Bucket#put()` then failed with "Provided readable stream must have a known length", even though the same request works in production. The body's length is now preserved.

‎packages/miniflare/src/http/fetch.ts‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,10 @@ export async function fetch(
3030
`Fetch API cannot load: ${url.toString()}\nMake sure you're using http(s):// URLs for WebSocket requests via fetch.`
3131
);
3232
}
33+
// `ws` doesn't use `dispatcher`, so apply its origin rewrite here
34+
if (requestInit?.dispatcher instanceof DispatchFetchDispatcher) {
35+
requestInit.dispatcher.rewriteOrigin(url);
36+
}
3337
url.protocol = url.protocol.replace("http", "ws");
3438

3539
// Normalise request headers to a format ws understands, extracting the
@@ -212,6 +216,14 @@ export class DispatchFetchDispatcher extends undici.Dispatcher {
212216
}
213217
}
214218

219+
rewriteOrigin(/* mut */ url: URL) {
220+
if (url.origin === this.userRuntimeOrigin) {
221+
const runtimeURL = new URL(this.actualRuntimeOrigin);
222+
url.protocol = runtimeURL.protocol;
223+
url.host = runtimeURL.host;
224+
}
225+
}
226+
215227
dispatch(
216228
/* mut */ options: undici.Dispatcher.DispatchOptions,
217229
handler: undici.Dispatcher.DispatchHandler

‎packages/miniflare/src/index.ts‎

Lines changed: 6 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -143,7 +143,7 @@ import type {
143143
ParsedInstanceOptions,
144144
ParsedWorkerOptions,
145145
} from "./config/schema";
146-
import type { DispatchFetch, RequestInit } from "./http";
146+
import type { DispatchFetch } from "./http";
147147
import type {
148148
DurableObjectClassNames,
149149
MiniflareFetcherBinding,
@@ -3124,13 +3124,8 @@ export class Miniflare {
31243124
assert(this.#runtimeDispatcher !== undefined);
31253125

31263126
const forward = new Request(input, init);
3127-
const url = new URL(forward.url);
31283127
const actualRuntimeOrigin = this.#runtimeEntryURL.origin;
3129-
const userRuntimeOrigin = url.origin;
3130-
3131-
// Rewrite URL for WebSocket requests which won't use `DispatchFetchDispatcher`
3132-
url.protocol = this.#runtimeEntryURL.protocol;
3133-
url.host = this.#runtimeEntryURL.host;
3128+
const userRuntimeOrigin = new URL(forward.url).origin;
31343129

31353130
// Remove `Content-Length: 0` headers from requests when a body is set to
31363131
// avoid `RequestContentLengthMismatch` errors
@@ -3150,9 +3145,10 @@ export class Miniflare {
31503145
cfBlob
31513146
);
31523147

3153-
const forwardInit = forward as RequestInit;
3154-
forwardInit.dispatcher = dispatcher;
3155-
const response = await fetch(url, forwardInit);
3148+
// Pass `forward` as the input, not `init`, so its body keeps a known length.
3149+
// As `init`, only the body stream is copied and it's sent chunked, which
3150+
// APIs like `R2Bucket#put()` reject. `dispatcher` routes it to the runtime.
3151+
const response = await fetch(forward, { dispatcher });
31563152

31573153
// If the Worker threw an uncaught exception, propagate it to the caller
31583154
const stack = response.headers.get(CoreHeaders.ERROR_STACK);

‎packages/miniflare/test/index.spec.ts‎

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import {
2222
LogLevel,
2323
Miniflare,
2424
MiniflareCoreError,
25+
Request,
2526
Response,
2627
viewToBuffer,
2728
} from "miniflare";
@@ -5183,3 +5184,43 @@ test("Miniflare: dispatchFetch handles POST/PUT with non-2xx status", async ({
51835184
expect(await res.json()).toEqual({ method: "PUT", status });
51845185
}
51855186
});
5187+
5188+
test("Miniflare: dispatchFetch() preserves request body length", async ({
5189+
expect,
5190+
}) => {
5191+
const mf = new Miniflare({
5192+
workers: [
5193+
{
5194+
config: {
5195+
name: "",
5196+
compatibilityDate: "2025-05-01",
5197+
manifest: singleModuleManifest(`export default {
5198+
async fetch(request, env) {
5199+
await env.BUCKET.put("key", request.body);
5200+
const object = await env.BUCKET.get("key");
5201+
return Response.json({
5202+
contentLength: request.headers.get("Content-Length"),
5203+
body: await object.text(),
5204+
});
5205+
}
5206+
}`),
5207+
env: { BUCKET: { type: "r2", name: "BUCKET" } },
5208+
},
5209+
},
5210+
],
5211+
});
5212+
useDispose(mf);
5213+
5214+
// `R2Bucket#put()` requires a known length, as it does for requests with a
5215+
// `Content-Length` in production
5216+
let res = await mf.dispatchFetch("http://localhost/", {
5217+
method: "PUT",
5218+
body: "value",
5219+
});
5220+
expect(await res.json()).toEqual({ contentLength: "5", body: "value" });
5221+
5222+
res = await mf.dispatchFetch(
5223+
new Request("http://localhost/", { method: "PUT", body: "request" })
5224+
);
5225+
expect(await res.json()).toEqual({ contentLength: "7", body: "request" });
5226+
});

0 commit comments

Comments
 (0)