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"