diff --git a/.changeset/witty-gifts-glow.md b/.changeset/witty-gifts-glow.md new file mode 100644 index 00000000000..cc38f656043 --- /dev/null +++ b/.changeset/witty-gifts-glow.md @@ -0,0 +1,5 @@ +--- +"@remix-run/dev": patch +--- + +Fix `request instanceof Request` checks when using Vite dev server diff --git a/integration/vite-dev-custom-entry-test.ts b/integration/vite-dev-custom-entry-test.ts new file mode 100644 index 00000000000..e609e5391e9 --- /dev/null +++ b/integration/vite-dev-custom-entry-test.ts @@ -0,0 +1,177 @@ +import { test, expect } from "@playwright/test"; +import type { Readable } from "node:stream"; +import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; +import resolveBin from "resolve-bin"; +import getPort from "get-port"; +import waitOn from "wait-on"; + +import { createFixtureProject, js } from "./helpers/create-fixture.js"; +import { killtree } from "./helpers/killtree.js"; + +test.describe("Vite custom entry dev", () => { + let projectDir: string; + let devProc: ChildProcessWithoutNullStreams; + let devPort: number; + + test.beforeAll(async () => { + devPort = await getPort(); + projectDir = await createFixtureProject({ + compiler: "vite", + files: { + "remix.config.js": js` + throw new Error("Remix should not access remix.config.js when using Vite"); + export default {}; + `, + "vite.config.ts": js` + import { defineConfig } from "vite"; + import { unstable_vitePlugin as remix } from "@remix-run/dev"; + + export default defineConfig({ + server: { + port: ${devPort}, + strictPort: true, + }, + plugins: [ + remix(), + ], + }); + `, + "app/entry.server.tsx": js` + import { PassThrough } from "node:stream"; + + import type { EntryContext } from "@remix-run/node"; + import { createReadableStreamFromReadable } from "@remix-run/node"; + import { RemixServer } from "@remix-run/react"; + import { renderToPipeableStream } from "react-dom/server"; + + const ABORT_DELAY = 5_000; + + export default function handleRequest( + request: Request, + responseStatusCode: number, + responseHeaders: Headers, + remixContext: EntryContext + ) { + return new Promise((resolve, reject) => { + let shellRendered = false; + const { pipe, abort } = renderToPipeableStream( + , + { + onShellReady() { + shellRendered = true; + const body = new PassThrough(); + const stream = createReadableStreamFromReadable(body); + + responseHeaders.set("Content-Type", "text/html"); + + // Used to test that the request object is an instance of the global Request constructor + responseHeaders.set("x-test-request-instanceof-request", String(request instanceof Request)); + + resolve( + new Response(stream, { + headers: responseHeaders, + status: responseStatusCode, + }) + ); + + pipe(body); + }, + onShellError(error: unknown) { + reject(error); + }, + onError(error: unknown) { + responseStatusCode = 500; + // Log streaming rendering errors from inside the shell. Don't log + // errors encountered during initial shell rendering since they'll + // reject and get logged in handleDocumentRequest. + if (shellRendered) { + console.error(error); + } + }, + } + ); + + setTimeout(abort, ABORT_DELAY); + }); + } + `, + "app/root.tsx": js` + import { Links, Meta, Outlet, Scripts, LiveReload } from "@remix-run/react"; + + export default function Root() { + return ( + + + + + + +
+

Root

+ +
+ + + + + ); + } + `, + "app/routes/_index.tsx": js` + export default function IndexRoute() { + return
IndexRoute
+ } + `, + }, + }); + + let nodeBin = process.argv[0]; + let viteBin = resolveBin.sync("vite"); + devProc = spawn(nodeBin, [viteBin, "dev"], { + cwd: projectDir, + env: process.env, + stdio: "pipe", + }); + let devStdout = bufferize(devProc.stdout); + let devStderr = bufferize(devProc.stderr); + + await waitOn({ + resources: [`http://localhost:${devPort}/`], + timeout: 10000, + }).catch((err) => { + let stdout = devStdout(); + let stderr = devStderr(); + throw new Error( + [ + err.message, + "", + "exit code: " + devProc.exitCode, + "stdout: " + stdout ? `\n${stdout}\n` : "", + "stderr: " + stderr ? `\n${stderr}\n` : "", + ].join("\n") + ); + }); + }); + + test.afterAll(async () => { + devProc.pid && (await killtree(devProc.pid)); + }); + + // Ensure libraries/consumers can perform an instanceof check on the request + test("request instanceof Request", async ({ request }) => { + let res = await request.get(`http://localhost:${devPort}/`); + expect(res.headers()).toMatchObject({ + "x-test-request-instanceof-request": "true", + }); + }); +}); + +let bufferize = (stream: Readable): (() => string) => { + let buffer = ""; + stream.on("data", (data) => (buffer += data.toString())); + return () => buffer; +}; diff --git a/packages/remix-dev/package.json b/packages/remix-dev/package.json index 59f44d8fd66..d4486e1496a 100644 --- a/packages/remix-dev/package.json +++ b/packages/remix-dev/package.json @@ -55,7 +55,6 @@ "minimatch": "^9.0.0", "node-fetch": "^2.6.9", "ora": "^5.4.1", - "parse-multipart-data": "^1.5.0", "picocolors": "^1.0.0", "picomatch": "^2.3.1", "pidtree": "^0.6.0", @@ -72,7 +71,6 @@ "set-cookie-parser": "^2.6.0", "tar-fs": "^2.1.1", "tsconfig-paths": "^4.0.0", - "undici": "^5.22.1", "ws": "^7.4.5" }, "devDependencies": { diff --git a/packages/remix-dev/vite/node/adapter.ts b/packages/remix-dev/vite/node/adapter.ts index 03b920b0598..8b8886ecd8c 100644 --- a/packages/remix-dev/vite/node/adapter.ts +++ b/packages/remix-dev/vite/node/adapter.ts @@ -1,69 +1,23 @@ -// @ts-nocheck -// adapted from https://github.com/solidjs/solid-start/blob/ccff60ce75e066f6613daf0272dbb43a196235a4/packages/start/node/fetch.js -import { once } from "events"; -import { type IncomingMessage, type ServerResponse } from "http"; -import multipart from "parse-multipart-data"; +import type { + IncomingHttpHeaders, + IncomingMessage, + ServerResponse, +} from "node:http"; +import { once } from "node:events"; +import { Readable } from "node:stream"; import { splitCookiesString } from "set-cookie-parser"; -import { Readable } from "stream"; -import { File, FormData, Headers, Request as BaseNodeRequest } from "undici"; -import { type ServerBuild, installGlobals } from "@remix-run/node"; +import { + type ServerBuild, + installGlobals, + createReadableStreamFromReadable, +} from "@remix-run/node"; import { createRequestHandler as createBaseRequestHandler } from "@remix-run/server-runtime"; -installGlobals(); - -function nodeToWeb(nodeStream) { - let destroyed = false; - let listeners = {}; - - function start(controller) { - listeners["data"] = onData; - listeners["end"] = onData; - listeners["end"] = onDestroy; - listeners["close"] = onDestroy; - listeners["error"] = onDestroy; - for (let name in listeners) nodeStream.on(name, listeners[name]); - - nodeStream.pause(); - - function onData(chunk) { - if (destroyed) return; - controller.enqueue(chunk); - nodeStream.pause(); - } - - function onDestroy(err) { - if (destroyed) return; - destroyed = true; - - for (let name in listeners) - nodeStream.removeListener(name, listeners[name]); +import invariant from "../../invariant"; - if (err) controller.error(err); - else controller.close(); - } - } - - function pull() { - if (destroyed) return; - nodeStream.resume(); - } - - function cancel() { - destroyed = true; - - for (let name in listeners) - nodeStream.removeListener(name, listeners[name]); - - nodeStream.push(null); - nodeStream.pause(); - if (nodeStream.destroy) nodeStream.destroy(); - else if (nodeStream.close) nodeStream.close(); - } - - return new ReadableStream({ start: start, pull: pull, cancel: cancel }); -} +installGlobals(); -function createHeaders(requestHeaders) { +function createHeaders(requestHeaders: IncomingHttpHeaders) { let headers = new Headers(); for (let [key, values] of Object.entries(requestHeaders)) { @@ -81,93 +35,33 @@ function createHeaders(requestHeaders) { return headers; } -class NodeRequest extends BaseNodeRequest { - constructor(input, init) { - if (init && init.data && init.data.on) { - init = { - duplex: "half", - ...init, - body: init.data.headers["content-type"]?.includes("x-www") - ? init.data - : nodeToWeb(init.data), - }; - } - - super(input, init); - } - - // async json() { - // return JSON.parse(await this.text()); - // } - - async buffer() { - return Buffer.from(await super.arrayBuffer()); - } - - // async text() { - // return (await this.buffer()).toString(); - // } - - // @ts-ignore - async formData() { - if ( - this.headers.get("content-type") === "application/x-www-form-urlencoded" - ) { - return await super.formData(); - } else { - let data = await this.buffer(); - let input = multipart.parse( - data, - this.headers - .get("content-type") - .replace("multipart/form-data; boundary=", "") - ); - let form = new FormData(); - input.forEach(({ name, data, filename, type }) => { - // file fields have Content-Type set, - // whereas non-file fields must not - // https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#multipart-form-data - let isFile = type !== undefined; - if (isFile) { - let value = new File([data], filename, { type }); - form.append(name, value, filename); - } else { - let value = data.toString("utf-8"); - form.append(name, value); - } - }); - return form; - } - } - - // @ts-ignore - clone() { - /** @type {BaseNodeRequest & { buffer?: () => Promise; formData?: () => Promise }} */ - let el = super.clone(); - el.buffer = this.buffer.bind(el); - el.formData = this.formData.bind(el); - return el; - } -} - -function createRequest(req: IncomingMessage): Request { +// Based on `createRemixRequest` in packages/remix-express/server.ts +function createRequest(req: IncomingMessage, res: ServerResponse): Request { let origin = req.headers.origin && "null" !== req.headers.origin ? req.headers.origin : `http://${req.headers.host}`; + invariant(req.url, 'Expected "req.url" to be defined'); let url = new URL(req.url, origin); - let init = { + let controller = new AbortController(); + res.on("close", () => controller.abort()); + + let init: RequestInit = { method: req.method, headers: createHeaders(req.headers), - // https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods - data: ["POST", "PUT", "DELETE", "PATCH"].includes(req.method) ? req : null, + signal: controller.signal, }; - return new NodeRequest(url.href, init); + if (req.method !== "GET" && req.method !== "HEAD") { + init.body = createReadableStreamFromReadable(req); + (init as { duplex: "half" }).duplex = "half"; + } + + return new Request(url.href, init); } -// Adapted from more recent version of `handleNodeResponse`: +// Adapted from solid-start's `handleNodeResponse`: // https://github.com/solidjs/solid-start/blob/7398163869b489cce503c167e284891cf51a6613/packages/start/node/fetch.js#L162-L185 async function handleNodeResponse(webRes: Response, res: ServerResponse) { res.statusCode = webRes.status; @@ -186,7 +80,9 @@ async function handleNodeResponse(webRes: Response, res: ServerResponse) { } if (webRes.body) { - let readable = Readable.from(webRes.body); + // https://github.com/microsoft/TypeScript/issues/29867 + let responseBody = webRes.body as unknown as AsyncIterable; + let readable = Readable.from(responseBody); readable.pipe(res); await once(readable, "end"); } else { @@ -196,15 +92,11 @@ async function handleNodeResponse(webRes: Response, res: ServerResponse) { export let createRequestHandler = ( build: ServerBuild, - { - mode = "production", - }: { - mode?: string; - } + { mode = "production" }: { mode?: string } ) => { let handler = createBaseRequestHandler(build, mode); return async (req: IncomingMessage, res: ServerResponse) => { - let request = createRequest(req); + let request = createRequest(req, res); let response = await handler(request, {}); handleNodeResponse(response, res); }; diff --git a/yarn.lock b/yarn.lock index 0ad802df0e7..682e1028180 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1679,11 +1679,6 @@ resolved "https://registry.npmjs.org/@extra-number/significant-digits/-/significant-digits-1.3.9.tgz" integrity sha512-E5PY/bCwrNqEHh4QS6AQBinLZ+sxM1lT8tsSVYk8VwhWIPp6fCU/BMRVq0V8iJ8LwS3FHmaA4vUzb78s4BIIyA== -"@fastify/busboy@^2.0.0": - version "2.0.0" - resolved "https://registry.npmjs.org/@fastify/busboy/-/busboy-2.0.0.tgz#f22824caff3ae506b18207bad4126dbc6ccdb6b8" - integrity sha512-JUFJad5lv7jxj926GPgymrWQxxjPYuJNiNjNMzqT+HiuP6Vl3dk5xzG+8sTX96np0ZAluvaMzPsjhHZ5rNuNQQ== - "@hapi/hoek@^9.0.0": version "9.3.0" resolved "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.3.0.tgz#8368869dcb735be2e7f5cb7647de78e167a251fb" @@ -10381,11 +10376,6 @@ parse-ms@^2.1.0: resolved "https://registry.npmjs.org/parse-ms/-/parse-ms-2.1.0.tgz" integrity sha512-kHt7kzLoS9VBZfUsiKjv43mr91ea+U05EyKkEtqp7vNbHxmaVuEqN7XxeEVnGrMtYOAxGrDElSi96K7EgO1zCA== -parse-multipart-data@^1.5.0: - version "1.5.0" - resolved "https://registry.npmjs.org/parse-multipart-data/-/parse-multipart-data-1.5.0.tgz#ab894cc6c40229d0a2042500e120df7562d94b87" - integrity sha512-ck5zaMF0ydjGfejNMnlo5YU2oJ+pT+80Jb1y4ybanT27j+zbVP/jkYmCrUGsEln0Ox/hZmuvgy8Ra7AxbXP2Mw== - parse5-htmlparser2-tree-adapter@^7.0.0: version "7.0.0" resolved "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-7.0.0.tgz#23c2cc233bcf09bb7beba8b8a69d46b08c62c2f1" @@ -12730,13 +12720,6 @@ unbox-primitive@^1.0.2: has-symbols "^1.0.3" which-boxed-primitive "^1.0.2" -undici@^5.22.1: - version "5.25.4" - resolved "https://registry.npmjs.org/undici/-/undici-5.25.4.tgz#7d8ef81d94f84cd384986271e5e5599b6dff4296" - integrity sha512-450yJxT29qKMf3aoudzFpIciqpx6Pji3hEWaXqXmanbXF58LTAGCKxcJjxMXWu3iG+Mudgo3ZUfDB6YDFd/dAw== - dependencies: - "@fastify/busboy" "^2.0.0" - unicode-canonical-property-names-ecmascript@^2.0.0: version "2.0.0" resolved "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.0.tgz"