From 181da977460dfcdc5924ae82afbc5b04365542f8 Mon Sep 17 00:00:00 2001 From: Jonathan Milgrom Date: Fri, 10 Jan 2025 14:18:58 -0800 Subject: [PATCH 1/4] migrate from remix compiler to vite plugin --- .gitignore | 1 + app/root.tsx | 6 +-- package.json | 19 ++++---- remix.config.js | 6 --- remix.env.d.ts | 2 - server.ts | 115 +++++++++++++++++------------------------------- tsconfig.json | 6 +-- vite.config.ts | 24 ++++++++++ 8 files changed, 78 insertions(+), 101 deletions(-) delete mode 100644 remix.config.js delete mode 100644 remix.env.d.ts create mode 100644 vite.config.ts diff --git a/.gitignore b/.gitignore index 6a4c3f9b..6be88c6a 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,7 @@ node_modules /build /public/build .env +.cache /cypress/screenshots /cypress/videos diff --git a/app/root.tsx b/app/root.tsx index 426fac35..65cbe617 100644 --- a/app/root.tsx +++ b/app/root.tsx @@ -1,9 +1,7 @@ -import { cssBundleHref } from "@remix-run/css-bundle"; import type { LinksFunction, LoaderFunctionArgs } from "@remix-run/node"; import { json } from "@remix-run/node"; import { Links, - LiveReload, Meta, Outlet, Scripts, @@ -11,11 +9,10 @@ import { } from "@remix-run/react"; import { getUser } from "~/session.server"; -import stylesheet from "~/tailwind.css"; +import stylesheet from "~/tailwind.css?url"; export const links: LinksFunction = () => [ { rel: "stylesheet", href: stylesheet }, - ...(cssBundleHref ? [{ rel: "stylesheet", href: cssBundleHref }] : []), ]; export const loader = async ({ request }: LoaderFunctionArgs) => { @@ -35,7 +32,6 @@ export default function App() { - ); diff --git a/package.json b/package.json index 4e3ccda0..c2fda7cc 100644 --- a/package.json +++ b/package.json @@ -4,18 +4,18 @@ "sideEffects": false, "scripts": { "build": "npm-run-all --sequential build:*", - "build:remix": "remix build", - "build:server": "esbuild --platform=node --format=cjs ./server.ts --outdir=build --bundle --external:fsevents", + "build:remix": "remix vite:build", + "build:server": "esbuild --platform=node --format=cjs ./server.ts --outdir=build --bundle --external:fsevents --external:esbuild --external:lightningcss", "dev": "npm-run-all --parallel dev:*", "dev:server": "cross-env NODE_ENV=development npm run build:server -- --watch", - "dev:remix": "remix dev --manual -c \"node --require ./mocks --watch-path ./build/server.js --watch ./build/server.js\"", + "dev:remix": "remix dev --manual -c \"node --require ./mocks --watch-path ./build/server --watch ./build/server\"", "docker": "docker compose up -d", "format": "prettier --write .", "format:repo": "npm run format && npm run lint -- --fix", "lint": "eslint --cache --cache-location ./node_modules/.cache/eslint .", "setup": "prisma generate && prisma migrate deploy && prisma db seed", - "start": "cross-env NODE_ENV=production node ./build/server.js", - "start:mocks": "cross-env NODE_ENV=production node --require ./mocks --require dotenv/config ./build/server.js", + "start": "cross-env NODE_ENV=production node ./build/server", + "start:mocks": "cross-env NODE_ENV=production node --require ./mocks --require dotenv/config ./build/server", "test": "vitest", "test:e2e:dev": "start-server-and-test dev http://localhost:3000 \"npx cypress open\"", "pretest:e2e:run": "npm run build", @@ -32,10 +32,9 @@ "dependencies": { "@isaacs/express-prometheus-middleware": "^1.2.1", "@prisma/client": "^5.20.0", - "@remix-run/css-bundle": "*", - "@remix-run/express": "*", - "@remix-run/node": "*", - "@remix-run/react": "*", + "@remix-run/express": "^2.15.2", + "@remix-run/node": "^2.15.2", + "@remix-run/react": "^2.15.2", "bcryptjs": "^2.4.3", "chokidar": "^3.6.0", "compression": "^1.7.4", @@ -96,7 +95,7 @@ "tailwindcss": "^3.4.13", "tsx": "^4.19.1", "typescript": "^5.6.2", - "vite": "^5.4.8", + "vite": "^5.4.11", "vite-tsconfig-paths": "^4.3.2", "vitest": "^2.1.2" }, diff --git a/remix.config.js b/remix.config.js deleted file mode 100644 index 29582b29..00000000 --- a/remix.config.js +++ /dev/null @@ -1,6 +0,0 @@ -/** @type {import('@remix-run/dev').AppConfig} */ -module.exports = { - cacheDirectory: "./node_modules/.cache/remix", - ignoredRouteFiles: ["**/.*", "**/*.test.{ts,tsx}"], - serverModuleFormat: "cjs", -}; diff --git a/remix.env.d.ts b/remix.env.d.ts deleted file mode 100644 index dcf8c45e..00000000 --- a/remix.env.d.ts +++ /dev/null @@ -1,2 +0,0 @@ -/// -/// diff --git a/server.ts b/server.ts index f9b19997..746fc1d0 100644 --- a/server.ts +++ b/server.ts @@ -1,13 +1,8 @@ -import fs from "node:fs"; -import path from "node:path"; -import url from "node:url"; - import prom from "@isaacs/express-prometheus-middleware"; import { createRequestHandler } from "@remix-run/express"; import type { ServerBuild } from "@remix-run/node"; -import { broadcastDevReady, installGlobals } from "@remix-run/node"; +import { installGlobals } from "@remix-run/node"; import compression from "compression"; -import type { RequestHandler } from "express"; import express from "express"; import morgan from "morgan"; import sourceMapSupport from "source-map-support"; @@ -16,18 +11,17 @@ sourceMapSupport.install(); installGlobals(); run(); +const MODE = process.env.NODE_ENV; + async function run() { - const BUILD_PATH = path.resolve("build/index.js"); - const VERSION_PATH = path.resolve("build/version.txt"); - - const initialBuild = await reimportServer(); - const remixHandler = - process.env.NODE_ENV === "development" - ? await createDevRequestHandler(initialBuild) - : createRequestHandler({ - build: initialBuild, - mode: initialBuild.mode, - }); + const viteDevServer = + MODE === "development" + ? await import("vite").then((vite) => + vite.createServer({ + server: { middlewareMode: true }, + }), + ) + : undefined; const app = express(); const metricsApp = express(); @@ -86,27 +80,36 @@ async function run() { // http://expressjs.com/en/advanced/best-practice-security.html#at-a-minimum-disable-x-powered-by-header app.disable("x-powered-by"); - // Remix fingerprints its assets so we can cache forever. - app.use( - "/build", - express.static("public/build", { immutable: true, maxAge: "1y" }), - ); - - // Everything else (like favicon.ico) is cached for an hour. You may want to be - // more aggressive with this caching. - app.use(express.static("public", { maxAge: "1h" })); + if (viteDevServer) { + app.use(viteDevServer.middlewares); + } else { + // Remix fingerprints its assets so we can cache forever. + app.use( + "/assets", + express.static("build/client/assets", { immutable: true, maxAge: "1y" }), + ); + // Everything else (like favicon.ico) is cached for an hour. You may want to be + // more aggressive with this caching. + app.use(express.static("build/client", { maxAge: "1h" })); + } app.use(morgan("tiny")); - app.all("*", remixHandler); + app.all( + "*", + createRequestHandler({ + getLoadContext: (_, res) => ({ + cspNonce: res.locals.cspNonce, + serverBuild: getBuild(), + }), + mode: MODE, + build: getBuild, + }), + ); const port = process.env.PORT || 3000; app.listen(port, () => { console.log(`✅ app ready: http://localhost:${port}`); - - if (process.env.NODE_ENV === "development") { - broadcastDevReady(initialBuild); - } }); const metricsPort = process.env.METRICS_PORT || 3010; @@ -115,49 +118,11 @@ async function run() { console.log(`✅ metrics ready: http://localhost:${metricsPort}/metrics`); }); - async function reimportServer(): Promise { - // cjs: manually remove the server build from the require cache - Object.keys(require.cache).forEach((key) => { - if (key.startsWith(BUILD_PATH)) { - delete require.cache[key]; - } - }); - - const stat = fs.statSync(BUILD_PATH); - - // convert build path to URL for Windows compatibility with dynamic `import` - const BUILD_URL = url.pathToFileURL(BUILD_PATH).href; - - // use a timestamp query parameter to bust the import cache - return import(BUILD_URL + "?t=" + stat.mtimeMs); - } - - async function createDevRequestHandler( - initialBuild: ServerBuild, - ): Promise { - let build = initialBuild; - async function handleServerUpdate() { - // 1. re-import the server build - build = await reimportServer(); - // 2. tell Remix that this app server is now up-to-date and ready - broadcastDevReady(build); - } - const chokidar = await import("chokidar"); - chokidar - .watch(VERSION_PATH, { ignoreInitial: true }) - .on("add", handleServerUpdate) - .on("change", handleServerUpdate); - - // wrap request handler to make sure its recreated with the latest build for every request - return async (req, res, next) => { - try { - return createRequestHandler({ - build, - mode: "development", - })(req, res, next); - } catch (error) { - next(error); - } - }; + async function getBuild() { + const build = viteDevServer + ? viteDevServer.ssrLoadModule("virtual:remix/server-build") + : await import("build/server/index.js"); + // not sure how to make this happy 🤷‍♂️ + return build as unknown as ServerBuild; } } diff --git a/tsconfig.json b/tsconfig.json index c0a761dd..4a41b6b2 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -3,12 +3,12 @@ "include": ["remix.env.d.ts", "**/*.ts", "**/*.tsx"], "compilerOptions": { "lib": ["DOM", "DOM.Iterable", "ES2020"], - "types": ["vitest/globals"], + "types": ["@remix-run/node", "vite/client"], "isolatedModules": true, "esModuleInterop": true, "jsx": "react-jsx", - "module": "CommonJS", - "moduleResolution": "node", + "module": "ESNext", + "moduleResolution": "Bundler", "resolveJsonModule": true, "target": "ES2020", "strict": true, diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 00000000..a46f769e --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,24 @@ +import { vitePlugin as remix } from "@remix-run/dev"; +import { defineConfig } from "vite"; +import tsconfigPaths from "vite-tsconfig-paths"; + +const MODE = process.env.NODE_ENV; + +export default defineConfig({ + server: { + port: 3000, + }, + build: { + cssMinify: MODE === "production", + }, + plugins: [ + remix({ + serverModuleFormat: "esm", + ignoredRouteFiles: ["**/.*", "**/*.test.{ts,tsx}"], + future: { + unstable_optimizeDeps: true, + }, + }), + tsconfigPaths(), + ], +}); From e04206c693d5e04b92e97032799347b93bd5afe9 Mon Sep 17 00:00:00 2001 From: Jonathan Milgrom Date: Tue, 14 Jan 2025 15:34:45 -0800 Subject: [PATCH 2/4] follow epic setup --- .gitignore | 1 + index.js | 6 ++++++ package.json | 16 +++++++++------- postcss.config.js | 2 +- prettier.config.js | 2 +- server/build-server.ts | 29 +++++++++++++++++++++++++++++ server/dev-server.js | 21 +++++++++++++++++++++ server.ts => server/index.ts | 35 +++++++++++++++++++++++++---------- vite.config.ts | 7 ++++--- 9 files changed, 97 insertions(+), 22 deletions(-) create mode 100644 index.js create mode 100644 server/build-server.ts create mode 100644 server/dev-server.js rename server.ts => server/index.ts (77%) diff --git a/.gitignore b/.gitignore index 6be88c6a..b8eb17c9 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ yarn.lock node_modules /build +/server-build /public/build .env .cache diff --git a/index.js b/index.js new file mode 100644 index 00000000..bfd77bdd --- /dev/null +++ b/index.js @@ -0,0 +1,6 @@ +// eslint-disable-next-line no-undef +if (process.env.NODE_ENV === "production") { + await import("./server-build/index.js"); +} else { + await import("./server/index.ts"); +} diff --git a/package.json b/package.json index c2fda7cc..343f984e 100644 --- a/package.json +++ b/package.json @@ -2,20 +2,19 @@ "name": "blues-stack-template", "private": true, "sideEffects": false, + "type": "module", "scripts": { "build": "npm-run-all --sequential build:*", "build:remix": "remix vite:build", - "build:server": "esbuild --platform=node --format=cjs ./server.ts --outdir=build --bundle --external:fsevents --external:esbuild --external:lightningcss", - "dev": "npm-run-all --parallel dev:*", - "dev:server": "cross-env NODE_ENV=development npm run build:server -- --watch", - "dev:remix": "remix dev --manual -c \"node --require ./mocks --watch-path ./build/server --watch ./build/server\"", + "build:server": "tsx ./server/build-server.ts", + "dev": "NODE_ENV=development node ./server/dev-server.js", "docker": "docker compose up -d", "format": "prettier --write .", "format:repo": "npm run format && npm run lint -- --fix", "lint": "eslint --cache --cache-location ./node_modules/.cache/eslint .", "setup": "prisma generate && prisma migrate deploy && prisma db seed", - "start": "cross-env NODE_ENV=production node ./build/server", - "start:mocks": "cross-env NODE_ENV=production node --require ./mocks --require dotenv/config ./build/server", + "start": "cross-env NODE_ENV=production node --require dotenv/config .", + "start:mocks": "cross-env NODE_ENV=production node --require ./mocks --require dotenv/config .", "test": "vitest", "test:e2e:dev": "start-server-and-test dev http://localhost:3000 \"npx cypress open\"", "pretest:e2e:run": "npm run build", @@ -58,6 +57,7 @@ "@types/cookie": "^0.6.0", "@types/eslint": "^8.56.12", "@types/express": "^4.17.21", + "@types/fs-extra": "^11.0.4", "@types/morgan": "^1.9.9", "@types/node": "^20.16.10", "@types/react": "^18.3.11", @@ -84,6 +84,8 @@ "eslint-plugin-react": "^7.37.1", "eslint-plugin-react-hooks": "^4.6.2", "eslint-plugin-testing-library": "^6.3.0", + "execa": "^9.5.2", + "fs-extra": "^11.2.0", "happy-dom": "^15.7.4", "msw": "^2.4.9", "npm-run-all2": "^6.2.3", @@ -100,7 +102,7 @@ "vitest": "^2.1.2" }, "engines": { - "node": ">=18.0.0" + "node": "18.0.0" }, "prisma": { "seed": "tsx prisma/seed.ts" diff --git a/postcss.config.js b/postcss.config.js index 12a703d9..2aa7205d 100644 --- a/postcss.config.js +++ b/postcss.config.js @@ -1,4 +1,4 @@ -module.exports = { +export default { plugins: { tailwindcss: {}, autoprefixer: {}, diff --git a/prettier.config.js b/prettier.config.js index 776594f0..fb6fba22 100644 --- a/prettier.config.js +++ b/prettier.config.js @@ -1,4 +1,4 @@ /** @type {import("prettier").Config} */ -module.exports = { +export default { plugins: ["prettier-plugin-tailwindcss"], }; diff --git a/server/build-server.ts b/server/build-server.ts new file mode 100644 index 00000000..334f4ef1 --- /dev/null +++ b/server/build-server.ts @@ -0,0 +1,29 @@ +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import esbuild from "esbuild"; +import fsExtra from "fs-extra"; + +const pkg = fsExtra.readJsonSync(path.join(process.cwd(), "package.json")); + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const here = (...s: string[]) => path.join(__dirname, ...s); + +console.log(); +console.log("building..."); + +esbuild + .build({ + // note that we are not including dev-server.js since it's only used for development + entryPoints: [here("index.ts")], + outdir: here("../server-build"), + target: [`node${pkg.engines.node}`], + platform: "node", + sourcemap: true, + format: "esm", + logLevel: "info", + }) + .catch((error: unknown) => { + console.error(error); + process.exit(1); + }); diff --git a/server/dev-server.js b/server/dev-server.js new file mode 100644 index 00000000..6c43e22c --- /dev/null +++ b/server/dev-server.js @@ -0,0 +1,21 @@ +import { execa } from "execa"; + +// eslint-disable-next-line no-undef +if (process.env.NODE_ENV === "production") { + await import("../server-build/index.js"); +} else { + const command = + 'tsx watch --clear-screen=false --ignore ".cache/**" --ignore "app/**" --ignore "vite.config.ts.timestamp-*" --ignore "build/**" --ignore "node_modules/**" --inspect ./index.js'; + execa(command, { + stdio: ["ignore", "inherit", "inherit"], + shell: true, + env: { + FORCE_COLOR: true, + MOCKS: true, + // eslint-disable-next-line no-undef + ...process.env, + }, + // https://github.com/sindresorhus/execa/issues/433 + windowsHide: false, + }); +} diff --git a/server.ts b/server/index.ts similarity index 77% rename from server.ts rename to server/index.ts index 746fc1d0..bd2e782c 100644 --- a/server.ts +++ b/server/index.ts @@ -11,9 +11,9 @@ sourceMapSupport.install(); installGlobals(); run(); -const MODE = process.env.NODE_ENV; - async function run() { + const MODE = process.env.NODE_ENV; + const viteDevServer = MODE === "development" ? await import("vite").then((vite) => @@ -103,26 +103,41 @@ async function run() { serverBuild: getBuild(), }), mode: MODE, - build: getBuild, + build: async () => { + const { error, build } = await getBuild(); + // gracefully "catch" the error + if (error) { + throw error; + } + return build; + }, }), ); - const port = process.env.PORT || 3000; + const port = process.env.PORT || 3024; app.listen(port, () => { console.log(`✅ app ready: http://localhost:${port}`); }); - const metricsPort = process.env.METRICS_PORT || 3010; + const metricsPort = process.env.METRICS_PORT || 3014; metricsApp.listen(metricsPort, () => { console.log(`✅ metrics ready: http://localhost:${metricsPort}/metrics`); }); async function getBuild() { - const build = viteDevServer - ? viteDevServer.ssrLoadModule("virtual:remix/server-build") - : await import("build/server/index.js"); - // not sure how to make this happy 🤷‍♂️ - return build as unknown as ServerBuild; + try { + const build = viteDevServer + ? await viteDevServer.ssrLoadModule("virtual:remix/server-build") + : // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore - the file might not exist yet but it will + await import("../build/server/index.js"); + + return { build: build as unknown as ServerBuild, error: null }; + } catch (error) { + // Catch error and return null to make express happy and avoid an unrecoverable crash + console.error("Error creating build:", error); + return { error: error, build: null as unknown as ServerBuild }; + } } } diff --git a/vite.config.ts b/vite.config.ts index a46f769e..ab4e4034 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -5,9 +5,6 @@ import tsconfigPaths from "vite-tsconfig-paths"; const MODE = process.env.NODE_ENV; export default defineConfig({ - server: { - port: 3000, - }, build: { cssMinify: MODE === "production", }, @@ -17,6 +14,10 @@ export default defineConfig({ ignoredRouteFiles: ["**/.*", "**/*.test.{ts,tsx}"], future: { unstable_optimizeDeps: true, + v3_fetcherPersist: true, + v3_lazyRouteDiscovery: true, + v3_relativeSplatPath: true, + v3_throwAbortReason: true, }, }), tsconfigPaths(), From 3da816c92933cb738d2b720759c19f8ac66e34a5 Mon Sep 17 00:00:00 2001 From: Jonathan Milgrom Date: Tue, 14 Jan 2025 15:40:40 -0800 Subject: [PATCH 3/4] better diff --- index.js | 2 +- server/index.ts => server.ts | 2 +- server/build-server.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) rename server/index.ts => server.ts (98%) diff --git a/index.js b/index.js index bfd77bdd..c22ef062 100644 --- a/index.js +++ b/index.js @@ -2,5 +2,5 @@ if (process.env.NODE_ENV === "production") { await import("./server-build/index.js"); } else { - await import("./server/index.ts"); + await import("./server.ts"); } diff --git a/server/index.ts b/server.ts similarity index 98% rename from server/index.ts rename to server.ts index bd2e782c..ae08f45e 100644 --- a/server/index.ts +++ b/server.ts @@ -131,7 +131,7 @@ async function run() { ? await viteDevServer.ssrLoadModule("virtual:remix/server-build") : // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore - the file might not exist yet but it will - await import("../build/server/index.js"); + await import("./build/server/index.js"); return { build: build as unknown as ServerBuild, error: null }; } catch (error) { diff --git a/server/build-server.ts b/server/build-server.ts index 334f4ef1..19785029 100644 --- a/server/build-server.ts +++ b/server/build-server.ts @@ -15,7 +15,7 @@ console.log("building..."); esbuild .build({ // note that we are not including dev-server.js since it's only used for development - entryPoints: [here("index.ts")], + entryPoints: [here("../server.ts")], outdir: here("../server-build"), target: [`node${pkg.engines.node}`], platform: "node", From 49a4d66193e09954705dafa8a6ff9920b9b1d27f Mon Sep 17 00:00:00 2001 From: Jonathan Milgrom Date: Tue, 14 Jan 2025 15:42:09 -0800 Subject: [PATCH 4/4] clean up --- server.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server.ts b/server.ts index ae08f45e..07b033e1 100644 --- a/server.ts +++ b/server.ts @@ -114,12 +114,12 @@ async function run() { }), ); - const port = process.env.PORT || 3024; + const port = process.env.PORT || 3000; app.listen(port, () => { console.log(`✅ app ready: http://localhost:${port}`); }); - const metricsPort = process.env.METRICS_PORT || 3014; + const metricsPort = process.env.METRICS_PORT || 3010; metricsApp.listen(metricsPort, () => { console.log(`✅ metrics ready: http://localhost:${metricsPort}/metrics`);