diff --git a/.changeset/honest-chicken-smash.md b/.changeset/honest-chicken-smash.md
new file mode 100644
index 0000000000..cc6b8796ec
--- /dev/null
+++ b/.changeset/honest-chicken-smash.md
@@ -0,0 +1,5 @@
+---
+"@react-router/express": patch
+---
+
+feat(express): add support for readable stream already closed
diff --git a/contributors.yml b/contributors.yml
index 8b7f8b4876..1bcd94fb14 100644
--- a/contributors.yml
+++ b/contributors.yml
@@ -122,6 +122,7 @@
- haivuw
- hampelm
- harshmangalam
+- HenriqueLimas
- hernanif1
- HK-SHAO
- holynewbie
diff --git a/integration/request-test.ts b/integration/request-test.ts
index e75c024edc..feb4ab367c 100644
--- a/integration/request-test.ts
+++ b/integration/request-test.ts
@@ -7,171 +7,248 @@ import {
createFixture,
js,
} from "./helpers/create-fixture.js";
+import getPort from "get-port";
+import { createProject, customDev, viteConfig } from "./helpers/vite.js";
let fixture: Fixture;
let appFixture: AppFixture;
-test.beforeAll(async () => {
- fixture = await createFixture({
- files: {
- "app/routes/_index.tsx": js`
- import { Form, useLoaderData, useActionData } from "react-router";
-
- async function requestToJson(request) {
- let body = null;
-
- if (request.body) {
- let fd = await request.formData();
- body = Object.fromEntries(fd.entries());
- }
-
- return {
- method: request.method,
- url: request.url,
- headers: Object.fromEntries(request.headers.entries()),
- body,
- };
- }
- export async function loader({ request }) {
- return requestToJson(request);
- }
- export function action({ request }) {
- return requestToJson(request);
+const sharedFiles = {
+ "app/routes/_index.tsx": js`
+ import { Form, useLoaderData, useActionData } from "react-router";
+
+ async function requestToJson(request) {
+ let body = null;
+
+ if (request.body) {
+ let fd = await request.formData();
+ body = Object.fromEntries(fd.entries());
}
- export default function Index() {
- let loaderData = useLoaderData();
- let actionData = useActionData();
- return (
-
-
+ )
+ }
+ `,
+};
+
+test.describe("Request Tests", () => {
+ test.beforeAll(async () => {
+ fixture = await createFixture({
+ files: {
+ ...sharedFiles,
+ },
+ });
+
+ appFixture = await createAppFixture(fixture);
});
- appFixture = await createAppFixture(fixture);
-});
+ test.afterAll(() => appFixture.close());
-test.afterAll(() => appFixture.close());
+ test("loader request on SSR GET requests", async ({ page }) => {
+ let app = new PlaywrightFixture(appFixture, page);
+ await app.goto("/");
+ await app.clickElement("#set-cookie");
-test("loader request on SSR GET requests", async ({ page }) => {
- let app = new PlaywrightFixture(appFixture, page);
- await app.goto("/");
- await app.clickElement("#set-cookie");
+ let loaderData = JSON.parse(await page.locator("#loader-data").innerHTML());
+ expect(loaderData.method).toEqual("GET");
+ expect(loaderData.url).toMatch(/^http:\/\/localhost:\d+\/$/);
+ expect(loaderData.headers.cookie).toEqual(undefined);
+ expect(loaderData.body).toEqual(null);
- let loaderData = JSON.parse(await page.locator("#loader-data").innerHTML());
- expect(loaderData.method).toEqual("GET");
- expect(loaderData.url).toMatch(/^http:\/\/localhost:\d+\/$/);
- expect(loaderData.headers.cookie).toEqual(undefined);
- expect(loaderData.body).toEqual(null);
+ await app.clickElement("#submit-get-ssr");
- await app.clickElement("#submit-get-ssr");
+ loaderData = JSON.parse(await page.locator("#loader-data").innerHTML());
+ expect(loaderData.method).toEqual("GET");
+ expect(loaderData.url).toMatch(/^http:\/\/localhost:\d+\/\?type=ssr$/);
+ expect(loaderData.headers.cookie).toEqual("cookie=nomnom");
+ expect(loaderData.body).toEqual(null);
+ });
- loaderData = JSON.parse(await page.locator("#loader-data").innerHTML());
- expect(loaderData.method).toEqual("GET");
- expect(loaderData.url).toMatch(/^http:\/\/localhost:\d+\/\?type=ssr$/);
- expect(loaderData.headers.cookie).toEqual("cookie=nomnom");
- expect(loaderData.body).toEqual(null);
-});
+ test("loader request on CSR GET requests", async ({ page }) => {
+ let app = new PlaywrightFixture(appFixture, page);
+ await app.goto("/");
+ await app.clickElement("#set-cookie");
-test("loader request on CSR GET requests", async ({ page }) => {
- let app = new PlaywrightFixture(appFixture, page);
- await app.goto("/");
- await app.clickElement("#set-cookie");
+ let loaderData = JSON.parse(await page.locator("#loader-data").innerHTML());
+ expect(loaderData.method).toEqual("GET");
+ expect(loaderData.url).toMatch(/^http:\/\/localhost:\d+\/$/);
+ expect(loaderData.headers.cookie).toEqual(undefined);
+ expect(loaderData.body).toEqual(null);
- let loaderData = JSON.parse(await page.locator("#loader-data").innerHTML());
- expect(loaderData.method).toEqual("GET");
- expect(loaderData.url).toMatch(/^http:\/\/localhost:\d+\/$/);
- expect(loaderData.headers.cookie).toEqual(undefined);
- expect(loaderData.body).toEqual(null);
+ await app.clickElement("#submit-get-csr");
- await app.clickElement("#submit-get-csr");
+ loaderData = JSON.parse(await page.locator("#loader-data").innerHTML());
+ expect(loaderData.method).toEqual("GET");
+ expect(loaderData.url).toMatch(/^http:\/\/localhost:\d+\/\?type=csr$/);
+ expect(loaderData.headers.cookie).toEqual("cookie=nomnom");
+ expect(loaderData.body).toEqual(null);
+ });
- loaderData = JSON.parse(await page.locator("#loader-data").innerHTML());
- expect(loaderData.method).toEqual("GET");
- expect(loaderData.url).toMatch(/^http:\/\/localhost:\d+\/\?type=csr$/);
- expect(loaderData.headers.cookie).toEqual("cookie=nomnom");
- expect(loaderData.body).toEqual(null);
-});
+ test("action + loader requests SSR POST requests", async ({ page }) => {
+ let app = new PlaywrightFixture(appFixture, page);
+ await app.goto("/");
+ await app.clickElement("#set-cookie");
+
+ let loaderData = JSON.parse(await page.locator("#loader-data").innerHTML());
+ expect(loaderData.method).toEqual("GET");
+ expect(loaderData.url).toMatch(/^http:\/\/localhost:\d+\/$/);
+ expect(loaderData.headers.cookie).toEqual(undefined);
+ expect(loaderData.body).toEqual(null);
+
+ await app.clickElement("#submit-post-ssr");
+
+ let actionData = JSON.parse(await page.locator("#action-data").innerHTML());
+ expect(actionData.method).toEqual("POST");
+ expect(actionData.url).toMatch(/^http:\/\/localhost:\d+\/$/);
+ expect(actionData.headers.cookie).toEqual("cookie=nomnom");
+ expect(actionData.body).toEqual({ type: "ssr" });
+
+ loaderData = JSON.parse(await page.locator("#loader-data").innerHTML());
+ expect(loaderData.method).toEqual("GET");
+ expect(loaderData.url).toMatch(/^http:\/\/localhost:\d+\/$/);
+ expect(loaderData.headers.cookie).toEqual("cookie=nomnom");
+ expect(loaderData.body).toEqual(null);
+ });
-test("action + loader requests SSR POST requests", async ({ page }) => {
- let app = new PlaywrightFixture(appFixture, page);
- await app.goto("/");
- await app.clickElement("#set-cookie");
-
- let loaderData = JSON.parse(await page.locator("#loader-data").innerHTML());
- expect(loaderData.method).toEqual("GET");
- expect(loaderData.url).toMatch(/^http:\/\/localhost:\d+\/$/);
- expect(loaderData.headers.cookie).toEqual(undefined);
- expect(loaderData.body).toEqual(null);
-
- await app.clickElement("#submit-post-ssr");
-
- let actionData = JSON.parse(await page.locator("#action-data").innerHTML());
- expect(actionData.method).toEqual("POST");
- expect(actionData.url).toMatch(/^http:\/\/localhost:\d+\/$/);
- expect(actionData.headers.cookie).toEqual("cookie=nomnom");
- expect(actionData.body).toEqual({ type: "ssr" });
-
- loaderData = JSON.parse(await page.locator("#loader-data").innerHTML());
- expect(loaderData.method).toEqual("GET");
- expect(loaderData.url).toMatch(/^http:\/\/localhost:\d+\/$/);
- expect(loaderData.headers.cookie).toEqual("cookie=nomnom");
- expect(loaderData.body).toEqual(null);
+ test("action + loader requests on CSR POST requests", async ({ page }) => {
+ let app = new PlaywrightFixture(appFixture, page);
+ await app.goto("/");
+ await app.clickElement("#set-cookie");
+
+ let loaderData = JSON.parse(await page.locator("#loader-data").innerHTML());
+ expect(loaderData.method).toEqual("GET");
+ expect(loaderData.url).toMatch(/^http:\/\/localhost:\d+\/$/);
+ expect(loaderData.headers.cookie).toEqual(undefined);
+ expect(loaderData.body).toEqual(null);
+
+ await app.clickElement("#submit-post-csr");
+
+ let actionData = JSON.parse(await page.locator("#action-data").innerHTML());
+ expect(actionData.method).toEqual("POST");
+ expect(actionData.url).toMatch(/^http:\/\/localhost:\d+\/$/);
+ expect(actionData.headers.cookie).toEqual("cookie=nomnom");
+ expect(actionData.body).toEqual({ type: "csr" });
+
+ loaderData = JSON.parse(await page.locator("#loader-data").innerHTML());
+ expect(loaderData.method).toEqual("GET");
+ expect(loaderData.url).toMatch(/^http:\/\/localhost:\d+\/$/);
+ expect(loaderData.headers.cookie).toEqual("cookie=nomnom");
+ expect(loaderData.body).toEqual(null);
+ });
});
-test("action + loader requests on CSR POST requests", async ({ page }) => {
- let app = new PlaywrightFixture(appFixture, page);
- await app.goto("/");
- await app.clickElement("#set-cookie");
-
- let loaderData = JSON.parse(await page.locator("#loader-data").innerHTML());
- expect(loaderData.method).toEqual("GET");
- expect(loaderData.url).toMatch(/^http:\/\/localhost:\d+\/$/);
- expect(loaderData.headers.cookie).toEqual(undefined);
- expect(loaderData.body).toEqual(null);
-
- await app.clickElement("#submit-post-csr");
-
- let actionData = JSON.parse(await page.locator("#action-data").innerHTML());
- expect(actionData.method).toEqual("POST");
- expect(actionData.url).toMatch(/^http:\/\/localhost:\d+\/$/);
- expect(actionData.headers.cookie).toEqual("cookie=nomnom");
- expect(actionData.body).toEqual({ type: "csr" });
-
- loaderData = JSON.parse(await page.locator("#loader-data").innerHTML());
- expect(loaderData.method).toEqual("GET");
- expect(loaderData.url).toMatch(/^http:\/\/localhost:\d+\/$/);
- expect(loaderData.headers.cookie).toEqual("cookie=nomnom");
- expect(loaderData.body).toEqual(null);
+test.describe("Request stream already closed", () => {
+ let port: number;
+ let cwd: string;
+ let stop: () => void;
+
+ test.beforeAll(async () => {
+ port = await getPort();
+ cwd = await createProject({
+ ...sharedFiles,
+ "vite.config.ts": await viteConfig.basic({ port }),
+ "server.mjs": String.raw`
+ import { createRequestHandler } from "@react-router/express";
+ import express from "express";
+
+ let viteDevServer =
+ process.env.NODE_ENV === "production"
+ ? undefined
+ : await import("vite").then((vite) =>
+ vite.createServer({
+ server: { middlewareMode: true },
+ })
+ );
+
+ const app = express();
+
+ app.use(express.json());
+ app.use(express.urlencoded());
+
+ if (viteDevServer) {
+ app.use(viteDevServer.middlewares);
+ } else {
+ app.use(
+ "/assets",
+ express.static("build/client/assets", { immutable: true, maxAge: "1y" })
+ );
+ }
+ app.use(express.static("build/client", { maxAge: "1h" }));
+
+ app.all(
+ "*",
+ createRequestHandler({
+ build: viteDevServer
+ ? () => viteDevServer.ssrLoadModule("virtual:react-router/server-build")
+ : await import("./build/index.js"),
+ getLoadContext: () => ({}),
+ })
+ );
+
+ const port = ${port};
+ app.listen(port, () => console.log('http://localhost:' + port));
+ `,
+ });
+ stop = await customDev({ cwd, port });
+ });
+
+ test.afterAll(() => stop());
+
+ test("action with formData read", async ({ page }) => {
+ await page.goto(`http://localhost:${port}`, {
+ waitUntil: "networkidle",
+ });
+
+ await page.locator("#submit-post-csr").click();
+
+ let actionData = JSON.parse(await page.locator("#action-data").innerHTML());
+ expect(actionData.body).toEqual({ type: "csr" });
+ });
});
diff --git a/packages/react-router-express/server.ts b/packages/react-router-express/server.ts
index 88248760c2..b56a7d2080 100644
--- a/packages/react-router-express/server.ts
+++ b/packages/react-router-express/server.ts
@@ -122,7 +122,18 @@ export function createRemixRequest(
res.on("close", () => controller?.abort());
if (req.method !== "GET" && req.method !== "HEAD") {
- init.body = createReadableStreamFromReadable(req);
+ if (req.closed) {
+ if (
+ req.is("application/x-www-form-urlencoded") ||
+ req.is("multipart/form-data")
+ ) {
+ init.body = new URLSearchParams(req.body);
+ } else {
+ init.body = JSON.stringify(req.body || {});
+ }
+ } else {
+ init.body = createReadableStreamFromReadable(req);
+ }
(init as { duplex: "half" }).duplex = "half";
}