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..65a82e4a 100644 --- a/package.json +++ b/package.json @@ -5,11 +5,10 @@ "scripts": { "build": "run-s build:*", "build:remix": "remix build", - "build:server": "esbuild --platform=node --format=cjs ./server.ts --outdir=build --bundle", + "build:server": "esbuild --platform=node --format=cjs ./server.ts --outdir=build --bundle --external:fsevents", "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:server": "cross-env NODE_ENV=development npm run build:server -- --watch", + "dev:remix": "remix dev --manual -c \"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 +39,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..2d67ad39 100644 --- a/server.ts +++ b/server.ts @@ -1,126 +1,160 @@ -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"; sourceMapSupport.install(); installGlobals(); +run(); + +async function run() { + const BUILD_PATH = path.resolve("build/index.js"); + const initialBuild = await reimportServer(); + + const app = express(); + const metricsApp = express(); + app.use( + prom({ + metricsPath: "/metrics", + collectDefaultMetrics: true, + metricsApp, + }), + ); + + app.use((req, res, next) => { + // helpful headers: + res.set("x-fly-region", process.env.FLY_REGION ?? "unknown"); + res.set("Strict-Transport-Security", `max-age=${60 * 60 * 24 * 365 * 100}`); + + // /clean-urls/ -> /clean-urls + if (req.path.endsWith("/") && req.path.length > 1) { + const query = req.url.slice(req.path.length); + const safepath = req.path.slice(0, -1).replace(/\/+/g, "/"); + res.redirect(301, safepath + query); + return; + } + next(); + }); + + // if we're not in the primary region, then we need to make sure all + // non-GET/HEAD/OPTIONS requests hit the primary region rather than read-only + // Postgres DBs. + // learn more: https://fly.io/docs/getting-started/multi-region-databases/#replay-the-request + app.all("*", function getReplayResponse(req, res, next) { + const { method, path: pathname } = req; + const { PRIMARY_REGION, FLY_REGION } = process.env; + + const isMethodReplayable = !["GET", "OPTIONS", "HEAD"].includes(method); + const isReadOnlyRegion = + FLY_REGION && PRIMARY_REGION && FLY_REGION !== PRIMARY_REGION; + + const shouldReplay = isMethodReplayable && isReadOnlyRegion; + + if (!shouldReplay) return next(); + + const logInfo = { + pathname, + method, + PRIMARY_REGION, + FLY_REGION, + }; + console.info(`Replaying:`, logInfo); + res.set("fly-replay", `region=${PRIMARY_REGION}`); + return res.sendStatus(409); + }); + + app.use(compression()); + + // 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" })); + + app.use(morgan("tiny")); + + app.all( + "*", + process.env.NODE_ENV === "development" + ? createDevRequestHandler(initialBuild) + : createRequestHandler({ + build: initialBuild, + mode: process.env.NODE_ENV, + }), + ); + + 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; + + metricsApp.listen(metricsPort, () => { + console.log(`✅ metrics ready: http://localhost:${metricsPort}/metrics`); + }); -const app = express(); -const metricsApp = express(); -app.use( - prom({ - metricsPath: "/metrics", - collectDefaultMetrics: true, - metricsApp, - }), -); - -app.use((req, res, next) => { - // helpful headers: - res.set("x-fly-region", process.env.FLY_REGION ?? "unknown"); - res.set("Strict-Transport-Security", `max-age=${60 * 60 * 24 * 365 * 100}`); - - // /clean-urls/ -> /clean-urls - if (req.path.endsWith("/") && req.path.length > 1) { - const query = req.url.slice(req.path.length); - const safepath = req.path.slice(0, -1).replace(/\/+/g, "/"); - res.redirect(301, safepath + query); - return; + 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); } - next(); -}); - -// if we're not in the primary region, then we need to make sure all -// non-GET/HEAD/OPTIONS requests hit the primary region rather than read-only -// Postgres DBs. -// learn more: https://fly.io/docs/getting-started/multi-region-databases/#replay-the-request -app.all("*", function getReplayResponse(req, res, next) { - const { method, path: pathname } = req; - const { PRIMARY_REGION, FLY_REGION } = process.env; - - const isMethodReplayable = !["GET", "OPTIONS", "HEAD"].includes(method); - const isReadOnlyRegion = - FLY_REGION && PRIMARY_REGION && FLY_REGION !== PRIMARY_REGION; - - const shouldReplay = isMethodReplayable && isReadOnlyRegion; - - if (!shouldReplay) return next(); - - const logInfo = { - pathname, - method, - PRIMARY_REGION, - FLY_REGION, - }; - console.info(`Replaying:`, logInfo); - res.set("fly-replay", `region=${PRIMARY_REGION}`); - return res.sendStatus(409); -}); - -app.use(compression()); - -// 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" })); - -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); - }, -); - -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}`); -}); - -const metricsPort = process.env.METRICS_PORT || 3001; - -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]; + + 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); + } + }; } }