From e6067b71f302d4a79b84362532fa7b4e546caf0f Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Mon, 24 Apr 2023 15:01:21 -0400 Subject: [PATCH] feat(dev): unstable dev server improvements (#6133) --- .changeset/dev-server.md | 156 ++++++++++++ integration/hmr-test.ts | 73 +++--- package.json | 1 - packages/remix-dev/channel.ts | 4 +- packages/remix-dev/cli/commands.ts | 49 +++- packages/remix-dev/cli/run.ts | 39 +-- packages/remix-dev/compiler/compiler.ts | 65 +++-- packages/remix-dev/config.ts | 10 +- packages/remix-dev/devServer_unstable/env.ts | 10 +- .../remix-dev/devServer_unstable/index.ts | 227 ++++++++---------- packages/remix-dev/devServer_unstable/ping.ts | 25 ++ .../{liveReload.ts => socket.ts} | 6 +- packages/remix-dev/index.ts | 2 + packages/remix-dev/result.ts | 7 + packages/remix-server-runtime/server.ts | 20 -- yarn.lock | 65 +---- 16 files changed, 442 insertions(+), 317 deletions(-) create mode 100644 .changeset/dev-server.md create mode 100644 packages/remix-dev/devServer_unstable/ping.ts rename packages/remix-dev/devServer_unstable/{liveReload.ts => socket.ts} (95%) create mode 100644 packages/remix-dev/result.ts diff --git a/.changeset/dev-server.md b/.changeset/dev-server.md new file mode 100644 index 00000000000..e3a2eac2275 --- /dev/null +++ b/.changeset/dev-server.md @@ -0,0 +1,156 @@ +--- +"@remix-run/dev": minor +"@remix-run/server-runtime": minor +--- + +Dev server improvements + +- Push-based app server syncing that doesn't rely on polling +- App server as a managed subprocess + +# Guide + +## 1. Enable new dev server + +Enable `unstable_dev` in `remix.config.js`: + +```js +{ + future: { + "unstable_dev": true + } +} +``` + +## 2. Update `package.json` scripts + +Specify the command to run your app server with the `-c`/`--command` flag: + +For Remix app server: + +```json +{ + "scripts": { + "dev": "NODE_ENV=development remix dev -c 'node_modules/.bin/remix-serve build'" + } +} +``` + +For any other servers, specify the command you use to run your production server. + +```json +{ + "scripts": { + "dev": "NODE_ENV=development remix dev -c 'node ./server.js'" + } +} +``` + +## 3. Call `ping` in your app server + +For example, in an Express server: + +```js +// server.mjs +import path from "node:path"; + +import express from "express"; +import { createRequestHandler } from "@remix-run/express"; +import { ping } from "@remix-run/dev"; + +let BUILD_DIR = path.join(process.cwd(), "build"); // path to Remix's server build directory (`build/` by default) + +let app = express(); + +app.all( + "*", + createRequestHandler({ + build: require(BUILD_DIR), + mode: process.env.NODE_ENV, + }) +); + +app.listen(3000, () => { + let build = require(BUILD_DIR); + console.log('Ready: http://localhost:' + port); + + // in development, call `ping` _after_ your server is ready + if (process.env.NODE_ENV === 'development') { + ping(build); + } +}); +``` + +## 4. That's it! + +You should now be able to run the Remix Dev server: + +```sh +$ npm run dev +# Ready: http://localhost:3000 +``` + +Make sure you navigate to your app server's URL in the browser, in this example `http://localhost:3000`. +Note: Any ports configured for the dev server are internal only (e.g. `--http-port` and `--websocket-port`) + +# Configuration + +Example: + +```js +{ + future: { + unstable_dev: { + // Port internally used by the dev server to receive app server `ping`s + httpPort: 3001, // by default, Remix chooses an open port in the range 3001-3099 + // Port internally used by the dev server to send live reload, HMR, and HDR updates to the browser + websocketPort: 3002, // by default, Remix chooses an open port in the range 3001-3099 + // Whether the app server should be restarted when app is rebuilt + // See `Advanced > restart` for more + restart: false, // default: `true` + } + } +} +``` + +You can also configure via flags: + +```sh +remix dev -c 'node ./server.mjs' --http-port=3001 --websocket-port=3002 --no-restart +``` + +## Advanced + +### Dev server scheme/host/port + +If you've customized the dev server's origin (e.g. for Docker or SSL support), you can use the `ping` options to specify the scheme/host/port for the dev server: + +```js +ping(build, { + scheme: "https", // defaults to http + host: "mycustomhost", // defaults to localhost + port: 3003 // defaults to REMIX_DEV_HTTP_PORT environment variable +}); +``` + +### restart + +If you want to manage app server updates yourself, you can use the `--no-restart` flag so that the Remix dev server doesn't restart the app server subprocess when a rebuild succeeds. + +For example, if you rely on require cache purging to keep your app server running while server changes are pulled in, then you'll want to use `--no-restart`. + +🚨 It is then your responsibility to call `ping` whenever server changes are incorporated in your app server. 🚨 + +So for require cache purging, you'd want to: +1. Purge the require cache +2. `require` your server build +3. Call `ping` within a `if (process.env.NODE_ENV === 'development')` + +([Looking at you, Kent](https://github.com/kentcdodds/kentcdodds.com/blob/main/server/index.ts#L298) 😆) + +--- + +The ultimate solution here would be to implement _server-side_ HMR (not to be confused with the more popular client-side HMR). +Then your app server could continuously update itself with new build with 0 downtime and without losing in-memory data that wasn't affected by the server changes. + +That's left as an exercise to the reader. diff --git a/integration/hmr-test.ts b/integration/hmr-test.ts index 61858c787cc..019763840a7 100644 --- a/integration/hmr-test.ts +++ b/integration/hmr-test.ts @@ -7,15 +7,21 @@ import getPort, { makeRange } from "get-port"; import { createFixtureProject, css, js, json } from "./helpers/create-fixture"; -let fixture = (options: { port: number; appServerPort: number }) => ({ +test.setTimeout(120_000); + +let fixture = (options: { + appServerPort: number; + httpPort: number; + webSocketPort: number; +}) => ({ files: { "remix.config.js": js` module.exports = { tailwind: true, future: { unstable_dev: { - port: ${options.port}, - appServerPort: ${options.appServerPort}, + httpPort: ${options.httpPort}, + webSocketPort: ${options.webSocketPort}, }, v2_routeConvention: true, v2_errorBoundary: true, @@ -28,8 +34,7 @@ let fixture = (options: { port: number; appServerPort: number }) => ({ private: true, sideEffects: false, scripts: { - "dev:remix": `cross-env NODE_ENV=development node ./node_modules/@remix-run/dev/dist/cli.js dev`, - "dev:app": `cross-env NODE_ENV=development nodemon --watch build/ ./server.js`, + dev: `cross-env NODE_ENV=development node ./node_modules/@remix-run/dev/dist/cli.js dev -c "node ./server.js"`, }, dependencies: { "@remix-run/css-bundle": "0.0.0-local-version", @@ -38,7 +43,6 @@ let fixture = (options: { port: number; appServerPort: number }) => ({ "cross-env": "0.0.0-local-version", express: "0.0.0-local-version", isbot: "0.0.0-local-version", - nodemon: "0.0.0-local-version", react: "0.0.0-local-version", "react-dom": "0.0.0-local-version", tailwindcss: "0.0.0-local-version", @@ -58,6 +62,7 @@ let fixture = (options: { port: number; appServerPort: number }) => ({ let path = require("path"); let express = require("express"); let { createRequestHandler } = require("@remix-run/express"); + let { ping } = require("@remix-run/dev"); const app = express(); app.use(express.static("public", { immutable: true, maxAge: "1y" })); @@ -75,8 +80,11 @@ let fixture = (options: { port: number; appServerPort: number }) => ({ let port = ${options.appServerPort}; app.listen(port, () => { - require(BUILD_DIR); + let build = require(BUILD_DIR); console.log('✅ app ready: http://localhost:' + port); + if (process.env.NODE_ENV === 'development') { + ping(build); + } }); `, @@ -204,43 +212,34 @@ let bufferize = (stream: Readable): (() => string) => { return () => buffer; }; +let HMR_TIMEOUT_MS = 10_000; + test("HMR", async ({ page }) => { // uncomment for debugging // page.on("console", (msg) => console.log(msg.text())); page.on("pageerror", (err) => console.log(err.message)); - let appServerPort = await getPort({ port: makeRange(3080, 3089) }); - let port = await getPort({ port: makeRange(3090, 3099) }); - let projectDir = await createFixtureProject(fixture({ port, appServerPort })); + let portRange = makeRange(3080, 3099); + let appServerPort = await getPort({ port: portRange }); + let httpPort = await getPort({ port: portRange }); + let webSocketPort = await getPort({ port: portRange }); + let projectDir = await createFixtureProject( + fixture({ appServerPort, httpPort, webSocketPort }) + ); // spin up dev server - let dev = execa("npm", ["run", "dev:remix"], { cwd: projectDir }); + let dev = execa("npm", ["run", "dev"], { cwd: projectDir }); let devStdout = bufferize(dev.stdout!); let devStderr = bufferize(dev.stderr!); await wait( () => { let stderr = devStderr(); if (stderr.length > 0) throw Error(stderr); - return /💿 Built in /.test(devStdout()); + return /✅ app ready: /.test(devStdout()); }, { timeoutMs: 10_000 } ); - // spin up app server - let app = execa("npm", ["run", "dev:app"], { cwd: projectDir }); - let appStdout = bufferize(app.stdout!); - let appStderr = bufferize(app.stderr!); - await wait( - () => { - let stderr = appStderr(); - if (stderr.length > 0) throw Error(stderr); - return /✅ app ready: /.test(appStdout()); - }, - { - timeoutMs: 10_000, - } - ); - try { await page.goto(`http://localhost:${appServerPort}`, { waitUntil: "networkidle", @@ -290,7 +289,7 @@ test("HMR", async ({ page }) => { // detect HMR'd content and style changes await page.waitForLoadState("networkidle"); let h1 = page.getByText("Changed"); - await h1.waitFor({ timeout: 2000 }); + await h1.waitFor({ timeout: HMR_TIMEOUT_MS }); expect(h1).toHaveCSS("color", "rgb(255, 255, 255)"); expect(h1).toHaveCSS("background-color", "rgb(0, 0, 0)"); @@ -301,7 +300,7 @@ test("HMR", async ({ page }) => { // undo change fs.writeFileSync(indexPath, originalIndex); fs.writeFileSync(cssModulePath, originalCssModule); - await page.getByText("Index Title").waitFor({ timeout: 2000 }); + 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")`); @@ -322,7 +321,7 @@ test("HMR", async ({ page }) => { } `; fs.writeFileSync(indexPath, withLoader1); - await page.getByText("Hello, world").waitFor({ timeout: 2000 }); + 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")`); @@ -344,7 +343,7 @@ test("HMR", async ({ page }) => { } `; fs.writeFileSync(indexPath, withLoader2); - await page.getByText("Hello, planet").waitFor({ timeout: 2000 }); + 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")`); @@ -388,10 +387,16 @@ test("HMR", async ({ page }) => { aboutCounter = await page.waitForSelector( `#about-counter:has-text("inc 0")` ); + } 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(); - app.kill(); - console.log(devStderr()); - console.log(appStderr()); } }); diff --git a/package.json b/package.json index b23c8490133..6037934fb4f 100644 --- a/package.json +++ b/package.json @@ -108,7 +108,6 @@ "jest-watch-typeahead": "^0.6.5", "jsonfile": "^6.0.1", "lodash": "^4.17.21", - "nodemon": "^2.0.20", "npm-run-all": "^4.1.5", "patch-package": "^6.5.0", "prettier": "2.7.1", diff --git a/packages/remix-dev/channel.ts b/packages/remix-dev/channel.ts index a631a4809a2..eb2ecdc128f 100644 --- a/packages/remix-dev/channel.ts +++ b/packages/remix-dev/channel.ts @@ -1,8 +1,8 @@ +import type { Result } from "./result"; + type Resolve = (value: V | PromiseLike) => void; type Reject = (reason?: any) => void; -type Result = { ok: true; value: V } | { ok: false; error: E }; - export type Type = { ok: (value: V) => void; err: (reason?: any) => void; diff --git a/packages/remix-dev/cli/commands.ts b/packages/remix-dev/cli/commands.ts index 1cd7c43af48..39b4f05e7c9 100644 --- a/packages/remix-dev/cli/commands.ts +++ b/packages/remix-dev/cli/commands.ts @@ -1,6 +1,8 @@ import * as path from "path"; import { execSync } from "child_process"; +import inspector from "inspector"; import * as fse from "fs-extra"; +import getPort, { makeRange } from "get-port"; import ora from "ora"; import prettyMs from "pretty-ms"; import * as esbuild from "esbuild"; @@ -206,17 +208,52 @@ export async function watch( export async function dev( remixRoot: string, - flags: { port?: number; appServerPort?: number } = {} + flags: { + debug?: boolean; + port?: number; // TODO: remove for v2 + command?: string; + httpPort?: number; + restart?: boolean; + websocketPort?: number; + } = {} ) { + if (process.env.NODE_ENV && process.env.NODE_ENV !== "development") { + console.warn( + `Expected NODE_ENV to be 'development' but got ${process.env.NODE_ENV}` + ); + } + if (flags.debug) inspector.open(); + let config = await readConfig(remixRoot); - if (config.future.unstable_dev !== false) { - await devServer_unstable.serve(config, flags); + if (config.future.unstable_dev === false) { + await devServer.serve(config, flags.port); return await new Promise(() => {}); } - await devServer.serve(config, flags.port); - return await new Promise(() => {}); + let { unstable_dev } = config.future; + + let command = + flags.command ?? (unstable_dev === true ? undefined : unstable_dev.command); + let httpPort = + flags.httpPort ?? + (unstable_dev === true ? undefined : unstable_dev.httpPort) ?? + (await findPort()); + let websocketPort = + flags.websocketPort ?? + (unstable_dev === true ? undefined : unstable_dev.websocketPort) ?? + (await findPort()); + let restart = + flags.restart ?? + (unstable_dev === true ? undefined : unstable_dev.restart) ?? + true; + + await devServer_unstable.serve(config, { + command, + httpPort, + websocketPort, + restart, + }); } export async function codemod( @@ -442,3 +479,5 @@ let parseMode = ( console.error(`Unrecognized mode: ${mode}`); process.exit(1); }; + +let findPort = async () => getPort({ port: makeRange(3001, 3100) }); diff --git a/packages/remix-dev/cli/run.ts b/packages/remix-dev/cli/run.ts index a62c53eb58b..3801db8924c 100644 --- a/packages/remix-dev/cli/run.ts +++ b/packages/remix-dev/cli/run.ts @@ -1,7 +1,6 @@ import * as path from "path"; import os from "os"; import arg from "arg"; -import inspector from "inspector"; import inquirer from "inquirer"; import semver from "semver"; import fse from "fs-extra"; @@ -137,20 +136,6 @@ const npxInterop = { pnpm: "pnpm exec", }; -async function dev( - projectDir: string, - flags: { debug?: boolean; port?: number; appServerPort?: number } -) { - if (process.env.NODE_ENV && process.env.NODE_ENV !== "development") { - console.warn( - `NODE_ENV=${process.env.NODE_ENV} overwritten to 'development'` - ); - } - - if (flags.debug) inspector.open(); - await commands.dev(projectDir, flags); -} - /** * Programmatic interface for running the Remix CLI with the given command line * arguments. @@ -166,12 +151,14 @@ export async function run(argv: string[] = process.argv.slice(2)) { let args = arg( { - "--app-server-port": Number, + "--command": String, + "-c": "--command", "--debug": Boolean, "--no-delete": Boolean, "--dry": Boolean, "--force": Boolean, "--help": Boolean, + "--http-port": Number, "-h": "--help", "--install": Boolean, "--no-install": Boolean, @@ -181,6 +168,8 @@ export async function run(argv: string[] = process.argv.slice(2)) { "--port": Number, "-p": "--port", "--remix-version": String, + "--restart": Boolean, + "--no-restart": Boolean, "--sourcemap": Boolean, "--template": String, "--token": String, @@ -188,6 +177,7 @@ export async function run(argv: string[] = process.argv.slice(2)) { "--no-typescript": Boolean, "--version": Boolean, "-v": "--version", + "--websocket-port": Number, }, { argv, @@ -212,6 +202,15 @@ export async function run(argv: string[] = process.argv.slice(2)) { return; } + if (flags["http-port"]) { + flags.httpPort = flags["http-port"]; + delete flags["http-port"]; + } + if (flags["websocket-port"]) { + flags.websocketPort = flags["websocket-port"]; + delete flags["websocket-port"]; + } + if (args["--no-delete"]) { flags.delete = false; } @@ -222,6 +221,10 @@ export async function run(argv: string[] = process.argv.slice(2)) { flags.interactive = false; } flags.interactive = flags.interactive ?? require.main === module; + if (args["--no-restart"]) { + flags.restart = false; + delete flags["no-restart"]; + } if (args["--no-typescript"]) { flags.typescript = false; } @@ -498,10 +501,10 @@ export async function run(argv: string[] = process.argv.slice(2)) { break; } case "dev": - await dev(input[1], flags); + await commands.dev(input[1], flags); break; default: // `remix ./my-project` is shorthand for `remix dev ./my-project` - await dev(input[0], flags); + await commands.dev(input[0], flags); } } diff --git a/packages/remix-dev/compiler/compiler.ts b/packages/remix-dev/compiler/compiler.ts index bb1b1b0538a..4a1f6016960 100644 --- a/packages/remix-dev/compiler/compiler.ts +++ b/packages/remix-dev/compiler/compiler.ts @@ -7,6 +7,7 @@ import * as Server from "./server"; import * as Channel from "../channel"; import type { Manifest } from "../manifest"; import { create as createManifest, write as writeManifest } from "./manifest"; +import { err, ok } from "../result"; type Compiler = { compile: () => Promise; @@ -29,29 +30,23 @@ export let create = async (ctx: Context): Promise => { js: await JS.createCompiler(ctx, channels), server: await Server.createCompiler(ctx, channels), }; + let cancel = async () => { + // resolve channels with error so that downstream tasks don't hang waiting for results from upstream tasks + channels.cssBundleHref.err(); + channels.manifest.err(); + + // optimization: cancel tasks + await Promise.all([ + subcompiler.css.cancel(), + subcompiler.js.cancel(), + subcompiler.server.cancel(), + ]); + }; let compile = async () => { - let hasThrown = false; - let cancelAndThrow = async (error: unknown) => { - // An earlier error from a failed task has already been thrown; ignore this error. - // Safe to cast as `never` here as subsequent errors are only thrown from canceled tasks. - if (hasThrown) return undefined as never; - - // resolve channels with error so that downstream tasks don't hang waiting for results from upstream tasks - channels.cssBundleHref.err(); - channels.manifest.err(); - - // optimization: cancel tasks - subcompiler.css.cancel(); - subcompiler.js.cancel(); - subcompiler.server.cancel(); - - // Only throw the first error encountered during compilation - // otherwise subsequent errors will be unhandled and will crash the compiler. - // `try`/`catch` won't handle subsequent errors either, so that isn't a viable alternative. - // `Promise.all` _could_ be used, but the resulting promise chaining is complex and hard to follow. - hasThrown = true; - throw error; + let errCancel = (error: unknown) => { + cancel(); + return err(error); }; // reset channels @@ -60,9 +55,9 @@ export let create = async (ctx: Context): Promise => { // kickoff compilations in parallel let tasks = { - css: subcompiler.css.compile().catch(cancelAndThrow), - js: subcompiler.js.compile().catch(cancelAndThrow), - server: subcompiler.server.compile().catch(cancelAndThrow), + css: subcompiler.css.compile().then(ok, errCancel), + js: subcompiler.js.compile().then(ok, errCancel), + server: subcompiler.server.compile().then(ok, errCancel), }; // keep track of manually written artifacts @@ -74,23 +69,26 @@ export let create = async (ctx: Context): Promise => { // css compilation let css = await tasks.css; + if (!css.ok) throw css.error; // css bundle let cssBundleHref = - css.bundle && + css.value.bundle && ctx.config.publicPath + path.relative( ctx.config.assetsBuildDirectory, - path.resolve(css.bundle.path) + path.resolve(css.value.bundle.path) ); channels.cssBundleHref.ok(cssBundleHref); - if (css.bundle) { - writes.cssBundle = CSS.writeBundle(ctx, css.outputFiles); + if (css.value.bundle) { + writes.cssBundle = CSS.writeBundle(ctx, css.value.outputFiles); } // js compilation (implicitly writes artifacts/js) // TODO: js task should not return metafile, but rather js assets - let { metafile, hmr } = await tasks.js; + let js = await tasks.js; + if (!js.ok) throw js.error; + let { metafile, hmr } = js.value; // artifacts/manifest let manifest = await createManifest({ @@ -103,18 +101,17 @@ export let create = async (ctx: Context): Promise => { writes.manifest = writeManifest(ctx.config, manifest); // server compilation - let serverFiles = await tasks.server; + let server = await tasks.server; + if (!server.ok) throw server.error; // artifacts/server - writes.server = Server.write(ctx.config, serverFiles); + writes.server = Server.write(ctx.config, server.value); await Promise.all(Object.values(writes)); return manifest; }; return { compile, - cancel: async () => { - await Promise.all(Object.values(subcompiler).map((sub) => sub.cancel())); - }, + cancel, dispose: async () => { await Promise.all(Object.values(subcompiler).map((sub) => sub.dispose())); }, diff --git a/packages/remix-dev/config.ts b/packages/remix-dev/config.ts index 8b4a0a0fec6..5c0eddb7796 100644 --- a/packages/remix-dev/config.ts +++ b/packages/remix-dev/config.ts @@ -38,10 +38,12 @@ export type ServerModuleFormat = "esm" | "cjs"; export type ServerPlatform = "node" | "neutral"; type Dev = { - port?: number; - appServerPort?: number; - remixRequestHandlerPath?: string; - rebuildPollIntervalMs?: number; + port?: number; // TODO: remove in v2 + + command?: string; + httpPort?: number; + websocketPort?: number; + restart?: boolean; }; interface FutureConfig { diff --git a/packages/remix-dev/devServer_unstable/env.ts b/packages/remix-dev/devServer_unstable/env.ts index 5999d6d3b40..9f9df9c4ff0 100644 --- a/packages/remix-dev/devServer_unstable/env.ts +++ b/packages/remix-dev/devServer_unstable/env.ts @@ -4,15 +4,9 @@ import * as path from "path"; // Import environment variables from: .env, failing gracefully if it doesn't exist export async function loadEnv(rootDirectory: string): Promise { let envPath = path.join(rootDirectory, ".env"); - try { - await fse.readFile(envPath); - } catch { - return; - } + if (!fse.existsSync(envPath)) return; console.log(`Loading environment variables from .env`); let result = require("dotenv").config({ path: envPath }); - if (result.error) { - throw result.error; - } + if (result.error) throw result.error; } diff --git a/packages/remix-dev/devServer_unstable/index.ts b/packages/remix-dev/devServer_unstable/index.ts index d62d2a652c9..75153b09042 100644 --- a/packages/remix-dev/devServer_unstable/index.ts +++ b/packages/remix-dev/devServer_unstable/index.ts @@ -1,173 +1,146 @@ -import exitHook from "exit-hook"; import fs from "fs-extra"; -import getPort, { makeRange } from "get-port"; -import os from "os"; import path from "node:path"; import prettyMs from "pretty-ms"; -import fetch from "node-fetch"; +import execa from "execa"; +import express from "express"; +import * as Channel from "../channel"; import { type Manifest } from "../manifest"; import * as Compiler from "../compiler"; import { type RemixConfig } from "../config"; import { loadEnv } from "./env"; -import * as LiveReload from "./liveReload"; +import * as Socket from "./socket"; import * as HMR from "./hmr"; import { warnOnce } from "../warnOnce"; -let info = (message: string) => console.info(`💿 ${message}`); - -let relativePath = (file: string) => path.relative(process.cwd(), file); - -let sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); - -let clean = (config: RemixConfig) => { - try { - fs.emptyDirSync(config.relativeAssetsBuildDirectory); - } catch { - // ignore failed clean up attempts - } -}; - -let getHost = () => - process.env.HOST ?? - Object.values(os.networkInterfaces()) - .flat() - .find((ip) => String(ip?.family).includes("4") && !ip?.internal)?.address; - -let findPort = async (portPreference?: number) => - getPort({ - port: - // prettier-ignore - portPreference ? Number(portPreference) : - process.env.PORT ? Number(process.env.PORT) : - makeRange(3001, 3100), - }); - -let fetchAssetsManifest = async ( - origin: string, - remixRequestHandlerPath: string -): Promise => { - try { - let url = origin + remixRequestHandlerPath + "/__REMIX_ASSETS_MANIFEST"; - let res = await fetch(url); - let assetsManifest = (await res.json()) as Manifest; - return assetsManifest; - } catch (error) { - return undefined; - } -}; - -let resolveDev = ( - dev: RemixConfig["future"]["unstable_dev"], - flags: { port?: number; appServerPort?: number } -) => { - if (dev === false) - throw Error("The new dev server requires 'unstable_dev' to be set"); - - let port = flags.port ?? (dev === true ? undefined : dev.port); - - let appServerPort = - flags.appServerPort ?? (dev === true || dev.appServerPort == undefined) - ? 3000 - : dev.appServerPort; - let remixRequestHandlerPath = - dev === true || dev.remixRequestHandlerPath === undefined - ? "" - : dev.remixRequestHandlerPath; - let rebuildPollIntervalMs = - dev === true || dev.rebuildPollIntervalMs === undefined - ? 50 - : dev.rebuildPollIntervalMs; - - return { - port, - appServerPort, - remixRequestHandlerPath, - rebuildPollIntervalMs, - }; -}; - export let serve = async ( config: RemixConfig, - flags: { port?: number; appServerPort?: number } = {} + options: { + command?: string; + httpPort: number; + websocketPort: number; + restart: boolean; + } ) => { - clean(config); await loadEnv(config.rootDirectory); - - let dev = resolveDev(config.future.unstable_dev, flags); - - let host = getHost(); - let appServerOrigin = `http://${host ?? "localhost"}:${dev.appServerPort}`; - - let waitForAppServer = async (buildHash: string) => { - while (true) { - // TODO AbortController signal to cancel responses? - let assetsManifest = await fetchAssetsManifest( - appServerOrigin, - dev.remixRequestHandlerPath - ); - if (assetsManifest?.version === buildHash) return; - - await sleep(dev.rebuildPollIntervalMs); - } + let websocket = Socket.serve({ port: options.websocketPort }); + + let state: { + latestBuildHash?: string; + buildHashChannel?: Channel.Type; + appServer?: execa.ExecaChildProcess; + prevManifest?: Manifest; + } = {}; + + let startAppServer = (command: string) => { + return execa.command(command, { + stdio: "inherit", + env: { + NODE_ENV: "development", + PATH: `${process.cwd()}/node_modules/.bin:${process.env.PATH}`, + REMIX_DEV_HTTP_PORT: String(options.httpPort), + }, + }); }; - // watch and live reload on rebuilds - let port = await findPort(dev.port); - let socket = LiveReload.serve({ port }); - let prevManifest: Manifest | undefined = undefined; let dispose = await Compiler.watch( { config, options: { mode: "development", - liveReloadPort: port, + liveReloadPort: options.websocketPort, // TODO: rename liveReloadPort sourcemap: true, onWarning: warnOnce, }, }, { onInitialBuild: (durationMs, manifest) => { - info(`Built in ${prettyMs(durationMs)}`); - prevManifest = manifest; + console.info(`💿 Built in ${prettyMs(durationMs)}`); + state.prevManifest = manifest; + if (options.command && manifest) { + console.log(`starting: ${options.command}`); + state.appServer = startAppServer(options.command); + } }, onRebuildStart: () => { clean(config); - socket.log("Rebuilding..."); + websocket.log("Rebuilding..."); }, onRebuildFinish: async (durationMs, manifest) => { if (!manifest) return; - socket.log(`Rebuilt in ${prettyMs(durationMs)}`); - - info(`Waiting for ${appServerOrigin}...`); - let start = Date.now(); - await waitForAppServer(manifest.version); - info(`${appServerOrigin} ready in ${prettyMs(Date.now() - start)}`); - await new Promise((resolve) => { - setTimeout(resolve, -1); - }); + websocket.log(`Rebuilt in ${prettyMs(durationMs)}`); + + // TODO: should we restart the app server when build failed? + state.latestBuildHash = manifest.version; + state.buildHashChannel = Channel.create(); + console.log(`Waiting (${state.latestBuildHash})`); + if (state.appServer === undefined || options.restart) { + console.log(`restarting: ${options.command}`); + await kill(state.appServer); + if (options.command) { + state.appServer = startAppServer(options.command); + } + } + await state.buildHashChannel.result; - if (manifest.hmr && prevManifest) { - let updates = HMR.updates(config, manifest, prevManifest); - socket.hmr(manifest, updates); + if (manifest.hmr && state.prevManifest) { + let updates = HMR.updates(config, manifest, state.prevManifest); + websocket.hmr(manifest, updates); + console.log("> HMR"); } else { - socket.reload(); + websocket.reload(); + console.log("> Reload"); } - prevManifest = manifest; + state.prevManifest = manifest; }, onFileCreated: (file) => - socket.log(`File created: ${relativePath(file)}`), + websocket.log(`File created: ${relativePath(file)}`), onFileChanged: (file) => - socket.log(`File changed: ${relativePath(file)}`), + websocket.log(`File changed: ${relativePath(file)}`), onFileDeleted: (file) => - socket.log(`File deleted: ${relativePath(file)}`), + websocket.log(`File deleted: ${relativePath(file)}`), } ); - // clean up build directories when dev server exits - exitHook(() => clean(config)); - return async () => { + let httpServer = express() + .use(express.json()) + .post("/ping", (req, res) => { + let { buildHash } = req.body; + if (typeof buildHash !== "string") { + console.warn(`Unrecognized payload: ${req.body}`); + res.sendStatus(400); + } + if (buildHash === state.latestBuildHash) { + state.buildHashChannel?.ok(); + } + res.sendStatus(200); + }) + .listen(options.httpPort, () => { + console.log(`dev server listening on port ${options.httpPort}`); + }); + + return new Promise(() => {}).finally(async () => { + await kill(state.appServer); + websocket.close(); + httpServer.close(); await dispose(); - socket.close(); - }; + }); +}; + +let clean = (config: RemixConfig) => { + try { + fs.emptyDirSync(config.relativeAssetsBuildDirectory); + } catch {} +}; + +let relativePath = (file: string) => path.relative(process.cwd(), file); + +let kill = async (p?: execa.ExecaChildProcess) => { + if (p === undefined) return; + // `execa`'s `kill` is not reliable on windows + if (process.platform === "win32") { + await execa("taskkill", ["/pid", String(p.pid), "/f", "/t"]); + return; + } + p.kill(); }; diff --git a/packages/remix-dev/devServer_unstable/ping.ts b/packages/remix-dev/devServer_unstable/ping.ts new file mode 100644 index 00000000000..c9f58d89994 --- /dev/null +++ b/packages/remix-dev/devServer_unstable/ping.ts @@ -0,0 +1,25 @@ +import type { ServerBuild } from "@remix-run/server-runtime"; + +export let ping = ( + build: ServerBuild, + options: { + scheme?: string; + host?: string; + port?: number; + } = {} +) => { + let scheme = options.scheme ?? "http"; + let host = options.host ?? "localhost"; + let port = options.port ?? Number(process.env.REMIX_DEV_HTTP_PORT); + if (!port) throw Error("Dev server port not set"); + if (isNaN(port)) + throw Error( + `Dev server port must be a number. Got: ${JSON.stringify(port)}` + ); + + fetch(`${scheme}://${host}:${port}/ping`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ buildHash: build.assets.version }), + }); +}; diff --git a/packages/remix-dev/devServer_unstable/liveReload.ts b/packages/remix-dev/devServer_unstable/socket.ts similarity index 95% rename from packages/remix-dev/devServer_unstable/liveReload.ts rename to packages/remix-dev/devServer_unstable/socket.ts index 919d7ca2aa8..83ea95fb5ca 100644 --- a/packages/remix-dev/devServer_unstable/liveReload.ts +++ b/packages/remix-dev/devServer_unstable/socket.ts @@ -25,17 +25,17 @@ export let serve = (options: { port: number }) => { }); }; - let reload = () => broadcast({ type: "RELOAD" }); - let log = (messageText: string) => { let _message = `💿 ${messageText}`; console.log(_message); broadcast({ type: "LOG", message: _message }); }; + let reload = () => broadcast({ type: "RELOAD" }); + let hmr = (assetsManifest: Manifest, updates: HMR.Update[]) => { broadcast({ type: "HMR", assetsManifest, updates }); }; - return { reload, hmr, log, close: wss.close }; + return { log, reload, hmr, close: wss.close }; }; diff --git a/packages/remix-dev/index.ts b/packages/remix-dev/index.ts index 70796298feb..28e02a58a8e 100644 --- a/packages/remix-dev/index.ts +++ b/packages/remix-dev/index.ts @@ -7,3 +7,5 @@ export { createApp } from "./cli/create"; export { type Manifest as AssetsManifest } from "./manifest"; export { getDependenciesToBundle } from "./dependencies"; + +export { ping } from "./devServer_unstable/ping"; diff --git a/packages/remix-dev/result.ts b/packages/remix-dev/result.ts new file mode 100644 index 00000000000..eaa82557b6b --- /dev/null +++ b/packages/remix-dev/result.ts @@ -0,0 +1,7 @@ +type Ok = { ok: true; value: V }; +type Err = { ok: false; error: E }; + +export type Result = Ok | Err; + +export let ok = (value: V): Ok => ({ ok: true, value }); +export let err = (error: E): Err => ({ ok: false, error }); diff --git a/packages/remix-server-runtime/server.ts b/packages/remix-server-runtime/server.ts index 23cf0c442a2..8897807b130 100644 --- a/packages/remix-server-runtime/server.ts +++ b/packages/remix-server-runtime/server.ts @@ -51,26 +51,6 @@ export const createRequestHandler: CreateRequestHandlerFunction = ( return async function requestHandler(request, loadContext = {}) { let url = new URL(request.url); - // special __REMIX_ASSETS_MANIFEST endpoint for checking if app server serving up-to-date routes and assets - let { unstable_dev } = build.future; - if ( - mode === "development" && - unstable_dev !== false && - url.pathname === - (unstable_dev === true - ? "/__REMIX_ASSETS_MANIFEST" - : (unstable_dev.remixRequestHandlerPath ?? "") + - "/__REMIX_ASSETS_MANIFEST") - ) { - if (request.method !== "GET") { - return new Response("Method not allowed", { status: 405 }); - } - return new Response(JSON.stringify(build.assets), { - status: 200, - headers: { "Content-Type": "application/json" }, - }); - } - let matches = matchServerRoutes(routes, url.pathname); let response: Response; diff --git a/yarn.lock b/yarn.lock index ab66d9deb49..5c6a629a76d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3602,11 +3602,6 @@ abab@^2.0.3, abab@^2.0.5: resolved "https://registry.npmjs.org/abab/-/abab-2.0.5.tgz" integrity sha512-9IK9EadsbHo6jLWIpxpR6pL0sazTXV6+SQv25ZB+F7Bj9mJNaOc4nCRabwd5M/JwmUa8idz6Eci6eKfJryPs6Q== -abbrev@1: - version "1.1.1" - resolved "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz#f8f2c887ad10bf67f634f005b6987fed3179aac8" - integrity sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q== - abort-controller@^3.0.0: version "3.0.0" resolved "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz" @@ -4741,7 +4736,7 @@ choices-separator@^2.0.0: debug "^2.6.6" strip-color "^0.1.0" -chokidar@^3.4.2, chokidar@^3.5.1, chokidar@^3.5.2, chokidar@^3.5.3: +chokidar@^3.4.2, chokidar@^3.5.1, chokidar@^3.5.3: version "3.5.3" resolved "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz#1cf37c8707b932bd1af1ae22c0432e2acd1903bd" integrity sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw== @@ -7521,11 +7516,6 @@ ieee754@^1.1.13: resolved "https://registry.npmjs.org/ieee754/-/ieee754-1.1.13.tgz" integrity sha512-4vf7I2LYV/HaWerSo3XmlMkp5eZ83i+/CDluXi/IGTs/O1sejBNhTtnxzmRZfvOUqj7lZjqHkeTvpgSFDlWZTg== -ignore-by-default@^1.0.1: - version "1.0.1" - resolved "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz#48ca6d72f6c6a3af00a9ad4ae6876be3889e2b09" - integrity sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA== - ignore@^5.1.1, ignore@^5.2.0: version "5.2.0" resolved "https://registry.npmjs.org/ignore/-/ignore-5.2.0.tgz" @@ -10182,29 +10172,6 @@ node-webtokens@^1.0.4: resolved "https://registry.npmjs.org/node-webtokens/-/node-webtokens-1.0.4.tgz" integrity sha512-Sla56CeSLWvPbwud2kogqf5edQtKNXZBtXDDpmOzAgNZjwETbK/Am6PXfs54iZPLBm8K8amZ9XWaCQwGqZmKyQ== -nodemon@^2.0.20: - version "2.0.20" - resolved "https://registry.npmjs.org/nodemon/-/nodemon-2.0.20.tgz#e3537de768a492e8d74da5c5813cb0c7486fc701" - integrity sha512-Km2mWHKKY5GzRg6i1j5OxOHQtuvVsgskLfigG25yTtbyfRGn/GNvIbRyOf1PSCKJ2aT/58TiuUsuOU5UToVViw== - dependencies: - chokidar "^3.5.2" - debug "^3.2.7" - ignore-by-default "^1.0.1" - minimatch "^3.1.2" - pstree.remy "^1.1.8" - semver "^5.7.1" - simple-update-notifier "^1.0.7" - supports-color "^5.5.0" - touch "^3.1.0" - undefsafe "^2.0.5" - -nopt@~1.0.10: - version "1.0.10" - resolved "https://registry.npmjs.org/nopt/-/nopt-1.0.10.tgz#6ddd21bd2a31417b92727dd585f8a6f37608ebee" - integrity sha512-NWmpvLSqUrgrAC9HCuxEvb+PSloHpqVu+FqcO4eeF2h5qYRhA7ev6KvelyQAKtegUbC6RypJnlEOhd8vloNKYg== - dependencies: - abbrev "1" - normalize-package-data@^2.3.2, normalize-package-data@^2.5.0: version "2.5.0" resolved "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-2.5.0.tgz" @@ -11109,11 +11076,6 @@ psl@^1.1.28, psl@^1.1.33: resolved "https://registry.npmjs.org/psl/-/psl-1.8.0.tgz" integrity sha512-RIdOzyoavK+hA18OGGWDqUTsCLhtA7IcZ/6NCs4fFJaHBDab+pDDmDIByWFRQJq2Cd7r1OoQxBGKOaztq+hjIQ== -pstree.remy@^1.1.8: - version "1.1.8" - resolved "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz#c242224f4a67c21f686839bbdb4ac282b8373d3a" - integrity sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w== - pump@^2.0.0: version "2.0.1" resolved "https://registry.npmjs.org/pump/-/pump-2.0.1.tgz" @@ -11762,12 +11724,12 @@ scheduler@^0.23.0: dependencies: loose-envify "^1.1.0" -"semver@2 || 3 || 4 || 5", semver@^5.4.1, semver@^5.5.0, semver@^5.6.0, semver@^5.7.1: +"semver@2 || 3 || 4 || 5", semver@^5.4.1, semver@^5.5.0, semver@^5.6.0: version "5.7.1" resolved "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz" integrity sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ== -semver@7.0.0, semver@~7.0.0: +semver@7.0.0: version "7.0.0" resolved "https://registry.npmjs.org/semver/-/semver-7.0.0.tgz" integrity sha512-+GB6zVA9LWh6zovYQLALHwv5rb2PHGlJi3lfiqIHxR0uuwCgefcOJc59v9fv1w8GbStwxuuqqAjI9NMAOOgq1A== @@ -11919,13 +11881,6 @@ simple-git@^3.16.0: "@kwsites/promise-deferred" "^1.1.1" debug "^4.3.4" -simple-update-notifier@^1.0.7: - version "1.1.0" - resolved "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-1.1.0.tgz#67694c121de354af592b347cdba798463ed49c82" - integrity sha512-VpsrsJSUcJEseSbMHkrsrAVSdvVS5I96Qo1QAQ4FxQ9wXFcB+pjj7FB7/us9+GcgfW4ziHtYMc1J0PLczb55mg== - dependencies: - semver "~7.0.0" - sisteransi@^1.0.5: version "1.0.5" resolved "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz" @@ -12402,7 +12357,7 @@ supertest@^6.1.5: methods "^1.1.2" superagent "^6.1.0" -supports-color@^5.3.0, supports-color@^5.5.0: +supports-color@^5.3.0: version "5.5.0" resolved "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz" integrity sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow== @@ -12678,13 +12633,6 @@ toml@^3.0.0: resolved "https://registry.npmjs.org/toml/-/toml-3.0.0.tgz" integrity sha512-y/mWCZinnvxjTKYhJ+pYxwD0mRLVvOtdS2Awbgxln6iEnt4rk0yBxeSBHkGJcPucRiG0e55mwWp+g/05rsrd6w== -touch@^3.1.0: - version "3.1.0" - resolved "https://registry.npmjs.org/touch/-/touch-3.1.0.tgz#fe365f5f75ec9ed4e56825e0bb76d24ab74af83b" - integrity sha512-WBx8Uy5TLtOSRtIq+M03/sKDrXCLHxwDcquSP2c43Le03/9serjQBIztjRz6FkJez9D/hleyAXTBGLwwZUw9lA== - dependencies: - nopt "~1.0.10" - tough-cookie@^4.0.0: version "4.0.0" resolved "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.0.0.tgz" @@ -12962,11 +12910,6 @@ unbox-primitive@^1.0.2: has-symbols "^1.0.3" which-boxed-primitive "^1.0.2" -undefsafe@^2.0.5: - version "2.0.5" - resolved "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz#38733b9327bdcd226db889fb723a6efd162e6e2c" - integrity sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA== - 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"