diff --git a/.gitignore b/.gitignore index 6a4c3f9b..b8eb17c9 100644 --- a/.gitignore +++ b/.gitignore @@ -9,8 +9,10 @@ yarn.lock node_modules /build +/server-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/index.js b/index.js new file mode 100644 index 00000000..c22ef062 --- /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.ts"); +} diff --git a/package.json b/package.json index 4e3ccda0..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 build", - "build:server": "esbuild --platform=node --format=cjs ./server.ts --outdir=build --bundle --external:fsevents", - "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\"", + "build:remix": "remix vite:build", + "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.js", - "start:mocks": "cross-env NODE_ENV=production node --require ./mocks --require dotenv/config ./build/server.js", + "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", @@ -32,10 +31,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", @@ -59,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", @@ -85,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", @@ -96,12 +97,12 @@ "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" }, "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/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..07b033e1 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"; @@ -17,17 +12,16 @@ installGlobals(); run(); 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 MODE = process.env.NODE_ENV; + + const viteDevServer = + MODE === "development" + ? await import("vite").then((vite) => + vite.createServer({ + server: { middlewareMode: true }, + }), + ) + : undefined; const app = express(); const metricsApp = express(); @@ -86,27 +80,43 @@ 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: async () => { + const { error, build } = await getBuild(); + // gracefully "catch" the error + if (error) { + throw error; + } + return build; + }, + }), + ); 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 +125,19 @@ 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); + async function getBuild() { + 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 }; } - 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); - } - }; } } diff --git a/server/build-server.ts b/server/build-server.ts new file mode 100644 index 00000000..19785029 --- /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("../server.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/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..ab4e4034 --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,25 @@ +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({ + build: { + cssMinify: MODE === "production", + }, + plugins: [ + remix({ + serverModuleFormat: "esm", + ignoredRouteFiles: ["**/.*", "**/*.test.{ts,tsx}"], + future: { + unstable_optimizeDeps: true, + v3_fetcherPersist: true, + v3_lazyRouteDiscovery: true, + v3_relativeSplatPath: true, + v3_throwAbortReason: true, + }, + }), + tsconfigPaths(), + ], +});