Skip to content

Commit

Permalink
The new dev server (#5133)
Browse files Browse the repository at this point in the history
* refactor(dev): builds return the updated assets manifest

* refactor(dev): inject live reload port during build

* feat(dev): new dev server via future flag

* test(dev): update tests for new dev server

* Create mean-clocks-bow.md
  • Loading branch information
pcattori authored Jan 18, 2023
1 parent ba90a58 commit 45ba8d6
Show file tree
Hide file tree
Showing 22 changed files with 277 additions and 23 deletions.
54 changes: 54 additions & 0 deletions .changeset/mean-clocks-bow.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
---
"remix": minor
"@remix-run/dev": minor
"@remix-run/react": minor
"@remix-run/serve": minor
"@remix-run/server-runtime": minor
---

# The new dev server

The new dev flow is to spin up the dev server _alongside_ your normal Remix app server:

```sh
# spin up the new dev server
remix dev

# spin up your app server in a separate tab or via `concurrently`
nodemon ./server.js
```

The dev server will build your app in dev mode and then rebuild whenever any app files change.
It will also wait for your app server to be "ready" (more on this later) before triggering a live reload in your browser.

## Benefits

- Navigations no longer wipe in-memory references (e.g. database connections, in-memory caches, etc...). That means no need to use `global` trick anymore.
- Supports _any_ app server, not just the Remix App Server.
- Automatically wires up the live reload port for you (no need for you to mess with env vars for that anymore)

## App server picks up changes

Use `nodemon` (or similar) so that your app server restarts and picks up changes after a rebuild finishes.

For example, you can use `wrangler --watch` for Cloudflare.

Alternatively, you can roll your own with `chokidar` (or similar) if you want to still use the `global` trick to persist in-memory stuff across rebuilds.

## Configure

- Dev server port
- flag: `--port`
- future config: `unstable_dev.port`
- default: finds an empty port to use
- App server port
- flag: `--app-server-port`
- future config: `unstable_dev.appServerPort`
- default: `3000`
- Remix request handler path
- Most Remix apps shouldn't need this, but if you wire up the Remix request handler at a specific URL path set this to that path so that the dev server can reliably check your app server for "readiness"
- future flag: `unstable_dev.remixRequestHandlerPath`
- default: `''`
- Rebuild poll interval (milliseconds)
- future config: `unstable_dev.rebuildPollIntervalMs`
- default: 50ms
1 change: 1 addition & 0 deletions packages/remix-dev/__tests__/readConfig-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ describe("readConfig", () => {
"future": Object {
"unstable_cssModules": Any<Boolean>,
"unstable_cssSideEffectImports": Any<Boolean>,
"unstable_dev": false,
"unstable_vanillaExtract": Any<Boolean>,
"v2_errorBoundary": Any<Boolean>,
"v2_meta": Any<Boolean>,
Expand Down
14 changes: 12 additions & 2 deletions packages/remix-dev/cli/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import * as esbuild from "esbuild";
import * as colors from "../colors";
import * as compiler from "../compiler";
import * as devServer from "../devServer";
import * as devServer2 from "../devServer2";
import type { RemixConfig } from "../config";
import { readConfig } from "../config";
import { formatRoutes, RoutesFormat, isRoutesFormat } from "../config/format";
Expand Down Expand Up @@ -194,10 +195,19 @@ export async function watch(
});
}

export async function dev(remixRoot: string, modeArg?: string, port?: number) {
export async function dev(
remixRoot: string,
modeArg?: string,
flags: { port?: number; appServerPort?: number } = {}
) {
let config = await readConfig(remixRoot);
let mode = compiler.parseMode(modeArg ?? "", "development");
return devServer.serve(config, mode, port);

if (config.future.unstable_dev !== false) {
return devServer2.serve(config, flags);
}

return devServer.serve(config, mode, flags.port);
}

export async function codemod(
Expand Down
6 changes: 4 additions & 2 deletions packages/remix-dev/cli/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,11 +132,12 @@ const npxInterop = {

async function dev(
projectDir: string,
flags: { debug?: boolean; port?: number }
flags: { debug?: boolean; port?: number; appServerPort?: number }
) {
if (!process.env.NODE_ENV) process.env.NODE_ENV = "development";

if (flags.debug) inspector.open();
await commands.dev(projectDir, process.env.NODE_ENV, flags.port);
await commands.dev(projectDir, process.env.NODE_ENV, flags);
}

/**
Expand All @@ -154,6 +155,7 @@ export async function run(argv: string[] = process.argv.slice(2)) {

let args = arg(
{
"--app-server-port": Number,
"--debug": Boolean,
"--no-delete": Boolean,
"--dry": Boolean,
Expand Down
2 changes: 1 addition & 1 deletion packages/remix-dev/compiler/compilerServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ const createEsbuildConfig = (
mdxPlugin(config),
emptyModulesPlugin(config, /\.client(\.[jt]sx?)?$/),
serverRouteModulesPlugin(config),
serverEntryModulePlugin(config),
serverEntryModulePlugin(config, { liveReloadPort: options.liveReloadPort }),
serverAssetsManifestPlugin(assetsManifestChannel.read()),
serverBareModulesPlugin(config, options.onWarning),
].filter(isNotNull);
Expand Down
1 change: 1 addition & 0 deletions packages/remix-dev/compiler/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ type Target =

export type CompileOptions = {
mode: Mode;
liveReloadPort?: number;
target: Target;
sourcemap: boolean;
onWarning?: (message: string, key: string) => void;
Expand Down
12 changes: 11 additions & 1 deletion packages/remix-dev/compiler/plugins/serverEntryModulePlugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,10 @@ import {
* for you to consume the build in a custom server entry that is also fed through
* the compiler.
*/
export function serverEntryModulePlugin(config: RemixConfig): Plugin {
export function serverEntryModulePlugin(
config: RemixConfig,
options: { liveReloadPort?: number } = {}
): Plugin {
let filter = serverBuildVirtualModule.filter;

return {
Expand Down Expand Up @@ -50,6 +53,13 @@ ${Object.keys(config.routes)
export const future = ${JSON.stringify(config.future)};
export const publicPath = ${JSON.stringify(config.publicPath)};
export const entry = { module: entryServer };
${
options.liveReloadPort
? `export const dev = ${JSON.stringify({
liveReloadPort: options.liveReloadPort,
})}`
: ""
}
export const routes = {
${Object.keys(config.routes)
.map((key, index) => {
Expand Down
3 changes: 2 additions & 1 deletion packages/remix-dev/compiler/remixCompiler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,12 +28,13 @@ export const compile = async (
options: {
onCompileFailure?: OnCompileFailure;
} = {}
): Promise<void> => {
): Promise<AssetsManifest | undefined> => {
try {
let assetsManifestChannel = createChannel<AssetsManifest>();
let browserPromise = compiler.browser.compile(assetsManifestChannel);
let serverPromise = compiler.server.compile(assetsManifestChannel);
await Promise.all([browserPromise, serverPromise]);
return assetsManifestChannel.read();
} catch (error: unknown) {
options.onCompileFailure?.(error as Error);
}
Expand Down
13 changes: 8 additions & 5 deletions packages/remix-dev/compiler/watch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import * as path from "path";

import type { RemixConfig } from "../config";
import { readConfig } from "../config";
import type { AssetsManifest } from "./assets";
import { logCompileFailure } from "./onCompileFailure";
import type { CompileOptions } from "./options";
import { compile, createRemixCompiler, dispose } from "./remixCompiler";
Expand All @@ -21,7 +22,7 @@ function isEntryPoint(config: RemixConfig, file: string): boolean {

export type WatchOptions = Partial<CompileOptions> & {
onRebuildStart?(): void;
onRebuildFinish?(durationMs: number): void;
onRebuildFinish?(durationMs: number, assetsManifest?: AssetsManifest): void;
onFileCreated?(file: string): void;
onFileChanged?(file: string): void;
onFileDeleted?(file: string): void;
Expand All @@ -32,6 +33,7 @@ export async function watch(
config: RemixConfig,
{
mode = "development",
liveReloadPort,
target = "node14",
sourcemap = true,
onWarning = warnOnce,
Expand All @@ -46,6 +48,7 @@ export async function watch(
): Promise<() => Promise<void>> {
let options: CompileOptions = {
mode,
liveReloadPort,
target,
sourcemap,
onCompileFailure,
Expand All @@ -72,15 +75,15 @@ export async function watch(
}

compiler = createRemixCompiler(config, options);
await compile(compiler);
onRebuildFinish?.(Date.now() - start);
let assetsManifest = await compile(compiler);
onRebuildFinish?.(Date.now() - start, assetsManifest);
}, 500);

let rebuild = debounce(async () => {
onRebuildStart?.();
let start = Date.now();
await compile(compiler, { onCompileFailure });
onRebuildFinish?.(Date.now() - start);
let assetsManifest = await compile(compiler, { onCompileFailure });
onRebuildFinish?.(Date.now() - start, assetsManifest);
}, 100);

let toWatch = [config.appDirectory];
Expand Down
11 changes: 10 additions & 1 deletion packages/remix-dev/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,17 @@ export type ServerBuildTarget =
export type ServerModuleFormat = "esm" | "cjs";
export type ServerPlatform = "node" | "neutral";

type Dev = {
port?: number;
appServerPort?: number;
remixRequestHandlerPath?: string;
rebuildPollIntervalMs?: number;
};

interface FutureConfig {
unstable_cssModules: boolean;
unstable_cssSideEffectImports: boolean;
unstable_dev: false | Dev;
unstable_vanillaExtract: boolean;
v2_errorBoundary: boolean;
v2_meta: boolean;
Expand Down Expand Up @@ -491,10 +499,11 @@ export async function readConfig(
writeConfigDefaults(tsconfigPath);
}

let future = {
let future: FutureConfig = {
unstable_cssModules: appConfig.future?.unstable_cssModules === true,
unstable_cssSideEffectImports:
appConfig.future?.unstable_cssSideEffectImports === true,
unstable_dev: appConfig.future?.unstable_dev ?? false,
unstable_vanillaExtract: appConfig.future?.unstable_vanillaExtract === true,
v2_errorBoundary: appConfig.future?.v2_errorBoundary === true,
v2_meta: appConfig.future?.v2_meta === true,
Expand Down
106 changes: 106 additions & 0 deletions packages/remix-dev/devServer2.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
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 { type AssetsManifest } from "./assets-manifest";
import * as Compiler from "./compiler";
import { type RemixConfig } from "./config";
import { loadEnv } from "./env";
import * as LiveReload from "./liveReload";

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 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<AssetsManifest | undefined> => {
try {
let url = origin + remixRequestHandlerPath + "/__REMIX_ASSETS_MANIFEST";
let res = await fetch(url);
let assetsManifest = (await res.json()) as AssetsManifest;
return assetsManifest;
} catch (error) {
return undefined;
}
};

export let serve = async (
config: RemixConfig,
flags: { port?: number; appServerPort?: number } = {}
) => {
await loadEnv(config.rootDirectory);

let { unstable_dev } = config.future;
if (unstable_dev === false)
throw Error("The new dev server requires 'unstable_dev' to be set");
let { remixRequestHandlerPath, rebuildPollIntervalMs } = unstable_dev;
let appServerPort = flags.appServerPort ?? unstable_dev.appServerPort ?? 3000;

let host = getHost();
let appServerOrigin = `http://${host ?? "localhost"}:${appServerPort}`;

let waitForAppServer = async (buildHash: string) => {
while (true) {
// TODO AbortController signal to cancel responses?
let assetsManifest = await fetchAssetsManifest(
appServerOrigin,
remixRequestHandlerPath ?? ""
);
if (assetsManifest?.version === buildHash) return;

await sleep(rebuildPollIntervalMs ?? 50);
}
};

// watch and live reload on rebuilds
let port = await findPort(flags.port ?? unstable_dev.port);
let socket = LiveReload.serve({ port });
let dispose = await Compiler.watch(config, {
mode: "development",
liveReloadPort: port,
onInitialBuild: (durationMs) => info(`Built in ${prettyMs(durationMs)}`),
onRebuildStart: () => socket.log("Rebuilding..."),
onRebuildFinish: async (durationMs, assetsManifest) => {
if (!assetsManifest) return;
socket.log(`Rebuilt in ${prettyMs(durationMs)}`);

info(`Waiting for ${appServerOrigin}...`);
let start = Date.now();
await waitForAppServer(assetsManifest.version);
info(`${appServerOrigin} ready in ${prettyMs(Date.now() - start)}`);

socket.reload();
},
onFileCreated: (file) => socket.log(`File created: ${relativePath(file)}`),
onFileChanged: (file) => socket.log(`File changed: ${relativePath(file)}`),
onFileDeleted: (file) => socket.log(`File deleted: ${relativePath(file)}`),
});

// TODO exit hook: clean up assetsBuildDirectory and serverBuildPath?

return async () => {
await dispose();
socket.close();
};
};
27 changes: 27 additions & 0 deletions packages/remix-dev/liveReload.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import WebSocket from "ws";

type Message = { type: "RELOAD" } | { type: "LOG"; message: string };

type Broadcast = (message: Message) => void;

export let serve = (options: { port: number }) => {
let wss = new WebSocket.Server({ port: options.port });

let broadcast: Broadcast = (message) => {
wss.clients.forEach((client) => {
if (client.readyState === WebSocket.OPEN) {
client.send(JSON.stringify(message));
}
});
};

let reload = () => broadcast({ type: "RELOAD" });

let log = (messageText: string) => {
let _message = `💿 ${messageText}`;
console.log(_message);
broadcast({ type: "LOG", message: _message });
};

return { reload, log, close: wss.close };
};
Loading

0 comments on commit 45ba8d6

Please sign in to comment.