From d68458b1d4fcada29c8d94060b87389c79d4527b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20De=20Boey?= Date: Fri, 7 Jul 2023 02:53:39 +0200 Subject: [PATCH] feat: enable `v2_dev` flag --- app/db.server.ts | 22 ++--------- app/singleton.server.ts | 12 ++++++ mocks/index.js | 10 ++++- package.json | 7 ++-- remix.config.js | 1 + server.ts | 82 ++++++++++++++++++++++++++--------------- 6 files changed, 82 insertions(+), 52 deletions(-) create mode 100644 app/singleton.server.ts diff --git a/app/db.server.ts b/app/db.server.ts index 929d384c..19fa4395 100644 --- a/app/db.server.ts +++ b/app/db.server.ts @@ -1,26 +1,12 @@ import { PrismaClient } from "@prisma/client"; import invariant from "tiny-invariant"; -let prisma: PrismaClient; +import { singleton } from "./singleton.server"; -declare global { - var __db__: PrismaClient; -} - -// this is needed because in development we don't want to restart -// the server with every change, but we want to make sure we don't -// create a new connection to the DB with every change either. -// in production we'll have a single connection to the DB. -if (process.env.NODE_ENV === "production") { - prisma = getClient(); -} else { - if (!global.__db__) { - global.__db__ = getClient(); - } - prisma = global.__db__; -} +// Hard-code a unique key, so we can look up the client when this module gets re-imported +const prisma = singleton("prisma", getPrismaClient); -function getClient() { +function getPrismaClient() { const { DATABASE_URL } = process.env; invariant(typeof DATABASE_URL === "string", "DATABASE_URL env var not set"); diff --git a/app/singleton.server.ts b/app/singleton.server.ts new file mode 100644 index 00000000..1e3a7dbf --- /dev/null +++ b/app/singleton.server.ts @@ -0,0 +1,12 @@ +// Borrowed & modified from https://github.com/jenseng/abuse-the-platform/blob/main/app/utils/singleton.ts +// Thanks @jenseng! + +export const singleton = ( + name: string, + valueFactory: () => Value +): Value => { + const g = global as any; + g.__singletons ??= {}; + g.__singletons[name] ??= valueFactory(); + return g.__singletons[name]; +}; diff --git a/mocks/index.js b/mocks/index.js index 2e399910..dbf2207b 100644 --- a/mocks/index.js +++ b/mocks/index.js @@ -1,6 +1,14 @@ +const { rest } = require("msw"); const { setupServer } = require("msw/node"); -const server = setupServer(); +// put one-off handlers that don't really need an entire file to themselves here +const miscHandlers = [ + rest.post(`${process.env.REMIX_DEV_HTTP_ORIGIN}/ping`, (req) => + req.passthrough() + ), +]; + +const server = setupServer(...miscHandlers); server.listen({ onUnhandledRequest: "bypass" }); console.info("🔶 Mock server running"); diff --git a/package.json b/package.json index e269adb3..16608e7d 100644 --- a/package.json +++ b/package.json @@ -6,10 +6,8 @@ "build": "run-s build:*", "build:remix": "remix build", "build:server": "esbuild --platform=node --format=cjs ./server.ts --outdir=build --bundle", - "dev": "run-p dev:*", - "dev:build": "cross-env NODE_ENV=development npm run build:server -- --watch", - "dev:remix": "cross-env NODE_ENV=development remix watch", - "dev:server": "cross-env NODE_ENV=development node --inspect --require ./node_modules/dotenv/config --require ./mocks ./build/server.js", + "dev": "remix dev --manual -c \"npm run dev:serve\"", + "dev:serve": "node --require ./mocks ./build/server.js", "docker": "docker-compose up -d", "format": "prettier --write .", "format:repo": "npm run format && npm run lint:repo -- --fix", @@ -40,6 +38,7 @@ "@remix-run/node": "*", "@remix-run/react": "*", "bcryptjs": "^2.4.3", + "chokidar": "^3.5.3", "compression": "^1.7.4", "cross-env": "^7.0.3", "express": "^4.18.2", diff --git a/remix.config.js b/remix.config.js index 303265b3..5b743123 100644 --- a/remix.config.js +++ b/remix.config.js @@ -2,6 +2,7 @@ module.exports = { cacheDirectory: "./node_modules/.cache/remix", future: { + v2_dev: true, v2_errorBoundary: true, v2_headers: true, v2_meta: true, diff --git a/server.ts b/server.ts index 6a02163c..0628fc88 100644 --- a/server.ts +++ b/server.ts @@ -1,9 +1,14 @@ -import path from "path"; +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 { installGlobals } from "@remix-run/node"; +import type { ServerBuild } from "@remix-run/node"; +import { broadcastDevReady, installGlobals } from "@remix-run/node"; +import chokidar from "chokidar"; import compression from "compression"; +import type { RequestHandler } from "express"; import express from "express"; import morgan from "morgan"; import sourceMapSupport from "source-map-support"; @@ -11,6 +16,9 @@ import sourceMapSupport from "source-map-support"; sourceMapSupport.install(); installGlobals(); +const BUILD_PATH = path.join(process.cwd(), "build", "index.js"); +const initialBuild = await reimportServer(); + const app = express(); const metricsApp = express(); app.use( @@ -80,29 +88,23 @@ app.use(express.static("public", { maxAge: "1h" })); app.use(morgan("tiny")); -const MODE = process.env.NODE_ENV; -const BUILD_DIR = path.join(process.cwd(), "build"); - app.all( "*", - MODE === "production" - ? createRequestHandler({ build: require(BUILD_DIR) }) - : (...args) => { - purgeRequireCache(); - const requestHandler = createRequestHandler({ - build: require(BUILD_DIR), - mode: MODE, - }); - return requestHandler(...args); - }, + process.env.NODE_ENV === "development" + ? createDevRequestHandler(initialBuild) + : createRequestHandler({ + build: initialBuild, + mode: initialBuild.mode, + }), ); const port = process.env.PORT || 3000; - app.listen(port, () => { - // require the built app so we're ready when the first request comes in - require(BUILD_DIR); console.log(`✅ app ready: http://localhost:${port}`); + + if (process.env.NODE_ENV === "development") { + broadcastDevReady(initialBuild); + } }); const metricsPort = process.env.METRICS_PORT || 3001; @@ -111,16 +113,38 @@ metricsApp.listen(metricsPort, () => { console.log(`✅ metrics ready: http://localhost:${metricsPort}/metrics`); }); -function purgeRequireCache() { - // purge require cache on requests for "server side HMR" this won't let - // you have in-memory objects between requests in development, - // alternatively you can set up nodemon/pm2-dev to restart the server on - // file changes, we prefer the DX of this though, so we've included it - // for you by default - for (const key in require.cache) { - if (key.startsWith(BUILD_DIR)) { - // eslint-disable-next-line @typescript-eslint/no-dynamic-delete - delete require.cache[key]; - } +async function reimportServer(): Promise { + 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); +} + +function createDevRequestHandler(initialBuild: ServerBuild): RequestHandler { + 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); } + chokidar + .watch(BUILD_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); + } + }; }