Last active
May 29, 2024 19:05
-
-
Save DennisKraaijeveld/5d47ae3b0425f5b99c81c050722ba2fe to your computer and use it in GitHub Desktop.
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
import crypto from 'crypto' | |
import { createRequestHandler as _createRequestHandler } from '@remix-run/express' | |
import { type ServerBuild, installGlobals } from '@remix-run/node' | |
import * as Sentry from '@sentry/remix' | |
import chalk from 'chalk' | |
import closeWithGrace from 'close-with-grace' | |
import compression from 'compression' | |
import express from 'express' | |
import rateLimit from 'express-rate-limit' | |
import getPort, { portNumbers } from 'get-port' | |
import helmet from 'helmet' | |
import morgan from 'morgan' | |
import { createExpressApp } from 'remix-create-express-app' | |
const MODE = process.env.NODE_ENV ?? 'development' | |
const IS_PROD = MODE === 'production' | |
const IS_DEV = MODE === 'development' | |
const createRequestHandler = IS_PROD | |
? Sentry.wrapExpressCreateRequestHandler(_createRequestHandler) | |
: _createRequestHandler | |
installGlobals() | |
export const app = createExpressApp({ | |
getLoadContext: ( | |
_req: express.Request, | |
res: express.Response, | |
{ build }, | |
) => ({ | |
cspNonce: res.locals.cspNonce, | |
serverBuild: build, | |
}), | |
// TODO: Needs attention. Not confident that this is correct. | |
customRequestHandler: defaultCreateRequestHandler => { | |
return ({ getLoadContext, mode, build }) => | |
createRequestHandler({ | |
getLoadContext: getLoadContext, | |
mode: MODE, | |
build: build as unknown as ServerBuild, | |
}) | |
}, | |
configure: async app => { | |
const getHost = (req: { get: (key: string) => string | undefined }) => | |
req.get('X-Forwarded-Host') ?? req.get('host') ?? '' | |
// fly is our proxy | |
app.set('trust proxy', true) | |
app.use((req, res, next) => { | |
const proto = req.get('X-Forwarded-Proto') | |
const host = getHost(req) | |
if (proto === 'http') { | |
res.set('X-Forwarded-Proto', 'https') | |
res.redirect(`https://${host}${req.originalUrl}`) | |
return | |
} | |
next() | |
}) | |
// no ending slashes for SEO reasons | |
// https://github.com/epicweb-dev/epic-stack/discussions/108 | |
app.get('*', (req, res, next) => { | |
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(302, safepath + query) | |
} else { | |
next() | |
} | |
}) | |
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') | |
app.use(Sentry.Handlers.requestHandler()) | |
app.use(Sentry.Handlers.tracingHandler()) | |
app.use( | |
'/assets', | |
express.static('build/client/assets', { 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('build/client', { maxAge: '1h' })) | |
app.get(['/img/*', '/favicons/*'], (_req, res) => { | |
// if we made it past the express.static for these, then we're missing something. | |
// So we'll just send a 404 and won't bother calling other middleware. | |
return res.status(404).send('Not found') | |
}) | |
morgan.token('url', req => decodeURIComponent(req.url ?? '')) | |
app.use( | |
morgan('tiny', { | |
skip: (req, res) => | |
res.statusCode === 200 && | |
(req.url?.startsWith('/resources/note-images') || | |
req.url?.startsWith('/resources/user-images') || | |
req.url?.startsWith('/resources/healthcheck')), | |
}), | |
) | |
app.use((_, res, next) => { | |
res.locals.cspNonce = crypto.randomBytes(16).toString('hex') | |
next() | |
}) | |
app.use( | |
helmet({ | |
xPoweredBy: false, | |
referrerPolicy: { policy: 'same-origin' }, | |
crossOriginEmbedderPolicy: false, | |
contentSecurityPolicy: { | |
// NOTE: Remove reportOnly when you're ready to enforce this CSP | |
reportOnly: true, | |
directives: { | |
'connect-src': [ | |
MODE === 'development' ? 'ws:' : null, | |
process.env.SENTRY_DSN ? '*.sentry.io' : null, | |
'https://app.posthog.com/*', | |
'https://us.i.posthog.com/*', | |
'https://*.posthog.com', | |
'https://api.novu.co/v1/widgets/session/initialize', | |
'https://api.novu.co/v1/widgets/notifications/feed', | |
'https://api.novu.co/*', | |
'*.novu.co', | |
'wss://ws.novu.co/socket.io/', | |
"'self'", | |
].filter(Boolean), | |
'font-src': ["'self'"], | |
'frame-src': ["'self'"], | |
'img-src': ["'self'", 'data:'], | |
'script-src': [ | |
"'strict-dynamic'", | |
"'self'", | |
// @ts-expect-error | |
(_, res) => `'nonce-${res.locals.cspNonce}'`, | |
], | |
'script-src-attr': [ | |
// @ts-expect-error | |
(_, res) => `'nonce-${res.locals.cspNonce}'`, | |
], | |
'upgrade-insecure-requests': null, | |
}, | |
}, | |
}), | |
) | |
// When running tests or running in development, we want to effectively disable | |
// rate limiting because playwright tests are very fast and we don't want to | |
// have to wait for the rate limit to reset between tests. | |
const maxMultiple = | |
!IS_PROD || process.env.PLAYWRIGHT_TEST_BASE_URL ? 10_000 : 1 | |
const rateLimitDefault = { | |
windowMs: 60 * 1000, | |
max: 1000 * maxMultiple, | |
standardHeaders: true, | |
legacyHeaders: false, | |
// Fly.io prevents spoofing of X-Forwarded-For | |
// so no need to validate the trustProxy config | |
validate: { trustProxy: false }, | |
} | |
const strongestRateLimit = rateLimit({ | |
...rateLimitDefault, | |
windowMs: 60 * 1000, | |
max: 10 * maxMultiple, | |
}) | |
const strongRateLimit = rateLimit({ | |
...rateLimitDefault, | |
windowMs: 60 * 1000, | |
max: 100 * maxMultiple, | |
}) | |
const generalRateLimit = rateLimit(rateLimitDefault) | |
app.use((req, res, next) => { | |
const strongPaths = [ | |
'/login', | |
'/signup', | |
'/verify', | |
'/admin', | |
'/onboarding', | |
'/reset-password', | |
'/settings/profile', | |
'/resources/login', | |
'/resources/verify', | |
] | |
if (req.method !== 'GET' && req.method !== 'HEAD') { | |
if (strongPaths.some(p => req.path.includes(p))) { | |
return strongestRateLimit(req, res, next) | |
} | |
return strongRateLimit(req, res, next) | |
} | |
// the verify route is a special case because it's a GET route that | |
// can have a token in the query string | |
if (req.path.includes('/verify')) { | |
return strongestRateLimit(req, res, next) | |
} | |
return generalRateLimit(req, res, next) | |
}) | |
const desiredPort = Number(process.env.PORT || 3000) | |
const portToUse = await getPort({ | |
port: portNumbers(desiredPort, desiredPort + 100), | |
}) | |
const portAvailable = desiredPort === portToUse | |
if (!portAvailable && !IS_DEV) { | |
console.log(`⚠️ Port ${desiredPort} is not available.`) | |
process.exit(1) | |
} | |
const server = app.listen(portToUse, () => { | |
if (!portAvailable) { | |
console.warn( | |
chalk.yellow( | |
`⚠️ Port ${desiredPort} is not available, using ${portToUse} instead.`, | |
), | |
) | |
} | |
console.log(`🚀 We have liftoff!`) | |
}) | |
closeWithGrace(async () => { | |
await new Promise((resolve, reject) => { | |
server.close(e => (e ? reject(e) : resolve('ok'))) | |
}) | |
}) | |
}, | |
}) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment