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 ( -

- -

- ); - } - `, - }, -}); - -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 ( -

- -

- ); - } - `; - 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: