diff --git a/.changeset/dirty-bags-wink.md b/.changeset/dirty-bags-wink.md
new file mode 100644
index 00000000000..e4b3c406d0f
--- /dev/null
+++ b/.changeset/dirty-bags-wink.md
@@ -0,0 +1,5 @@
+---
+"@remix-run/serve": major
+---
+
+integrate manual mode in remix-serve
diff --git a/integration/helpers/create-fixture.ts b/integration/helpers/create-fixture.ts
index 743dd98b152..c513017ff4c 100644
--- a/integration/helpers/create-fixture.ts
+++ b/integration/helpers/create-fixture.ts
@@ -137,7 +137,7 @@ export async function createAppFixture(fixture: Fixture, mode?: ServerMode) {
let newChunk = chunk.toString();
stdout += newChunk;
let match: RegExpMatchArray | null = stdout.match(
- /started at http:\/\/localhost:(\d+)\s/
+ /\[remix-serve\] http:\/\/localhost:(\d+)\s/
);
if (match) {
clearTimeout(rejectTimeout);
@@ -223,6 +223,26 @@ export async function createFixtureProject(
path.join(projectDir, "node_modules"),
{ overwrite: true }
);
+ // let remixDev = path.join(
+ // projectDir,
+ // "node_modules/@remix-run/dev/dist/cli.js"
+ // );
+ // await fse.chmod(remixDev, 0o755);
+ // await fse.ensureSymlink(
+ // remixDev,
+ // path.join(projectDir, "node_modules/.bin/remix")
+ // );
+ //
+ // let remixServe = path.join(
+ // projectDir,
+ // "node_modules/@remix-run/serve/dist/cli.js"
+ // );
+ // await fse.chmod(remixServe, 0o755);
+ // await fse.ensureSymlink(
+ // remixServe,
+ // path.join(projectDir, "node_modules/.bin/remix-serve")
+ // );
+
await writeTestFiles(init, projectDir);
// We update the config file *after* writing test files so that tests can provide a custom
diff --git a/integration/helpers/node-template/package.json b/integration/helpers/node-template/package.json
index e202cff6ca6..05953d0ec46 100644
--- a/integration/helpers/node-template/package.json
+++ b/integration/helpers/node-template/package.json
@@ -6,7 +6,7 @@
"scripts": {
"build": "node ../../../build/node_modules/@remix-run/dev/dist/cli.js build",
"dev": "node ../../../build/node_modules/@remix-run/dev/dist/cli.js dev",
- "start": "node ../../../build/node_modules/@remix-run/serve/dist/cli.js build"
+ "start": "node ../../../build/node_modules/@remix-run/serve/dist/cli.js ./build/index.js"
},
"dependencies": {
"@remix-run/node": "0.0.0-local-version",
diff --git a/integration/hmr-log-test.ts b/integration/hmr-log-test.ts
deleted file mode 100644
index 750b893430d..00000000000
--- a/integration/hmr-log-test.ts
+++ /dev/null
@@ -1,561 +0,0 @@
-import { test, expect } from "@playwright/test";
-import execa from "execa";
-import fs from "node:fs";
-import path from "node:path";
-import type { Readable } from "node:stream";
-import getPort, { makeRange } from "get-port";
-
-import type { FixtureInit } from "./helpers/create-fixture.js";
-import {
- createFixtureProject,
- css,
- js,
- json,
-} from "./helpers/create-fixture.js";
-
-test.setTimeout(120_000);
-
-let fixture = (options: { appPort: number; devPort: number }): FixtureInit => ({
- config: {
- dev: {
- port: options.devPort,
- },
- },
- files: {
- "package.json": json({
- private: true,
- sideEffects: false,
- type: "module",
- scripts: {
- dev: `node ./node_modules/@remix-run/dev/dist/cli.js dev -c "node ./server.js"`,
- },
- dependencies: {
- "@remix-run/css-bundle": "0.0.0-local-version",
- "@remix-run/node": "0.0.0-local-version",
- "@remix-run/react": "0.0.0-local-version",
- "cross-env": "0.0.0-local-version",
- express: "0.0.0-local-version",
- isbot: "0.0.0-local-version",
- react: "0.0.0-local-version",
- "react-dom": "0.0.0-local-version",
- tailwindcss: "0.0.0-local-version",
- },
- devDependencies: {
- "@remix-run/dev": "0.0.0-local-version",
- "@types/react": "0.0.0-local-version",
- "@types/react-dom": "0.0.0-local-version",
- typescript: "0.0.0-local-version",
- },
- engines: {
- node: ">=18.0.0",
- },
- }),
-
- "server.js": js`
- import path from "path";
- import url from "url";
- import express from "express";
- import { createRequestHandler } from "@remix-run/express";
- import { logDevReady, installGlobals } from "@remix-run/node";
-
- installGlobals();
-
- const app = express();
- app.use(express.static("public", { immutable: true, maxAge: "1y" }));
-
- const MODE = process.env.NODE_ENV;
- const BUILD_PATH = url.pathToFileURL(path.join(process.cwd(), "build", "index.js"));
-
- app.all(
- "*",
- createRequestHandler({
- build: await import(BUILD_PATH),
- mode: MODE,
- })
- );
-
- let port = ${options.appPort};
- app.listen(port, async () => {
- let build = await import(BUILD_PATH);
- console.log('✅ app ready: http://localhost:' + port);
- if (process.env.NODE_ENV === 'development') {
- logDevReady(build);
- }
- });
- `,
-
- "tailwind.config.js": js`
- /** @type {import('tailwindcss').Config} */
- module.exports = {
- content: ["./app/**/*.{ts,tsx,jsx,js}"],
- theme: {
- extend: {},
- },
- plugins: [],
- };
- `,
-
- "app/tailwind.css": css`
- @tailwind base;
- @tailwind components;
- @tailwind utilities;
- `,
-
- "app/styles.module.css": css`
- .test {
- color: initial;
- }
- `,
-
- "app/root.tsx": js`
- import type { LinksFunction } from "@remix-run/node";
- import { Link, Links, LiveReload, Meta, Outlet, Scripts } from "@remix-run/react";
- import { cssBundleHref } from "@remix-run/css-bundle";
-
- import Counter from "./components/counter";
- import styles from "./tailwind.css";
-
- export const links: LinksFunction = () => [
- { rel: "stylesheet", href: styles },
- ...cssBundleHref ? [{ rel: "stylesheet", href: cssBundleHref }] : [],
- ];
-
- // dummy loader to make sure that HDR is granular
- export const loader = () => {
- return null;
- };
-
- export default function Root() {
- return (
-
-
-
-
-
-
-
-
-
-
-
-
- );
- }
- `,
-
- "app/routes/_index.tsx": js`
- import { useLoaderData } from "@remix-run/react";
- export function shouldRevalidate(args) {
- return true;
- }
- export default function Index() {
- const t = useLoaderData();
- return (
-
- Index Title
-
- )
- }
- `,
-
- "app/routes/about.tsx": js`
- import Counter from "../components/counter";
- export default function About() {
- return (
-
- About Title
-
-
- )
- }
- `,
- "app/routes/mdx.mdx": `import { useLoaderData } from '@remix-run/react'
-export const loader = () => "crazy"
-export const Component = () => {
- const data = useLoaderData()
- return {data}
-}
-
-# heyo
-whatsup
-
-
-`,
-
- "app/components/counter.tsx": js`
- import * as React from "react";
- export default function Counter({ id }) {
- let [count, setCount] = React.useState(0);
- return (
-
- setCount(count + 1)}>inc {count}
-
- );
- }
- `,
- },
-});
-
-let sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
-
-let wait = async (
- callback: () => boolean,
- { timeoutMs = 1000, intervalMs = 250 } = {}
-) => {
- let start = Date.now();
- while (Date.now() - start <= timeoutMs) {
- if (callback()) {
- return;
- }
- await sleep(intervalMs);
- }
- throw Error(`wait: timeout ${timeoutMs}ms`);
-};
-
-let bufferize = (stream: Readable): (() => string) => {
- let buffer = "";
- stream.on("data", (data) => (buffer += data.toString()));
- return () => buffer;
-};
-
-let logConsoleError = (error: Error) => {
- console.error(`[console] ${error.name}: ${error.message}`);
-};
-
-let expectConsoleError = (
- isExpected: (error: Error) => boolean,
- unexpected = logConsoleError
-) => {
- return (error: Error) => {
- if (isExpected(error)) {
- return;
- }
- unexpected(error);
- };
-};
-
-let HMR_TIMEOUT_MS = 10_000;
-
-test("HMR", async ({ page, browserName }) => {
- // uncomment for debugging
- // page.on("console", (msg) => console.log(msg.text()));
- page.on("pageerror", logConsoleError);
- let dataRequests = 0;
- page.on("request", (request) => {
- let url = new URL(request.url());
- if (url.searchParams.has("_data")) {
- dataRequests++;
- }
- });
-
- let portRange = makeRange(4080, 4099);
- let appPort = await getPort({ port: portRange });
- let devPort = await getPort({ port: portRange });
- let projectDir = await createFixtureProject(fixture({ appPort, devPort }));
-
- // spin up dev server
- let dev = execa("npm", ["run", "dev"], { cwd: projectDir });
- let devStdout = bufferize(dev.stdout!);
- let devStderr = bufferize(dev.stderr!);
- try {
- await wait(
- () => {
- if (dev.exitCode) throw Error("Dev server exited early");
- return /✅ app ready: /.test(devStdout());
- },
- { timeoutMs: HMR_TIMEOUT_MS }
- );
-
- await page.goto(`http://localhost:${appPort}`, {
- waitUntil: "networkidle",
- });
-
- // ` ` value as page state that
- // would be wiped out by a full page refresh
- // but should be persisted by hmr
- let input = page.getByLabel("Root Input");
- expect(input).toBeVisible();
- await input.type("asdfasdf");
-
- let counter = await page.waitForSelector("#root-counter");
- await counter.click();
- await page.waitForSelector(`#root-counter:has-text("inc 1")`);
-
- let indexPath = path.join(projectDir, "app", "routes", "_index.tsx");
- let originalIndex = fs.readFileSync(indexPath, "utf8");
- let counterPath = path.join(projectDir, "app", "components", "counter.tsx");
- let originalCounter = fs.readFileSync(counterPath, "utf8");
- let cssModulePath = path.join(projectDir, "app", "styles.module.css");
- let originalCssModule = fs.readFileSync(cssModulePath, "utf8");
- let mdxPath = path.join(projectDir, "app", "routes", "mdx.mdx");
- let originalMdx = fs.readFileSync(mdxPath, "utf8");
-
- // make content and style changed to index route
- let newCssModule = `
- .test {
- background: black;
- color: white;
- }
- `;
- fs.writeFileSync(cssModulePath, newCssModule);
-
- let newIndex = `
- import { useLoaderData } from "@remix-run/react";
- import styles from "~/styles.module.css";
- export function shouldRevalidate(args) {
- return true;
- }
- export default function Index() {
- const t = useLoaderData();
- return (
-
- Changed
-
- )
- }
- `;
- fs.writeFileSync(indexPath, newIndex);
-
- // detect HMR'd content and style changes
- await page.waitForLoadState("networkidle");
-
- let h1 = page.getByText("Changed");
- await h1.waitFor({ timeout: HMR_TIMEOUT_MS });
- expect(h1).toHaveCSS("color", "rgb(255, 255, 255)");
- expect(h1).toHaveCSS("background-color", "rgb(0, 0, 0)");
-
- // verify that ` ` value was persisted (i.e. hmr, not full page refresh)
- expect(await page.getByLabel("Root Input").inputValue()).toBe("asdfasdf");
- await page.waitForSelector(`#root-counter:has-text("inc 1")`);
-
- // undo change
- fs.writeFileSync(indexPath, originalIndex);
- fs.writeFileSync(cssModulePath, originalCssModule);
- await page.getByText("Index Title").waitFor({ timeout: HMR_TIMEOUT_MS });
- expect(await page.getByLabel("Root Input").inputValue()).toBe("asdfasdf");
- await page.waitForSelector(`#root-counter:has-text("inc 1")`);
-
- // We should not have done any revalidation yet as only UI has changed
- expect(dataRequests).toBe(0);
-
- // add loader
- let withLoader1 = `
- import { json } from "@remix-run/node";
- import { useLoaderData } from "@remix-run/react";
-
- export let loader = () => json({ hello: "world" });
-
- export function shouldRevalidate(args) {
- return true;
- }
- export default function Index() {
- let { hello } = useLoaderData();
- return (
-
- Hello, {hello}
-
- )
- }
- `;
- fs.writeFileSync(indexPath, withLoader1);
- await expect.poll(() => dataRequests, { timeout: HMR_TIMEOUT_MS }).toBe(1);
- await page.waitForLoadState("networkidle");
-
- await page.getByText("Hello, world").waitFor({ timeout: HMR_TIMEOUT_MS });
- expect(await page.getByLabel("Root Input").inputValue()).toBe("asdfasdf");
- await page.waitForSelector(`#root-counter:has-text("inc 1")`);
-
- expect(dataRequests).toBe(1);
-
- let withLoader2 = `
- import { json } from "@remix-run/node";
- import { useLoaderData } from "@remix-run/react";
-
- export function loader() {
- return json({ hello: "planet" })
- }
-
- export function shouldRevalidate(args) {
- return true;
- }
- export default function Index() {
- let { hello } = useLoaderData();
- return (
-
- Hello, {hello}
-
- )
- }
- `;
- fs.writeFileSync(indexPath, withLoader2);
- await expect.poll(() => dataRequests, { timeout: HMR_TIMEOUT_MS }).toBe(2);
- await page.waitForLoadState("networkidle");
-
- await page.getByText("Hello, planet").waitFor({ timeout: HMR_TIMEOUT_MS });
- expect(await page.getByLabel("Root Input").inputValue()).toBe("asdfasdf");
- await page.waitForSelector(`#root-counter:has-text("inc 1")`);
-
- // change shared component
- let updatedCounter = `
- import * as React from "react";
- export default function Counter({ id }) {
- let [count, setCount] = React.useState(0);
- return (
-
- setCount(count - 1)}>dec {count}
-
- );
- }
- `;
- fs.writeFileSync(counterPath, updatedCounter);
- await page.waitForSelector(`#root-counter:has-text("dec 1")`);
- counter = await page.waitForSelector("#root-counter");
- await counter.click();
- await counter.click();
- await page.waitForSelector(`#root-counter:has-text("dec -1")`);
-
- await page.click(`a[href="/about"]`);
- let aboutCounter = await page.waitForSelector(
- `#about-counter:has-text("dec 0")`
- );
- await aboutCounter.click();
- await page.waitForSelector(`#about-counter:has-text("dec -1")`);
-
- // undo change
- fs.writeFileSync(counterPath, originalCounter);
-
- counter = await page.waitForSelector(`#root-counter:has-text("inc -1")`);
- await counter.click();
- counter = await page.waitForSelector(`#root-counter:has-text("inc 0")`);
-
- aboutCounter = await page.waitForSelector(
- `#about-counter:has-text("inc -1")`
- );
- await aboutCounter.click();
- aboutCounter = await page.waitForSelector(
- `#about-counter:has-text("inc 0")`
- );
-
- expect(dataRequests).toBe(2);
-
- // mdx
- await page.click(`a[href="/mdx"]`);
- await page.waitForSelector(`#crazy`);
- let mdx = `import { useLoaderData } from '@remix-run/react'
-export const loader = () => "hot"
-export const Component = () => {
- const data = useLoaderData()
- return {data}
-}
-
-# heyo
-whatsup
-
-
-`;
- fs.writeFileSync(mdxPath, mdx);
- await expect.poll(() => dataRequests, { timeout: HMR_TIMEOUT_MS }).toBe(4);
- await page.waitForSelector(`#hot`);
-
- fs.writeFileSync(mdxPath, originalMdx);
- await expect.poll(() => dataRequests, { timeout: HMR_TIMEOUT_MS }).toBe(5);
- await page.waitForSelector(`#crazy`);
-
- // dev server doesn't crash when rebuild fails
- await page.click(`a[href="/"]`);
- await page.getByText("Hello, planet").waitFor({ timeout: HMR_TIMEOUT_MS });
- await page.waitForLoadState("networkidle");
-
- let stderr = devStderr();
- let withSyntaxError = `
- import { useLoaderData } from "@remix-run/react";
- export function shouldRevalidate(args) {
- return true;
- }
- eport efault functio Index() {
- const t = useLoaderData();
- return (
-
- With Syntax Error
-
- )
- }
- `;
- fs.writeFileSync(indexPath, withSyntaxError);
- await wait(
- () =>
- devStderr()
- .replace(stderr, "")
- .includes('Expected ";" but found "efault"'),
- {
- timeoutMs: HMR_TIMEOUT_MS,
- }
- );
-
- // React Router integration w/ React Refresh has a bug where sometimes rerenders happen with old UI and new data
- // in this case causing `TypeError: Cannot destructure property`.
- // Need to fix that bug, but it only shows a harmless console error in the browser in dev
- page.removeListener("pageerror", logConsoleError);
- // let expectedErrorCount = 0;
- let expectDestructureTypeError = expectConsoleError((error) => {
- let expectedMessage = new Set([
- // chrome, edge
- "Cannot destructure property 'hello' of 'useLoaderData(...)' as it is null.",
- // firefox
- "(intermediate value)() is null",
- // webkit
- "Right side of assignment cannot be destructured",
- ]);
- let isExpected =
- error.name === "TypeError" && expectedMessage.has(error.message);
- // if (isExpected) expectedErrorCount += 1;
- return isExpected;
- });
- page.on("pageerror", expectDestructureTypeError);
-
- let withFix = `
- import { useLoaderData } from "@remix-run/react";
- export function shouldRevalidate(args) {
- return true;
- }
- export default function Index() {
- // const t = useLoaderData();
- return (
-
- With Fix
-
- )
- }
- `;
- fs.writeFileSync(indexPath, withFix);
- await page.waitForLoadState("networkidle");
- await page.getByText("With Fix").waitFor({ timeout: HMR_TIMEOUT_MS });
-
- // Restore normal console error handling
- page.removeListener("pageerror", expectDestructureTypeError);
- // expect(expectedErrorCount).toBe(browserName === "webkit" ? 1 : 2);
- page.addListener("pageerror", logConsoleError);
- } catch (e) {
- console.log("stdout begin -----------------------");
- console.log(devStdout());
- console.log("stdout end -------------------------");
-
- console.log("stderr begin -----------------------");
- console.log(devStderr());
- console.log("stderr end -------------------------");
- throw e;
- } finally {
- dev.kill();
- }
-});
diff --git a/integration/hmr-test.ts b/integration/hmr-test.ts
index 0339e97f5c1..a8c745f109d 100644
--- a/integration/hmr-test.ts
+++ b/integration/hmr-test.ts
@@ -1,9 +1,11 @@
-import { test, expect } from "@playwright/test";
-import execa from "execa";
import fs from "node:fs";
import path from "node:path";
import type { Readable } from "node:stream";
-import getPort, { makeRange } from "get-port";
+import type { Page } from "@playwright/test";
+import { test, expect } from "@playwright/test";
+import execa from "execa";
+import getPort from "get-port";
+import pidtree from "pidtree";
import type { FixtureInit } from "./helpers/create-fixture.js";
import {
@@ -15,76 +17,8 @@ import {
test.setTimeout(150_000);
-let fixture = (options: { appPort: number; devPort: number }): FixtureInit => ({
- config: {
- dev: {
- port: options.devPort,
- },
- },
- files: {
- "package.json": json({
- private: true,
- sideEffects: false,
- type: "module",
- scripts: {
- dev: `node ./node_modules/@remix-run/dev/dist/cli.js dev -c "node ./server.js"`,
- },
- dependencies: {
- "@remix-run/css-bundle": "0.0.0-local-version",
- "@remix-run/node": "0.0.0-local-version",
- "@remix-run/react": "0.0.0-local-version",
- "cross-env": "0.0.0-local-version",
- express: "0.0.0-local-version",
- isbot: "0.0.0-local-version",
- "postcss-import": "0.0.0-local-version",
- react: "0.0.0-local-version",
- "react-dom": "0.0.0-local-version",
- tailwindcss: "0.0.0-local-version",
- },
- devDependencies: {
- "@remix-run/dev": "0.0.0-local-version",
- "@types/react": "0.0.0-local-version",
- "@types/react-dom": "0.0.0-local-version",
- typescript: "0.0.0-local-version",
- },
- engines: {
- node: ">=18.0.0",
- },
- }),
-
- "server.js": js`
- import path from "path";
- import url from "url";
- import express from "express";
- import { createRequestHandler } from "@remix-run/express";
- import { broadcastDevReady, installGlobals } from "@remix-run/node";
-
- installGlobals();
-
- const app = express();
- app.use(express.static("public", { immutable: true, maxAge: "1y" }));
-
- const BUILD_PATH = url.pathToFileURL(path.join(process.cwd(), "build", "index.js"));
-
- app.all(
- "*",
- createRequestHandler({
- build: await import(BUILD_PATH),
- mode: process.env.NODE_ENV,
- })
- );
-
- let port = ${options.appPort};
- app.listen(port, async () => {
- let build = await import(BUILD_PATH);
- console.log('✅ app ready: http://localhost:' + port);
- if (process.env.NODE_ENV === 'development') {
- broadcastDevReady(build);
- }
- });
- `,
-
- "postcss.config.cjs": js`
+let files = {
+ "postcss.config.cjs": js`
module.exports = {
plugins: {
"postcss-import": {}, // Testing PostCSS cache invalidation
@@ -93,7 +27,7 @@ let fixture = (options: { appPort: number; devPort: number }): FixtureInit => ({
};
`,
- "tailwind.config.js": js`
+ "tailwind.config.js": js`
/** @type {import('tailwindcss').Config} */
export default {
content: ["./app/**/*.{ts,tsx,jsx,js}"],
@@ -104,45 +38,45 @@ let fixture = (options: { appPort: number; devPort: number }): FixtureInit => ({
};
`,
- "app/tailwind.css": css`
- @tailwind base;
- @tailwind components;
- @tailwind utilities;
- `,
+ "app/tailwind.css": css`
+ @tailwind base;
+ @tailwind components;
+ @tailwind utilities;
+ `,
- "app/stylesWithImport.css": css`
- @import "./importedStyle.css";
- `,
+ "app/stylesWithImport.css": css`
+ @import "./importedStyle.css";
+ `,
- "app/importedStyle.css": css`
- .importedStyle {
- font-weight: normal;
- }
- `,
+ "app/importedStyle.css": css`
+ .importedStyle {
+ font-weight: normal;
+ }
+ `,
- "app/sideEffectStylesWithImport.css": css`
- @import "./importedSideEffectStyle.css";
- `,
+ "app/sideEffectStylesWithImport.css": css`
+ @import "./importedSideEffectStyle.css";
+ `,
- "app/importedSideEffectStyle.css": css`
- .importedSideEffectStyle {
- font-size: initial;
- }
- `,
+ "app/importedSideEffectStyle.css": css`
+ .importedSideEffectStyle {
+ font-size: initial;
+ }
+ `,
- "app/style.module.css": css`
- .test {
- composes: color from "./composedStyle.module.css";
- }
- `,
+ "app/style.module.css": css`
+ .test {
+ composes: color from "./composedStyle.module.css";
+ }
+ `,
- "app/composedStyle.module.css": css`
- .color {
- color: initial;
- }
- `,
+ "app/composedStyle.module.css": css`
+ .color {
+ color: initial;
+ }
+ `,
- "app/root.tsx": js`
+ "app/root.tsx": js`
import type { LinksFunction } from "@remix-run/node";
import { Link, Links, LiveReload, Meta, Outlet, Scripts } from "@remix-run/react";
import { cssBundleHref } from "@remix-run/css-bundle";
@@ -192,7 +126,7 @@ let fixture = (options: { appPort: number; devPort: number }): FixtureInit => ({
}
`,
- "app/routes/_index.tsx": js`
+ "app/routes/_index.tsx": js`
import { useLoaderData } from "@remix-run/react";
export function shouldRevalidate(args) {
return true;
@@ -207,7 +141,7 @@ let fixture = (options: { appPort: number; devPort: number }): FixtureInit => ({
}
`,
- "app/routes/about.tsx": js`
+ "app/routes/about.tsx": js`
import Counter from "../components/counter";
export default function About() {
return (
@@ -218,7 +152,7 @@ let fixture = (options: { appPort: number; devPort: number }): FixtureInit => ({
)
}
`,
- "app/routes/mdx.mdx": `import { useLoaderData } from '@remix-run/react'
+ "app/routes/mdx.mdx": `import { useLoaderData } from '@remix-run/react'
export const loader = () => "crazy"
export const Component = () => {
const data = useLoaderData()
@@ -230,7 +164,7 @@ whatsup
`,
- "app/components/counter.tsx": js`
+ "app/components/counter.tsx": js`
import * as React from "react";
export default function Counter({ id }) {
let [count, setCount] = React.useState(0);
@@ -241,50 +175,135 @@ whatsup
);
}
`,
- },
-});
-
-let sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
-
-let wait = async (
- callback: () => boolean,
- { timeoutMs = 1000, intervalMs = 250 } = {}
-) => {
- let start = Date.now();
- while (Date.now() - start <= timeoutMs) {
- if (callback()) {
- return;
- }
- await sleep(intervalMs);
- }
- throw Error(`wait: timeout ${timeoutMs}ms`);
};
-let bufferize = (stream: Readable): (() => string) => {
- let buffer = "";
- stream.on("data", (data) => (buffer += data.toString()));
- return () => buffer;
+let packageJson = (options: { devScript: string; deps?: string[] }) => {
+ return json({
+ private: true,
+ sideEffects: false,
+ type: "module",
+ scripts: {
+ dev: options.devScript,
+ },
+ dependencies: deps([
+ ...(options.deps ?? []),
+ "@remix-run/css-bundle",
+ "@remix-run/express",
+ "@remix-run/node",
+ "@remix-run/react",
+ "cross-env",
+ "express",
+ "isbot",
+ "postcss-import",
+ "react",
+ "react-dom",
+ "tailwindcss",
+ ]),
+ devDependencies: deps([
+ "@remix-run/dev",
+ "@types/react",
+ "@types/react-dom",
+ "typescript",
+ ]),
+ engines: {
+ node: ">=18.0.0",
+ },
+ });
};
-let logConsoleError = (error: Error) => {
- console.error(`[console] ${error.name}: ${error.message}`);
-};
+let customServer = (options: { appPort: number; devReady: string }) => {
+ return js`
+ import path from "path";
+ import url from "url";
+ import express from "express";
+ import { createRequestHandler } from "@remix-run/express";
+ import { ${options.devReady}, installGlobals } from "@remix-run/node";
-let expectConsoleError = (
- isExpected: (error: Error) => boolean,
- unexpected = logConsoleError
-) => {
- return (error: Error) => {
- if (isExpected(error)) {
- return;
- }
- unexpected(error);
- };
+ installGlobals();
+
+ const app = express();
+ app.use(express.static("public", { immutable: true, maxAge: "1y" }));
+
+ const BUILD_PATH = url.pathToFileURL(path.join(process.cwd(), "build", "index.js"));
+
+ app.all(
+ "*",
+ createRequestHandler({
+ build: await import(BUILD_PATH),
+ mode: process.env.NODE_ENV,
+ })
+ );
+
+ let port = ${options.appPort};
+ app.listen(port, async () => {
+ let build = await import(BUILD_PATH);
+ console.log('✅ app ready: http://localhost:' + port);
+ if (process.env.NODE_ENV === 'development') {
+ ${options.devReady}(build);
+ }
+ });
+ `;
};
-let HMR_TIMEOUT_MS = 10_000;
+let HMR_TIMEOUT_MS = 30_000;
+
+let remix = "node ./node_modules/@remix-run/dev/dist/cli.js";
+let serve = "node ./node_modules/@remix-run/serve/dist/cli.js";
+
+test("HMR for remix-serve", async ({ page }) => {
+ await dev(page, {
+ files: (appPort) => ({
+ ...files,
+ "package.json": packageJson({
+ devScript: `cross-env PORT=${appPort} ${remix} dev -c "${serve} ./build/index.js"`,
+ deps: ["@remix-run/serve"],
+ }),
+ }),
+ appReadyPattern: /\[remix-serve\] /,
+ });
+});
+
+test("HMR for custom server with broadcast", async ({ page }) => {
+ await dev(page, {
+ files: (appPort) => ({
+ ...files,
+ "package.json": packageJson({
+ devScript: `${remix} dev -c "node ./server.js"`,
+ deps: ["@remix-run/express"],
+ }),
+ "server.js": customServer({
+ appPort,
+ devReady: "broadcastDevReady",
+ }),
+ }),
+ appReadyPattern: /✅ app ready: /,
+ });
+});
-test("HMR", async ({ page, browserName }) => {
+test("HMR for custom server with log", async ({ page }) => {
+ await dev(page, {
+ files: (appPort) => ({
+ ...files,
+ "package.json": packageJson({
+ devScript: `${remix} dev -c "node ./server.js"`,
+ deps: ["@remix-run/express"],
+ }),
+ "server.js": customServer({
+ appPort,
+ devReady: "logDevReady",
+ }),
+ }),
+ appReadyPattern: /✅ app ready: /,
+ });
+});
+
+async function dev(
+ page: Page,
+ options: {
+ files: (appPort: number) => Record;
+ appReadyPattern: RegExp;
+ }
+) {
// uncomment for debugging
// page.on("console", (msg) => console.log(msg.text()));
page.on("pageerror", logConsoleError);
@@ -296,24 +315,32 @@ test("HMR", async ({ page, browserName }) => {
}
});
- let portRange = makeRange(3080, 3099);
- let appPort = await getPort({ port: portRange });
- let devPort = await getPort({ port: portRange });
- let projectDir = await createFixtureProject(fixture({ appPort, devPort }));
+ let appPort = await getPort();
+ let devPort = await getPort();
+
+ let fixture: FixtureInit = {
+ config: {
+ dev: {
+ port: devPort,
+ },
+ },
+ files: options.files(appPort),
+ };
+
+ let projectDir = await createFixtureProject(fixture);
+
+ let devProc = execa("npm", ["run", "dev"], { cwd: projectDir });
+ let devStdout = bufferize(devProc.stdout!);
+ let devStderr = bufferize(devProc.stderr!);
- // spin up dev server
- let dev = execa("npm", ["run", "dev"], { cwd: projectDir });
- let devStdout = bufferize(dev.stdout!);
- let devStderr = bufferize(dev.stderr!);
try {
await wait(
() => {
- if (dev.exitCode) throw Error("Dev server exited early");
- return /✅ app ready: /.test(devStdout());
+ if (devProc.exitCode) throw Error("Dev server exited early");
+ return options.appReadyPattern.test(devStdout());
},
{ timeoutMs: HMR_TIMEOUT_MS }
);
-
await page.goto(`http://localhost:${appPort}`, {
waitUntil: "networkidle",
});
@@ -636,6 +663,105 @@ whatsup
console.log("stderr end -------------------------");
throw e;
} finally {
- dev.kill();
+ devProc.pid && (await killtree(devProc.pid));
}
-});
+}
+
+let sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
+
+let wait = async (
+ callback: () => boolean,
+ { timeoutMs = 1000, intervalMs = 250 } = {}
+) => {
+ let start = Date.now();
+ while (Date.now() - start <= timeoutMs) {
+ if (callback()) {
+ return;
+ }
+ await sleep(intervalMs);
+ }
+ throw Error(`wait: timeout ${timeoutMs}ms`);
+};
+
+let bufferize = (stream: Readable): (() => string) => {
+ let buffer = "";
+ stream.on("data", (data) => (buffer += data.toString()));
+ return () => buffer;
+};
+
+let logConsoleError = (error: Error) => {
+ console.error(`[console] ${error.name}: ${error.message}`);
+};
+
+let expectConsoleError = (
+ isExpected: (error: Error) => boolean,
+ unexpected = logConsoleError
+) => {
+ return (error: Error) => {
+ if (isExpected(error)) {
+ return;
+ }
+ unexpected(error);
+ };
+};
+
+let deps = (packages: string[]): Record => {
+ return Object.fromEntries(
+ packages.map((pkg) => [pkg, "0.0.0-local-version"])
+ );
+};
+
+let isWindows = process.platform === "win32";
+
+let kill = async (pid: number) => {
+ if (!isAlive(pid)) return;
+ if (isWindows) {
+ await execa("taskkill", ["/F", "/PID", pid.toString()]).catch((error) => {
+ // taskkill 128 -> the process is already dead
+ if (error.exitCode === 128) return;
+ if (/There is no running instance of the task./.test(error.message))
+ return;
+ console.warn(error.message);
+ });
+ return;
+ }
+ await execa("kill", ["-9", pid.toString()]).catch((error) => {
+ // process is already dead
+ if (/No such process/.test(error.message)) return;
+ console.warn(error.message);
+ });
+};
+
+let isAlive = (pid: number) => {
+ try {
+ process.kill(pid, 0);
+ return true;
+ } catch (error) {
+ return false;
+ }
+};
+
+let killtree = async (pid: number) => {
+ let descendants = await pidtree(pid).catch(() => undefined);
+ if (descendants === undefined) return;
+ let pids = [pid, ...descendants];
+
+ await Promise.all(pids.map(kill));
+
+ return new Promise((resolve, reject) => {
+ let check = setInterval(() => {
+ pids = pids.filter(isAlive);
+ if (pids.length === 0) {
+ clearInterval(check);
+ resolve();
+ }
+ }, 50);
+
+ setTimeout(() => {
+ clearInterval(check);
+ reject(
+ new Error("Timeout: Processes did not exit within the specified time.")
+ );
+ }, 2000);
+ });
+};
diff --git a/package.json b/package.json
index b895b422f02..aa73cead05d 100644
--- a/package.json
+++ b/package.json
@@ -97,8 +97,10 @@
"eslint": "^8.23.1",
"eslint-plugin-markdown": "^2.2.1",
"eslint-plugin-prefer-let": "^3.0.1",
+ "execa": "5.1.1",
"express": "^4.17.1",
"front-matter": "^4.0.2",
+ "get-port": "5.1.1",
"glob": "8.0.3",
"isbot": "^3.5.1",
"jest": "^27.5.1",
@@ -109,6 +111,7 @@
"mime": "^3.0.0",
"npm-run-all": "^4.1.5",
"patch-package": "^6.5.0",
+ "pidtree": "^0.6.0",
"postcss-import": "^15.1.0",
"prettier": "^2.7.1",
"prompt-confirm": "^2.0.4",
diff --git a/packages/remix-serve/cli.ts b/packages/remix-serve/cli.ts
index 97dd18dd33a..f1ffe8b3791 100644
--- a/packages/remix-serve/cli.ts
+++ b/packages/remix-serve/cli.ts
@@ -1,13 +1,15 @@
import "@remix-run/node/install";
-import path from "node:path";
+import fs from "node:fs";
import os from "node:os";
+import path from "node:path";
import url from "node:url";
import {
type ServerBuild,
broadcastDevReady,
installGlobals,
} from "@remix-run/node";
-import { createRequestHandler } from "@remix-run/express";
+import { type RequestHandler, createRequestHandler } from "@remix-run/express";
+import chokidar from "chokidar";
import compression from "compression";
import express from "express";
import morgan from "morgan";
@@ -32,11 +34,43 @@ async function run() {
process.exit(1);
}
- let buildPath = url.pathToFileURL(
- path.resolve(process.cwd(), buildPathArg)
- ).href;
+ let buildPath = path.resolve(buildPathArg);
- let build: ServerBuild = await import(buildPath);
+ async function reimportServer() {
+ let stat = fs.statSync(buildPath);
+
+ // use a timestamp query parameter to bust the import cache
+ return import(url.pathToFileURL(buildPath).href + "?t=" + stat.mtimeMs);
+ }
+
+ function createDevRequestHandler(initialBuild: ServerBuild): RequestHandler {
+ let build = initialBuild;
+ async function handleServerUpdate() {
+ // 1. re-import the server build
+ build = await reimportServer();
+ // 2. tell Remix that this app server is now up-to-date and ready
+ broadcastDevReady(build);
+ }
+
+ chokidar
+ .watch(buildPath, { ignoreInitial: true })
+ .on("add", handleServerUpdate)
+ .on("change", handleServerUpdate);
+
+ // wrap request handler to make sure its recreated with the latest build for every request
+ return async (req, res, next) => {
+ try {
+ return createRequestHandler({
+ build,
+ mode: "development",
+ })(req, res, next);
+ } catch (error) {
+ next(error);
+ }
+ };
+ }
+
+ let build: ServerBuild = await reimportServer();
let onListen = () => {
let address =
@@ -47,10 +81,10 @@ async function run() {
?.address;
if (!address) {
- console.log(`Remix App Server started at http://localhost:${port}`);
+ console.log(`[remix-serve] http://localhost:${port}`);
} else {
console.log(
- `Remix App Server started at http://localhost:${port} (http://${address}:${port})`
+ `[remix-serve] http://localhost:${port} (http://${address}:${port})`
);
}
if (process.env.NODE_ENV === "development") {
@@ -71,22 +105,15 @@ async function run() {
app.use(express.static("public", { maxAge: "1h" }));
app.use(morgan("tiny"));
- let requestHandler: ReturnType | undefined;
- app.all("*", async (req, res, next) => {
- try {
- if (!requestHandler) {
- let build = await import(buildPath);
- requestHandler = createRequestHandler({
+ app.all(
+ "*",
+ process.env.NODE_ENV === "development"
+ ? createDevRequestHandler(build)
+ : createRequestHandler({
build,
mode: process.env.NODE_ENV,
- });
- }
-
- return await requestHandler(req, res, next);
- } catch (error) {
- next(error);
- }
- });
+ })
+ );
let server = process.env.HOST
? app.listen(port, process.env.HOST, onListen)
diff --git a/packages/remix-serve/package.json b/packages/remix-serve/package.json
index ac6d7552c31..06524c6b710 100644
--- a/packages/remix-serve/package.json
+++ b/packages/remix-serve/package.json
@@ -17,6 +17,7 @@
"dependencies": {
"@remix-run/express": "1.19.3",
"@remix-run/node": "1.19.3",
+ "chokidar": "^3.5.3",
"compression": "^1.7.4",
"express": "^4.17.1",
"morgan": "^1.10.0",
diff --git a/templates/remix-javascript/package.json b/templates/remix-javascript/package.json
index 7143725f8e2..521a8e35ae7 100644
--- a/templates/remix-javascript/package.json
+++ b/templates/remix-javascript/package.json
@@ -4,7 +4,7 @@
"type": "module",
"scripts": {
"build": "remix build",
- "dev": "remix dev",
+ "dev": "remix dev --manual",
"start": "remix-serve build"
},
"dependencies": {
diff --git a/templates/remix/package.json b/templates/remix/package.json
index b79f377eec7..22c4b044d8d 100644
--- a/templates/remix/package.json
+++ b/templates/remix/package.json
@@ -4,7 +4,7 @@
"type": "module",
"scripts": {
"build": "remix build",
- "dev": "remix dev",
+ "dev": "remix dev --manual",
"start": "remix-serve build",
"typecheck": "tsc"
},
diff --git a/yarn.lock b/yarn.lock
index f161055e2d5..30e78f2d70c 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -6076,9 +6076,9 @@ get-package-type@^0.1.0:
resolved "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz"
integrity sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==
-get-port@^5.1.1:
+get-port@5.1.1, get-port@^5.1.1:
version "5.1.1"
- resolved "https://registry.npmjs.org/get-port/-/get-port-5.1.1.tgz"
+ resolved "https://registry.npmjs.org/get-port/-/get-port-5.1.1.tgz#0469ed07563479de6efb986baf053dcd7d4e3193"
integrity sha512-g/Q1aTSDOxFpchXC4i8ZWvxA1lnPqx/JHqcpIw0/LX9T8x/GBbi6YnlN5nhaKIFkT8oFsscUKgDJYxfwfS6QsQ==
get-stream@^5.0.0, get-stream@^5.1.0: