diff --git a/.changeset/mean-clocks-bow.md b/.changeset/mean-clocks-bow.md new file mode 100644 index 00000000000..f24a9e6c0b3 --- /dev/null +++ b/.changeset/mean-clocks-bow.md @@ -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 diff --git a/packages/remix-dev/__tests__/readConfig-test.ts b/packages/remix-dev/__tests__/readConfig-test.ts index 38cbb33b315..98f71a24c59 100644 --- a/packages/remix-dev/__tests__/readConfig-test.ts +++ b/packages/remix-dev/__tests__/readConfig-test.ts @@ -43,6 +43,7 @@ describe("readConfig", () => { "future": Object { "unstable_cssModules": Any, "unstable_cssSideEffectImports": Any, + "unstable_dev": false, "unstable_vanillaExtract": Any, "v2_errorBoundary": Any, "v2_meta": Any, diff --git a/packages/remix-dev/cli/commands.ts b/packages/remix-dev/cli/commands.ts index 1ae39d70da7..58072db6d43 100644 --- a/packages/remix-dev/cli/commands.ts +++ b/packages/remix-dev/cli/commands.ts @@ -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"; @@ -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( diff --git a/packages/remix-dev/cli/run.ts b/packages/remix-dev/cli/run.ts index 4f80395c731..15ee71c540c 100644 --- a/packages/remix-dev/cli/run.ts +++ b/packages/remix-dev/cli/run.ts @@ -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); } /** @@ -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, diff --git a/packages/remix-dev/compiler/compilerServer.ts b/packages/remix-dev/compiler/compilerServer.ts index 9026f82495b..ec9d3cc5ee1 100644 --- a/packages/remix-dev/compiler/compilerServer.ts +++ b/packages/remix-dev/compiler/compilerServer.ts @@ -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); diff --git a/packages/remix-dev/compiler/options.ts b/packages/remix-dev/compiler/options.ts index 59f6853647a..2977498058a 100644 --- a/packages/remix-dev/compiler/options.ts +++ b/packages/remix-dev/compiler/options.ts @@ -22,6 +22,7 @@ type Target = export type CompileOptions = { mode: Mode; + liveReloadPort?: number; target: Target; sourcemap: boolean; onWarning?: (message: string, key: string) => void; diff --git a/packages/remix-dev/compiler/plugins/serverEntryModulePlugin.ts b/packages/remix-dev/compiler/plugins/serverEntryModulePlugin.ts index 64bc1107375..0750ee2d1cc 100644 --- a/packages/remix-dev/compiler/plugins/serverEntryModulePlugin.ts +++ b/packages/remix-dev/compiler/plugins/serverEntryModulePlugin.ts @@ -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 { @@ -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) => { diff --git a/packages/remix-dev/compiler/remixCompiler.ts b/packages/remix-dev/compiler/remixCompiler.ts index 0d78fa34911..4b7250cc87e 100644 --- a/packages/remix-dev/compiler/remixCompiler.ts +++ b/packages/remix-dev/compiler/remixCompiler.ts @@ -28,12 +28,13 @@ export const compile = async ( options: { onCompileFailure?: OnCompileFailure; } = {} -): Promise => { +): Promise => { try { let assetsManifestChannel = createChannel(); 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); } diff --git a/packages/remix-dev/compiler/watch.ts b/packages/remix-dev/compiler/watch.ts index 10a70ac8fde..5643309f356 100644 --- a/packages/remix-dev/compiler/watch.ts +++ b/packages/remix-dev/compiler/watch.ts @@ -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"; @@ -21,7 +22,7 @@ function isEntryPoint(config: RemixConfig, file: string): boolean { export type WatchOptions = Partial & { onRebuildStart?(): void; - onRebuildFinish?(durationMs: number): void; + onRebuildFinish?(durationMs: number, assetsManifest?: AssetsManifest): void; onFileCreated?(file: string): void; onFileChanged?(file: string): void; onFileDeleted?(file: string): void; @@ -32,6 +33,7 @@ export async function watch( config: RemixConfig, { mode = "development", + liveReloadPort, target = "node14", sourcemap = true, onWarning = warnOnce, @@ -46,6 +48,7 @@ export async function watch( ): Promise<() => Promise> { let options: CompileOptions = { mode, + liveReloadPort, target, sourcemap, onCompileFailure, @@ -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]; diff --git a/packages/remix-dev/config.ts b/packages/remix-dev/config.ts index 143c9f6fee1..e21d1656d91 100644 --- a/packages/remix-dev/config.ts +++ b/packages/remix-dev/config.ts @@ -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; @@ -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, diff --git a/packages/remix-dev/devServer2.ts b/packages/remix-dev/devServer2.ts new file mode 100644 index 00000000000..eefb475aee9 --- /dev/null +++ b/packages/remix-dev/devServer2.ts @@ -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 => { + 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(); + }; +}; diff --git a/packages/remix-dev/liveReload.ts b/packages/remix-dev/liveReload.ts new file mode 100644 index 00000000000..81c16a34758 --- /dev/null +++ b/packages/remix-dev/liveReload.ts @@ -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 }; +}; diff --git a/packages/remix-dev/server-build.ts b/packages/remix-dev/server-build.ts index f4ad1fbe0e8..d9144b95fe1 100644 --- a/packages/remix-dev/server-build.ts +++ b/packages/remix-dev/server-build.ts @@ -11,6 +11,7 @@ export const assets: ServerBuild["assets"] = undefined!; export const entry: ServerBuild["entry"] = undefined!; export const routes: ServerBuild["routes"] = undefined!; export const future: ServerBuild["future"] = undefined!; +export const dev: ServerBuild["dev"] = undefined!; export const publicPath: ServerBuild["publicPath"] = undefined!; // prettier-ignore export const assetsBuildDirectory: ServerBuild["assetsBuildDirectory"] = undefined!; diff --git a/packages/remix-react/__tests__/components-test.tsx b/packages/remix-react/__tests__/components-test.tsx index ecdafb0842a..d3d76b99a5d 100644 --- a/packages/remix-react/__tests__/components-test.tsx +++ b/packages/remix-react/__tests__/components-test.tsx @@ -47,14 +47,14 @@ describe("", () => { LiveReload = require("../components").LiveReload; let { container } = render(); expect(container.querySelector("script")).toHaveTextContent( - `8002 + "/socket"` + "let port = (window.__remixContext.dev && window.__remixContext.dev.liveReloadPort) || 8002;" ); }); it("can set the port explicitly", () => { let { container } = render(); expect(container.querySelector("script")).toHaveTextContent( - `4321 + "/socket"` + "let port = (window.__remixContext.dev && window.__remixContext.dev.liveReloadPort) || 4321;" ); }); @@ -62,7 +62,7 @@ describe("", () => { process.env.REMIX_DEV_SERVER_WS_PORT = "1234"; let { container } = render(); expect(container.querySelector("script")).toHaveTextContent( - `1234 + "/socket"` + "let port = (window.__remixContext.dev && window.__remixContext.dev.liveReloadPort) || 1234;" ); }); }); diff --git a/packages/remix-react/components.tsx b/packages/remix-react/components.tsx index 8edb487f9cd..31df2f30b9b 100644 --- a/packages/remix-react/components.tsx +++ b/packages/remix-react/components.tsx @@ -1554,9 +1554,10 @@ export const LiveReload = function remixLiveReloadConnect(config) { let protocol = location.protocol === "https:" ? "wss:" : "ws:"; let host = location.hostname; - let socketPath = protocol + "//" + host + ":" + ${String( + let port = (window.__remixContext.dev && window.__remixContext.dev.liveReloadPort) || ${String( port - )} + "/socket"; + )}; + let socketPath = protocol + "//" + host + ":" + port + "/socket"; let ws = new WebSocket(socketPath); ws.onmessage = (message) => { let event = JSON.parse(message.data); diff --git a/packages/remix-react/entry.ts b/packages/remix-react/entry.ts index 595cbc6d987..1847026b6d4 100644 --- a/packages/remix-react/entry.ts +++ b/packages/remix-react/entry.ts @@ -10,6 +10,7 @@ export interface RemixContextObject { serverHandoffString?: string; future: FutureConfig; abortDelay?: number; + dev?: { liveReloadPort: number }; } // Additional React-Router information needed at runtime, but not hydrated diff --git a/packages/remix-server-runtime/__tests__/server-test.ts b/packages/remix-server-runtime/__tests__/server-test.ts index e4d1951728d..11ac0103ce5 100644 --- a/packages/remix-server-runtime/__tests__/server-test.ts +++ b/packages/remix-server-runtime/__tests__/server-test.ts @@ -12,7 +12,7 @@ function spyConsole() { let spy: any = {}; beforeAll(() => { - spy.console = jest.spyOn(console, "error").mockImplementation(() => {}); + spy.console = jest.spyOn(console, "error").mockImplementation(() => { }); }); afterAll(() => { @@ -123,6 +123,8 @@ describe("shared server runtime", () => { loader: resourceLoader, path: "resource", }, + future: { + }, }); let handler = createRequestHandler(build, ServerMode.Test); @@ -1616,7 +1618,7 @@ describe("shared server runtime", () => { }); let calledBefore = false; let ogHandleDocumentRequest = build.entry.module.default; - build.entry.module.default = jest.fn(function () { + build.entry.module.default = jest.fn(function() { if (!calledBefore) { throw new Error("thrown"); } @@ -1660,7 +1662,7 @@ describe("shared server runtime", () => { }, }); let lastThrownError; - build.entry.module.default = jest.fn(function () { + build.entry.module.default = jest.fn(function() { lastThrownError = new Error("rofl"); throw lastThrownError; }) as any; @@ -1702,7 +1704,7 @@ describe("shared server runtime", () => { let errorMessage = "thrown from handleDocumentRequest and expected to be logged in console only once"; let lastThrownError; - build.entry.module.default = jest.fn(function () { + build.entry.module.default = jest.fn(function() { lastThrownError = new Error(errorMessage); throw lastThrownError; }) as any; diff --git a/packages/remix-server-runtime/__tests__/utils.ts b/packages/remix-server-runtime/__tests__/utils.ts index 21223d7d5d0..806b189fd39 100644 --- a/packages/remix-server-runtime/__tests__/utils.ts +++ b/packages/remix-server-runtime/__tests__/utils.ts @@ -80,7 +80,9 @@ export function mockServerBuild( }, {} ), - future: {}, + future: { + unstable_dev: {}, + }, }; } diff --git a/packages/remix-server-runtime/build.ts b/packages/remix-server-runtime/build.ts index 9c01f8258cb..0c87b7c07ff 100644 --- a/packages/remix-server-runtime/build.ts +++ b/packages/remix-server-runtime/build.ts @@ -14,6 +14,7 @@ export interface ServerBuild { publicPath: string; assetsBuildDirectory: string; future: FutureConfig; + dev?: { liveReloadPort: number }; } export interface HandleDocumentRequestFunction { diff --git a/packages/remix-server-runtime/entry.ts b/packages/remix-server-runtime/entry.ts index 7dddbb38061..77a28741349 100644 --- a/packages/remix-server-runtime/entry.ts +++ b/packages/remix-server-runtime/entry.ts @@ -14,6 +14,7 @@ export interface EntryContext { export interface FutureConfig { unstable_cssModules: true; unstable_cssSideEffectImports: boolean; + unstable_dev: false | { remixRequestHandlerPath?: string }; unstable_vanillaExtract: boolean; v2_errorBoundary: boolean; v2_meta: boolean; diff --git a/packages/remix-server-runtime/server.ts b/packages/remix-server-runtime/server.ts index f57ffd7355a..de337434b76 100644 --- a/packages/remix-server-runtime/server.ts +++ b/packages/remix-server-runtime/server.ts @@ -50,6 +50,25 @@ 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.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; @@ -267,6 +286,7 @@ async function handleDocumentRequestRR( errors: serializeErrors(context.errors), }, future: build.future, + dev: build.dev, }), future: build.future, }; diff --git a/packages/remix-server-runtime/serverHandoff.ts b/packages/remix-server-runtime/serverHandoff.ts index 3d1c1817c5c..a6165841c08 100644 --- a/packages/remix-server-runtime/serverHandoff.ts +++ b/packages/remix-server-runtime/serverHandoff.ts @@ -20,6 +20,7 @@ export function createServerHandoffString(serverHandoff: { // we'd end up including duplicate info state: ValidateShape; future: FutureConfig; + dev?: { liveReloadPort: number }; }): string { // Uses faster alternative of jsesc to escape data returned from the loaders. // This string is inserted directly into the HTML in the `` element.