From 630245b8e2b5ed9b180b77051ffcdf63877569db Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Wed, 18 Jan 2023 15:24:34 -0500 Subject: [PATCH] feat(dev): new dev server via future flag --- packages/remix-dev/cli/commands.ts | 14 +++- packages/remix-dev/cli/run.ts | 6 +- packages/remix-dev/config.ts | 11 ++- packages/remix-dev/devServer2.ts | 106 ++++++++++++++++++++++++ packages/remix-dev/liveReload.ts | 27 ++++++ packages/remix-server-runtime/entry.ts | 1 + packages/remix-server-runtime/server.ts | 19 +++++ 7 files changed, 179 insertions(+), 5 deletions(-) create mode 100644 packages/remix-dev/devServer2.ts create mode 100644 packages/remix-dev/liveReload.ts 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/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-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 4abe1c3285d..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;