Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: enable v2_dev flag #196

Merged
merged 3 commits into from
Sep 1, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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);
}
};
}
}