Repository: remix-run/remix (ts)

Issue:
Unable to get HMR/ HDR for HTTPS express server - Could not reach Remix dev server at https://localhost:3001
### What version of Remix are you using?

1.16.0

### Are all your remix dependencies & dev-dependencies using the same version?

- [X] Yes

### Steps to Reproduce

Example repo of a new Remix Express app using HTTPS: https://github.com/cliffordfajardo/remix-express-https-hmr/tree/main


## 1. Install dependencies

```sh
npm i
```

## 2. Generate self-signed certs
Since we intend on using HTTPs, we need to create self-signed certificates.
After running the command below, you will be prompted to give answers to the openssl CLI tool that generates the cert `server.key` and `server.cert` file.


```sh
npm run gen-cert

# For details see this small blog post:  https://flaviocopes.com/express-https-self-signed-certificate/
```

## 3. Start the remix app
```sh
## NOTE: you will be prompted to fill in a few values from openssl CLI tool. See: https://flaviocopes.com/express-https-self-signed-certificate/
npm run dev
```

### Expected Behavior

The dev server should be reachable via HTTPS when passing https settings inside of `remix.config.js`
See sample repo: https://github.com/cliffordfajardo/remix-express-https- 


```js
/** @type {import('@remix-run/dev').AppConfig} */
module.exports = {
  ignoredRouteFiles: ["**/.*"],
  // appDirectory: "app",
  // assetsBuildDirectory: "public/build",
  // serverBuildPath: "build/index.js",
  // publicPath: "/build/",
  // serverModuleFormat: "cjs",
  serverModuleFormat: "cjs",
  future: {
    unstable_dev: {
      httpScheme: 'https',
      httpHost: 'localhost',
      port: 15001,
    },
    v2_errorBoundary: true,
    v2_meta: true,
    v2_normalizeFormMethod: true,
    v2_routeConvention: true,
  },
};
```

### Actual Behavior

```
> remix dev --no-restart -c "node server.js"

💿 Building...
💿 Built in 609ms
Waiting for app server (52766818)
> node server.js
Remix dev server ready
Express server listening https://localhost:15001
❌ Could not reach Remix dev server at https://localhost:3001
```

Pull request description:
feat(dev): built-in tls support
Fixes #6402 #4476

Patch:
diff --git a/.changeset/ninety-hornets-brake.md b/.changeset/ninety-hornets-brake.md
new file mode 100644
index 000000000..37035a1ec
--- /dev/null
+++ b/.changeset/ninety-hornets-brake.md
@@ -0,0 +1,85 @@
+---
+"@remix-run/dev": minor
+---
+
+built-in tls support
+
+New options:
+- `--tls-key` / `tlsKey`: TLS key
+- `--tls-cert` / `tlsCert`: TLS Certificate
+
+If both TLS options are set, `scheme` defaults to `https`
+
+## Example
+
+Install [mkcert](https://github.com/FiloSottile/mkcert) and create a local CA:
+
+```sh
+brew install mkcert
+mkcert -install
+```
+
+Then make sure you inform `node` about your CA certs:
+
+```sh
+export NODE_EXTRA_CA_CERTS="$(mkcert -CAROOT)/rootCA.pem"
+```
+
+👆 You'll probably want to put that env var in your scripts or `.bashrc`/`.zshrc`
+
+Now create `key.pem` and `cert.pem`:
+
+```sh
+mkcert -key-file key.pem -cert-file cert.pem localhost
+```
+
+See `mkcert` docs for more details.
+
+Finally, pass in the paths to the key and cert via flags:
+
+```sh
+remix dev --tls-key=key.pem --tls-cert=cert.pem
+```
+
+or via config:
+
+```js
+module.exports = {
+  future: {
+    unstable_dev:  {
+      tlsKey: "key.pem",
+      tlsCert: "cert.pem",
+    }
+  }
+}
+```
+
+That's all that's needed to set up the Remix Dev Server with TLS.
+
+🚨 Make sure to update your app server for TLS as well.
+
+For example, with `express`:
+
+```ts
+import express from 'express'
+import https from 'node:https'
+import fs from 'node:fs'
+
+let app = express()
+
+// ...code setting up your express app...
+
+let appServer = https.createServer({
+  key: fs.readFileSync("key.pem"),
+  cert: fs.readFileSync("cert.pem"),
+}, app)
+
+appServer.listen(3000, () => {
+  console.log('Ready on https://localhost:3000')
+})
+```
+
+## Known limitations
+
+`remix-serve` does not yet support TLS.
+That means this only works for custom app server using the `-c` flag for now.
diff --git a/packages/remix-dev/cli/commands.ts b/packages/remix-dev/cli/commands.ts
index 8df2b5050..0a9074923 100644
--- a/packages/remix-dev/cli/commands.ts
+++ b/packages/remix-dev/cli/commands.ts
@@ -217,6 +217,8 @@ export async function dev(
     host?: string;
     port?: number;
     restart?: boolean;
+    tlsKey?: string;
+    tlsCert?: string;
   } = {}
 ) {
   if (process.env.NODE_ENV && process.env.NODE_ENV !== "development") {
@@ -472,7 +474,10 @@ type DevOrigin = {
 };
 let resolveDevOrigin = async (
   config: RemixConfig,
-  flags: Partial<DevOrigin> = {}
+  flags: Partial<DevOrigin> & {
+    tlsKey?: string;
+    tlsCert?: string;
+  } = {}
 ): Promise<DevOrigin> => {
   let dev = config.future.unstable_dev;
   if (dev === false) throw Error("This should never happen");
@@ -481,7 +486,7 @@ let resolveDevOrigin = async (
   let scheme =
     flags.scheme ??
     (dev === true ? undefined : dev.scheme) ??
-    "http";
+    (flags.tlsKey && flags.tlsCert) ? "https": "http";
   // prettier-ignore
   let host =
     flags.host ??
@@ -503,6 +508,8 @@ let resolveDevOrigin = async (
 type DevServeFlags = DevOrigin & {
   command?: string;
   restart: boolean;
+  tlsKey?: string;
+  tlsCert?: string;
 };
 let resolveDevServe = async (
   config: RemixConfig,
@@ -521,9 +528,16 @@ let resolveDevServe = async (
   let restart =
     flags.restart ?? (dev === true ? undefined : dev.restart) ?? true;
 
+  let tlsKey = flags.tlsKey ?? (dev === true ? undefined : dev.tlsKey);
+  if (tlsKey) tlsKey = path.resolve(tlsKey);
+  let tlsCert = flags.tlsCert ?? (dev === true ? undefined : dev.tlsCert);
+  if (tlsCert) tlsCert = path.resolve(tlsCert);
+
   return {
     command,
     ...origin,
     restart,
+    tlsKey,
+    tlsCert,
   };
 };
diff --git a/packages/remix-dev/cli/run.ts b/packages/remix-dev/cli/run.ts
index f1d8bcde7..abba2fdc9 100644
--- a/packages/remix-dev/cli/run.ts
+++ b/packages/remix-dev/cli/run.ts
@@ -48,6 +48,8 @@ ${colors.logoBlue("R")} ${colors.logoGreen("E")} ${colors.logoYellow(
     --host              Host for the dev server. Default: localhost
     --port              Port for the dev server. Default: any open port
     --no-restart        Do not restart the app server when rebuilds occur.
+    --tls-key           Path to TLS key (key.pem)
+    --tls-cert          Path to TLS certificate (cert.pem)
   \`init\` Options:
     --no-delete         Skip deleting the \`remix.init\` script
   \`routes\` Options:
@@ -186,6 +188,8 @@ export async function run(argv: string[] = process.argv.slice(2)) {
       "--port": Number,
       "-p": "--port",
       "--no-restart": Boolean,
+      "--tls-key": String,
+      "--tls-cert": String,
     },
     {
       argv,
@@ -210,6 +214,15 @@ export async function run(argv: string[] = process.argv.slice(2)) {
     return;
   }
 
+  if (flags["tls-key"]) {
+    flags.tlsKey = flags["tls-key"];
+    delete flags["tls-key"];
+  }
+  if (flags["tls-cert"]) {
+    flags.tlsCert = flags["tls-cert"];
+    delete flags["tls-cert"];
+  }
+
   if (args["--no-delete"]) {
     flags.delete = false;
   }
diff --git a/packages/remix-dev/config.ts b/packages/remix-dev/config.ts
index e431e14d9..68e91d486 100644
--- a/packages/remix-dev/config.ts
+++ b/packages/remix-dev/config.ts
@@ -42,6 +42,8 @@ type Dev = {
   host?: string;
   port?: number;
   restart?: boolean;
+  tlsKey?: string;
+  tlsCert?: string;
 };
 
 interface FutureConfig {
diff --git a/packages/remix-dev/devServer_unstable/index.ts b/packages/remix-dev/devServer_unstable/index.ts
index 009a24e74..f9b47c0f1 100644
--- a/packages/remix-dev/devServer_unstable/index.ts
+++ b/packages/remix-dev/devServer_unstable/index.ts
@@ -1,6 +1,7 @@
 import * as path from "node:path";
 import * as stream from "node:stream";
 import * as http from "node:http";
+import * as https from "node:https";
 import fs from "fs-extra";
 import prettyMs from "pretty-ms";
 import execa from "execa";
@@ -47,6 +48,8 @@ export let serve = async (
     host: string;
     port: number;
     restart: boolean;
+    tlsKey?: string;
+    tlsCert?: string;
   }
 ) => {
   await loadEnv(initialConfig.rootDirectory);
@@ -74,7 +77,16 @@ export let serve = async (
       res.sendStatus(200);
     });
 
-  let server = http.createServer(app);
+  let server =
+    options.tlsKey && options.tlsCert
+      ? https.createServer(
+          {
+            key: fs.readFileSync(options.tlsKey),
+            cert: fs.readFileSync(options.tlsCert),
+          },
+          app
+        )
+      : http.createServer(app);
   let websocket = Socket.serve(server);
 
   let origin: Origin = {


Test patch:
diff --git a/packages/remix-dev/__tests__/cli-test.ts b/packages/remix-dev/__tests__/cli-test.ts
index 5e14cc52a..91d8fea10 100644
--- a/packages/remix-dev/__tests__/cli-test.ts
+++ b/packages/remix-dev/__tests__/cli-test.ts
@@ -122,6 +122,8 @@ describe("remix CLI", () => {
             --host              Host for the dev server. Default: localhost
             --port              Port for the dev server. Default: any open port
             --no-restart        Do not restart the app server when rebuilds occur.
+            --tls-key           Path to TLS key (key.pem)
+            --tls-cert          Path to TLS certificate (cert.pem)
           \`init\` Options:
             --no-delete         Skip deleting the \`remix.init\` script
           \`routes\` Options: