Skip to content

Commit

Permalink
feat: enable v2_dev flag (#196)
Browse files Browse the repository at this point in the history
Co-authored-by: Pedro Cattori <[email protected]>
  • Loading branch information
MichaelDeBoey and pcattori authored Sep 1, 2023
1 parent e229f45 commit 19f6083
Show file tree
Hide file tree
Showing 6 changed files with 175 additions and 134 deletions.
22 changes: 4 additions & 18 deletions app/db.server.ts
Original file line number Diff line number Diff line change
@@ -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");

Expand Down
12 changes: 12 additions & 0 deletions app/singleton.server.ts
Original file line number Diff line number Diff line change
@@ -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 = <Value>(
name: string,
valueFactory: () => Value
): Value => {
const g = global as any;
g.__singletons ??= {};
g.__singletons[name] ??= valueFactory();
return g.__singletons[name];
};
10 changes: 9 additions & 1 deletion mocks/index.js
Original file line number Diff line number Diff line change
@@ -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");
Expand Down
8 changes: 4 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions remix.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
module.exports = {
cacheDirectory: "./node_modules/.cache/remix",
future: {
v2_dev: true,
v2_errorBoundary: true,
v2_headers: true,
v2_meta: true,
Expand Down
256 changes: 145 additions & 111 deletions server.ts
Original file line number Diff line number Diff line change
@@ -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<ServerBuild> {
// 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);
}
};
}
}

0 comments on commit 19f6083

Please sign in to comment.