Repository: remix-run/remix
Pull request #6173 (2023-04-25 19:51:28)

fix(dev): statically serve built assets from dev server
<!--

👋 Hey, thanks for your interest in contributing to Remix!

If this is a simple docs change, go ahead and delete all this.

**Please ask first before starting work on any significant new features.**

It's never a fun experience to have your pull request declined after investing a
lot of time and effort into a new feature. To avoid this from happening, we
request that contributors create a
[Feature Request discussion](https://github.com/remix-run/remix/discussions/new?category=ideas)
to first discuss any significant new features.

https://github.com/remix-run/remix/blob/main/CONTRIBUTING.md

Please fill in or delete each item below:

-->

Closes: #

- [ ] Docs
- [ ] Tests

Testing Strategy:

<!--
Please explain how you tested this. For example:

> This test covers this code: <link to test>

Or

> I opened up my windows machine and ran this script:
>
> ```
> npx create-remix@0.0.0-experimental-7e420ee3 --template remix my-test
> cd my-test
> npm run dev
> ```
-->


changeset-bot[bot]: ###  ⚠️  No Changeset found

Latest commit: 455f89c382be18ea13166d46e287da15d7221f10

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. **If these changes should result in a version bump, you need to add a changeset.**

<details><summary>This PR includes no changesets</summary>

  When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

</details>

[Click here to learn what changesets are, and how to add one](https://github.com/changesets/changesets/blob/main/docs/adding-a-changeset.md).

[Click here if you're a maintainer who wants to add a changeset to this PR](https://github.com/remix-run/remix/new/pedro/fix-dev-server-assets?filename=.changeset/tasty-oranges-retire.md&value=---%0A%22remix%22%3A%20patch%0A%22%40remix-run%2Fdev%22%3A%20patch%0A%22%40remix-run%2Fserve%22%3A%20patch%0A%22%40remix-run%2Fserver-runtime%22%3A%20patch%0A---%0A%0APedro%2Ffix%20dev%20server%20assets%0A)



Patch:
diff --git a/.changeset/dev-server.md b/.changeset/dev-server.md
index 570a90389..5273e5637 100644
--- a/.changeset/dev-server.md
+++ b/.changeset/dev-server.md
@@ -7,6 +7,8 @@ Dev server improvements
 
 - Push-based app server syncing that doesn't rely on polling
 - App server as a managed subprocess
+- Gracefully handle new files and routes without crashing
+- Statically serve static assets to avoid fetch errors during app server reboots
 
 # Guide
 
@@ -90,21 +92,30 @@ $ 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`)
+Make sure you navigate to your app server's URL in the browser, not the dev server's URL.
+In this example, that would be `http://localhost:3000`.
 
 # Configuration
 
+Most users won't need to configure the dev server, but you might need to if:
+
+- You are setting up custom origins for SSL support or for Docker networking
+- You want to handle server updates yourself (e.g. via require cache purging)
+
 Example:
 
 ```js
 {
   future: {
     unstable_dev: {
-      // Port internally used by the dev server to receive app server `devReady` messages
-      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
+      // HTTP(S) scheme used when sending `devReady` messages to the dev server
+      httpScheme: "https", // default: `"http"`
+      // HTTP(S) host used when sending `devReady` messages to the dev server
+      httpHost: "mycustomhost", // default: `"localhost"`
+      // HTTP(S) port internally used by the dev server to statically serve built assets and to receive app server `devReady` messages
+      httpPort: 8001, // default: Remix chooses an open port in the range 3001-3099
+      // Websocket port internally used by the dev server for sending updates to the browser (Live reload, HMR, HDR)
+      websocketPort: 8002, // 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`
@@ -113,25 +124,13 @@ Example:
 }
 ```
 
-You can also configure via flags:
+You can also configure via flags. For example:
 
 ```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 `devReady` options to specify the scheme/host/port for the dev server:
-
-```js
-devReady(build, {
-  scheme: "https", // defaults to http
-  host: "mycustomhost", // defaults to localhost
-  port: 3003, // defaults to REMIX_DEV_HTTP_PORT environment variable
-});
-```
+See `remix dev --help` for more details.
 
 ### restart
 
@@ -151,7 +150,8 @@ So for require cache purging, you'd want to:
 
 ---
 
-The ultimate solution here would be to implement _server-side_ HMR (not to be confused with the more popular client-side HMR).
+The ultimate solution for `--no-restart` would be for you to implement _server-side_ HMR for your app server.
+Note: server-side HMR is not to be confused with the client-side HMR provided by Remix.
 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.
+This is left as an exercise to the reader.
diff --git a/packages/remix-dev/cli/commands.ts b/packages/remix-dev/cli/commands.ts
index 32e41ede4..08e9eff74 100644
--- a/packages/remix-dev/cli/commands.ts
+++ b/packages/remix-dev/cli/commands.ts
@@ -177,7 +177,7 @@ export async function build(
   };
   if (config.future.unstable_dev) {
     let dev = await resolveDev(config.future.unstable_dev);
-    options.devHttpPort = dev.httpPort;
+    options.devHttpOrigin = dev.httpOrigin;
     options.devWebsocketPort = dev.websocketPort;
   }
 
@@ -212,7 +212,11 @@ export async function dev(
   flags: {
     debug?: boolean;
     port?: number; // TODO: remove for v2
+
+    // unstable_dev
     command?: string;
+    httpScheme?: string;
+    httpHost?: string;
     httpPort?: number;
     restart?: boolean;
     websocketPort?: number;
@@ -466,17 +470,27 @@ let resolveDev = async (
   dev: Exclude<RemixConfig["future"]["unstable_dev"], false>,
   flags: {
     command?: string;
+    httpScheme?: string;
+    httpHost?: string;
     httpPort?: number;
     restart?: boolean;
     websocketPort?: number;
   } = {}
 ): Promise<{
   command?: string;
-  httpPort: number;
+  httpOrigin: {
+    scheme: string;
+    host: string;
+    port: number;
+  };
   restart: boolean;
   websocketPort: number;
 }> => {
   let command = flags.command ?? (dev === true ? undefined : dev.command);
+  let httpScheme =
+    flags.httpScheme ?? (dev === true ? undefined : dev.httpScheme) ?? "http";
+  let httpHost =
+    flags.httpHost ?? (dev === true ? undefined : dev.httpHost) ?? "localhost";
   let httpPort =
     flags.httpPort ??
     (dev === true ? undefined : dev.httpPort) ??
@@ -490,7 +504,11 @@ let resolveDev = async (
 
   return {
     command,
-    httpPort,
+    httpOrigin: {
+      scheme: httpScheme,
+      host: httpHost,
+      port: httpPort,
+    },
     websocketPort,
     restart,
   };
diff --git a/packages/remix-dev/cli/run.ts b/packages/remix-dev/cli/run.ts
index 44367f940..c4448f122 100644
--- a/packages/remix-dev/cli/run.ts
+++ b/packages/remix-dev/cli/run.ts
@@ -41,6 +41,14 @@ ${colors.logoBlue("R")} ${colors.logoGreen("E")} ${colors.logoYellow(
   \`dev\` Options:
     --debug             Attach Node.js inspector
     --port, -p          Choose the port from which to run your app
+
+    [unstable_dev]
+    --command, -c       Command used to run your app server
+    --http-scheme       HTTP(S) scheme for the dev server. Default: http
+    --http-host         HTTP(S) host for the dev server. Default: localhost
+    --http-port         HTTP(S) port for the dev server. Default: any open port
+    --no-restart        Do not restart the app server when rebuilds occur.
+    --websocket-port    Websocket port for the dev server. Default: any open port
   \`init\` Options:
     --no-delete         Skip deleting the \`remix.init\` script
   \`routes\` Options:
@@ -151,14 +159,11 @@ export async function run(argv: string[] = process.argv.slice(2)) {
 
   let args = arg(
     {
-      "--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,
@@ -168,8 +173,6 @@ 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,
@@ -177,6 +180,14 @@ export async function run(argv: string[] = process.argv.slice(2)) {
       "--no-typescript": Boolean,
       "--version": Boolean,
       "-v": "--version",
+
+      // dev server
+      "--command": String,
+      "-c": "--command",
+      "--http-scheme": String,
+      "--http-host": String,
+      "--http-port": Number,
+      "--no-restart": Boolean,
       "--websocket-port": Number,
     },
     {
@@ -202,6 +213,14 @@ export async function run(argv: string[] = process.argv.slice(2)) {
     return;
   }
 
+  if (flags["http-scheme"]) {
+    flags.httpScheme = flags["http-scheme"];
+    delete flags["http-scheme"];
+  }
+  if (flags["http-host"]) {
+    flags.httpHost = flags["http-host"];
+    delete flags["http-host"];
+  }
   if (flags["http-port"]) {
     flags.httpPort = flags["http-port"];
     delete flags["http-port"];
diff --git a/packages/remix-dev/compiler/options.ts b/packages/remix-dev/compiler/options.ts
index e42f1156d..d2f9dc979 100644
--- a/packages/remix-dev/compiler/options.ts
+++ b/packages/remix-dev/compiler/options.ts
@@ -6,6 +6,10 @@ export type Options = {
   onWarning?: (message: string, key: string) => void;
 
   // TODO: required in v2
-  devHttpPort?: number;
+  devHttpOrigin?: {
+    scheme: string;
+    host: string;
+    port: number;
+  };
   devWebsocketPort?: number;
 };
diff --git a/packages/remix-dev/compiler/server/compiler.ts b/packages/remix-dev/compiler/server/compiler.ts
index cc7576d91..3dd99e80f 100644
--- a/packages/remix-dev/compiler/server/compiler.ts
+++ b/packages/remix-dev/compiler/server/compiler.ts
@@ -102,8 +102,8 @@ const createEsbuildConfig = (
       "process.env.REMIX_DEV_SERVER_WS_PORT": JSON.stringify(
         ctx.config.devServerPort
       ),
-      "process.env.REMIX_DEV_HTTP_PORT": JSON.stringify(
-        ctx.options.devHttpPort ?? ""
+      "process.env.REMIX_DEV_HTTP_ORIGIN": JSON.stringify(
+        ctx.options.devHttpOrigin ?? "" // TODO: remove nullish check in v2
       ),
     },
     jsx: "automatic",
diff --git a/packages/remix-dev/config.ts b/packages/remix-dev/config.ts
index af7d0455b..5133fe6d5 100644
--- a/packages/remix-dev/config.ts
+++ b/packages/remix-dev/config.ts
@@ -41,6 +41,8 @@ type Dev = {
   port?: number; // TODO: remove in v2
 
   command?: string;
+  httpScheme?: string;
+  httpHost?: string;
   httpPort?: number;
   websocketPort?: number;
   restart?: boolean;
diff --git a/packages/remix-dev/devServer_unstable/index.ts b/packages/remix-dev/devServer_unstable/index.ts
index af23ce0d0..bcbffac97 100644
--- a/packages/remix-dev/devServer_unstable/index.ts
+++ b/packages/remix-dev/devServer_unstable/index.ts
@@ -7,18 +7,43 @@ 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 { readConfig, type RemixConfig } from "../config";
 import { loadEnv } from "./env";
 import * as Socket from "./socket";
 import * as HMR from "./hmr";
 import { warnOnce } from "../warnOnce";
 import { detectPackageManager } from "../cli/detectPackageManager";
 
+type Origin = {
+  scheme: string;
+  host: string;
+  port: number;
+};
+
+let stringifyOrigin = (o: Origin) => `${o.scheme}://${o.host}:${o.port}`;
+
+let patchPublicPath = (
+  config: RemixConfig,
+  devHttpOrigin: Origin
+): RemixConfig => {
+  // set public path to point to dev server
+  // so that browser asks the dev server for assets
+  return {
+    ...config,
+    // dev server has its own origin, to `/build/` path will not cause conflicts with app server routes
+    publicPath: stringifyOrigin(devHttpOrigin) + "/build/",
+  };
+};
+
 export let serve = async (
   initialConfig: RemixConfig,
   options: {
     command?: string;
-    httpPort: number;
+    httpOrigin: {
+      scheme: string;
+      host: string;
+      port: number;
+    };
     websocketPort: number;
     restart: boolean;
   }
@@ -42,23 +67,27 @@ export let serve = async (
       env: {
         NODE_ENV: "development",
         PATH: `${bin}:${process.env.PATH}`,
-        REMIX_DEV_HTTP_PORT: String(options.httpPort),
+        REMIX_DEV_HTTP_ORIGIN: stringifyOrigin(options.httpOrigin),
       },
     });
   };
 
   let dispose = await Compiler.watch(
     {
-      config: initialConfig,
+      config: patchPublicPath(initialConfig, options.httpOrigin),
       options: {
         mode: "development",
         sourcemap: true,
         onWarning: warnOnce,
-        devHttpPort: options.httpPort,
+        devHttpOrigin: options.httpOrigin,
         devWebsocketPort: options.websocketPort,
       },
     },
     {
+      reloadConfig: async (root) => {
+        let config = await readConfig(root);
+        return patchPublicPath(config, options.httpOrigin);
+      },
       onBuildStart: (ctx) => {
         state.buildHashChannel?.err();
         clean(ctx.config);
@@ -110,6 +139,20 @@ export let serve = async (
   );
 
   let httpServer = express()
+    // statically serve built assets
+    .use((_, res, next) => {
+      res.header("Access-Control-Allow-Origin", "*");
+      next();
+    })
+    .use(
+      "/build",
+      express.static(initialConfig.assetsBuildDirectory, {
+        immutable: true,
+        maxAge: "1y",
+      })
+    )
+
+    // handle `devReady` messages
     .use(express.json())
     .post("/ping", (req, res) => {
       let { buildHash } = req.body;
@@ -122,8 +165,8 @@ export let serve = async (
       }
       res.sendStatus(200);
     })
-    .listen(options.httpPort, () => {
-      console.log(`dev server listening on port ${options.httpPort}`);
+    .listen(options.httpOrigin.port, () => {
+      console.log("Remix dev server ready");
     });
 
   return new Promise(() => {}).finally(async () => {
diff --git a/packages/remix-server-runtime/dev.ts b/packages/remix-server-runtime/dev.ts
index 1ef987d6c..8ce5210cf 100644
--- a/packages/remix-server-runtime/dev.ts
+++ b/packages/remix-server-runtime/dev.ts
@@ -1,25 +1,17 @@
 import type { ServerBuild } from "./build";
 
-export let devReady = (
-  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)}`
-    );
+export let devReady = (build: ServerBuild, origin?: string) => {
+  origin ??= process.env.REMIX_DEV_HTTP_ORIGIN;
+  if (!origin) throw Error("Dev server origin not set");
 
-  fetch(`${scheme}://${host}:${port}/ping`, {
-    method: "POST",
-    headers: { "Content-Type": "application/json" },
-    body: JSON.stringify({ buildHash: build.assets.version }),
-  });
+  try {
+    fetch(`${origin}/ping`, {
+      method: "POST",
+      headers: { "Content-Type": "application/json" },
+      body: JSON.stringify({ buildHash: build.assets.version }),
+    });
+  } catch (error) {
+    console.error(`Could not reach Remix dev server at ${origin}`);
+    throw error;
+  }
 };


Test patch:
diff --git a/packages/remix-dev/__tests__/cli-test.ts b/packages/remix-dev/__tests__/cli-test.ts
index 9b0c8963a..ba57c1006 100644
--- a/packages/remix-dev/__tests__/cli-test.ts
+++ b/packages/remix-dev/__tests__/cli-test.ts
@@ -115,6 +115,14 @@ describe("remix CLI", () => {
           \`dev\` Options:
             --debug             Attach Node.js inspector
             --port, -p          Choose the port from which to run your app
+
+            [unstable_dev]
+            --command, -c       Command used to run your app server
+            --http-scheme       HTTP(S) scheme for the dev server. Default: http
+            --http-host         HTTP(S) host for the dev server. Default: localhost
+            --http-port         HTTP(S) port for the dev server. Default: any open port
+            --no-restart        Do not restart the app server when rebuilds occur.
+            --websocket-port    Websocket port for the dev server. Default: any open port
           \`init\` Options:
             --no-delete         Skip deleting the \`remix.init\` script
           \`routes\` Options: