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 ( -
- +
+ - - -
-
- -
-
- -
-
- -
-
{JSON.stringify(loaderData)}
- {actionData ? -
{JSON.stringify(actionData)}
: - null} -
- ) - } - `, - }, + +
+ +
+
+ +
+
+ +
+
{JSON.stringify(loaderData)}
+ {actionData ? +
{JSON.stringify(actionData)}
: + null} + + ) + } + `, +}; + +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"; }