-
Notifications
You must be signed in to change notification settings - Fork 234
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Co-authored-by: Pedro Cattori <[email protected]>
- Loading branch information
1 parent
e229f45
commit 19f6083
Showing
6 changed files
with
175 additions
and
134 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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]; | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
} | ||
}; | ||
} | ||
} |