diff --git a/.gitignore b/.gitignore index 2345034e4..42cfbbdcc 100644 --- a/.gitignore +++ b/.gitignore @@ -2,7 +2,6 @@ node_modules .DS_store /build -/server-build .env .cache diff --git a/.prettierignore b/.prettierignore index f022d0280..9dc17fca7 100644 --- a/.prettierignore +++ b/.prettierignore @@ -2,7 +2,6 @@ node_modules /build /public/build -/server-build .env /test-results/ diff --git a/app/routes/_auth/auth.$provider/callback.test.ts b/app/routes/_auth/auth.$provider/callback.test.ts index 10346eb5f..2f611c4a8 100644 --- a/app/routes/_auth/auth.$provider/callback.test.ts +++ b/app/routes/_auth/auth.$provider/callback.test.ts @@ -2,6 +2,7 @@ import { invariant } from '@epic-web/invariant' import { faker } from '@faker-js/faker' import { SetCookie } from '@mjackson/headers' import { http } from 'msw' +import { type AppLoadContext } from 'react-router' import { afterEach, expect, test } from 'vitest' import { twoFAVerificationType } from '#app/routes/settings/profile/two-factor/_layout.tsx' import { getSessionExpirationDate, sessionKey } from '#app/utils/auth.server.ts' @@ -25,9 +26,11 @@ afterEach(async () => { test('a new user goes to onboarding', async () => { const request = await setupRequest() - const response = await loader({ request, params: PARAMS, context: {} }).catch( - (e) => e, - ) + const response = await loader({ + request, + params: PARAMS, + context: {} as AppLoadContext, + }).catch((e) => e) expect(response).toHaveRedirect('/onboarding/github') }) @@ -39,9 +42,11 @@ test('when auth fails, send the user to login with a toast', async () => { }), ) const request = await setupRequest() - const response = await loader({ request, params: PARAMS, context: {} }).catch( - (e) => e, - ) + const response = await loader({ + request, + params: PARAMS, + context: {} as AppLoadContext, + }).catch((e) => e) invariant(response instanceof Response, 'response should be a Response') expect(response).toHaveRedirect('/login') await expect(response).toSendToast( @@ -60,7 +65,11 @@ test('when a user is logged in, it creates the connection', async () => { sessionId: session.id, code: githubUser.code, }) - const response = await loader({ request, params: PARAMS, context: {} }) + const response = await loader({ + request, + params: PARAMS, + context: {} as AppLoadContext, + }) expect(response).toHaveRedirect('/settings/profile/connections') await expect(response).toSendToast( expect.objectContaining({ @@ -96,7 +105,11 @@ test(`when a user is logged in and has already connected, it doesn't do anything sessionId: session.id, code: githubUser.code, }) - const response = await loader({ request, params: PARAMS, context: {} }) + const response = await loader({ + request, + params: PARAMS, + context: {} as AppLoadContext, + }) expect(response).toHaveRedirect('/settings/profile/connections') await expect(response).toSendToast( expect.objectContaining({ @@ -111,7 +124,11 @@ test('when a user exists with the same email, create connection and make session const email = githubUser.primaryEmail.toLowerCase() const { userId } = await setupUser({ ...createUser(), email }) const request = await setupRequest({ code: githubUser.code }) - const response = await loader({ request, params: PARAMS, context: {} }) + const response = await loader({ + request, + params: PARAMS, + context: {} as AppLoadContext, + }) expect(response).toHaveRedirect('/') @@ -155,7 +172,11 @@ test('gives an error if the account is already connected to another user', async sessionId: session.id, code: githubUser.code, }) - const response = await loader({ request, params: PARAMS, context: {} }) + const response = await loader({ + request, + params: PARAMS, + context: {} as AppLoadContext, + }) expect(response).toHaveRedirect('/settings/profile/connections') await expect(response).toSendToast( expect.objectContaining({ @@ -178,7 +199,11 @@ test('if a user is not logged in, but the connection exists, make a session', as }, }) const request = await setupRequest({ code: githubUser.code }) - const response = await loader({ request, params: PARAMS, context: {} }) + const response = await loader({ + request, + params: PARAMS, + context: {} as AppLoadContext, + }) expect(response).toHaveRedirect('/') await expect(response).toHaveSessionForUser(userId) }) @@ -202,7 +227,11 @@ test('if a user is not logged in, but the connection exists and they have enable }, }) const request = await setupRequest({ code: githubUser.code }) - const response = await loader({ request, params: PARAMS, context: {} }) + const response = await loader({ + request, + params: PARAMS, + context: {} as AppLoadContext, + }) const searchParams = new URLSearchParams({ type: twoFAVerificationType, target: userId, diff --git a/app/routes/_seo/sitemap[.]xml.ts b/app/routes/_seo/sitemap[.]xml.ts index 04d37c31e..f14219511 100644 --- a/app/routes/_seo/sitemap[.]xml.ts +++ b/app/routes/_seo/sitemap[.]xml.ts @@ -1,14 +1,11 @@ import { generateSitemap } from '@nasa-gcn/remix-seo' -import { type ServerBuild } from 'react-router' import { getDomainUrl } from '#app/utils/misc.tsx' import { type Route } from './+types/sitemap[.]xml.ts' export async function loader({ request, context }: Route.LoaderArgs) { - const serverBuild = (await context.serverBuild) as { build: ServerBuild } - // TODO: This is typeerror is coming up since of the remix-run/server-runtime package. We might need to remove/update that one. // @ts-expect-error - return generateSitemap(request, serverBuild.build.routes, { + return generateSitemap(request, context.serverBuild.routes, { siteUrl: getDomainUrl(request), headers: { 'Cache-Control': `public, max-age=${60 * 5}`, diff --git a/docs/authentication.md b/docs/authentication.md index 4ffedd9d6..af7bc3a52 100644 --- a/docs/authentication.md +++ b/docs/authentication.md @@ -44,8 +44,8 @@ is a precondition for a "Mock GitHub server" to be installed (with the help of calls to `https://github.com/login/oauth/access_token` are being intercepted. But once deployed to an environment where `process.env.MOCKS` is not set to `'true'` (see how this is done when launching the -[dev server](../server/dev-server.js) and checked in the -[entrypoint](../index.js)), or even when developing _locally_ but not setting +[server](../server/index.ts) and checked in the +[entrypoint](../index.ts)), or even when developing _locally_ but not setting `GITHUB_CLIENT_ID` to `MOCK_...`, the requests will actually reach the GitHub auth server. This is where you will want to have a GitHub OAuth application properly set up, otherwise the logging in with GitHub will fail and a diff --git a/docs/managing-updates.md b/docs/managing-updates.md index 0c52dda4e..417e1ba5f 100644 --- a/docs/managing-updates.md +++ b/docs/managing-updates.md @@ -12,14 +12,11 @@ If you wish to change the Node.js version, you can do so by updating the ```json { "engines": { - "node": "20.3.1" + "node": "^22.18.0" } } ``` -Make certain you do not use a version range here because this is used in the -`./other/build-server.ts` to compile the express server code. - You will also want to update the `Dockerfile` to use the same version of Node.js as the `package.json` file. diff --git a/index.js b/index.ts similarity index 79% rename from index.js rename to index.ts index 082cd60f5..f33db4af1 100644 --- a/index.js +++ b/index.ts @@ -20,8 +20,4 @@ if (process.env.MOCKS === 'true') { await import('./tests/mocks/index.ts') } -if (process.env.NODE_ENV === 'production') { - await import('./server-build/index.js') -} else { - await import('./server/index.ts') -} +await import('./server/index.ts') diff --git a/other/Dockerfile b/other/Dockerfile index eb5beec7d..092afc638 100644 --- a/other/Dockerfile +++ b/other/Dockerfile @@ -81,7 +81,6 @@ RUN INTERNAL_COMMAND_TOKEN=$(openssl rand -hex 32) && \ COPY --from=production-deps /myapp/node_modules /myapp/node_modules COPY --from=build /myapp/node_modules/.prisma /myapp/node_modules/.prisma -COPY --from=build /myapp/server-build /myapp/server-build COPY --from=build /myapp/build /myapp/build COPY --from=build /myapp/package.json /myapp/package.json COPY --from=build /myapp/prisma /myapp/prisma diff --git a/other/build-server.ts b/other/build-server.ts deleted file mode 100644 index 896b0f62a..000000000 --- a/other/build-server.ts +++ /dev/null @@ -1,50 +0,0 @@ -import path from 'node:path' -import { fileURLToPath } from 'node:url' -import esbuild from 'esbuild' -import fsExtra from 'fs-extra' -import { globSync } from 'glob' - -const pkg = fsExtra.readJsonSync(path.join(process.cwd(), 'package.json')) - -const __dirname = path.dirname(fileURLToPath(import.meta.url)) -const here = (...s: Array) => path.join(__dirname, ...s) -const globsafe = (s: string) => s.replace(/\\/g, '/') - -const allFiles = globSync(globsafe(here('../server/**/*.*')), { - ignore: [ - 'server/dev-server.js', // for development only - '**/tsconfig.json', - '**/eslint*', - '**/__tests__/**', - ], -}) - -const entries = [] -for (const file of allFiles) { - if (/\.(ts|js|tsx|jsx)$/.test(file)) { - entries.push(file) - } else { - const dest = file.replace(here('../server'), here('../server-build')) - fsExtra.ensureDirSync(path.parse(dest).dir) - fsExtra.copySync(file, dest) - console.log(`copied: ${file.replace(`${here('../server')}/`, '')}`) - } -} - -console.log() -console.log('building...') - -esbuild - .build({ - entryPoints: entries, - outdir: here('../server-build'), - target: [`node${pkg.engines.node}`], - platform: 'node', - sourcemap: true, - format: 'esm', - logLevel: 'info', - }) - .catch((error: unknown) => { - console.error(error) - process.exit(1) - }) diff --git a/package-lock.json b/package-lock.json index 36eaf419a..edb4ec641 100644 --- a/package-lock.json +++ b/package-lock.json @@ -126,7 +126,7 @@ "vitest": "^3.1.3" }, "engines": { - "node": "22" + "node": "^22.18.0" } }, "node_modules/@adobe/css-tools": { diff --git a/package.json b/package.json index 006e36fc7..b5f071391 100644 --- a/package.json +++ b/package.json @@ -10,16 +10,14 @@ "#tests/*": "./tests/*" }, "scripts": { - "build": "run-s build:*", - "build:remix": "react-router build", - "build:server": "tsx ./other/build-server.ts", - "dev": "cross-env NODE_ENV=development MOCKS=true node ./server/dev-server.js", - "dev:no-mocks": "cross-env NODE_ENV=development node ./server/dev-server.js", + "build": "react-router build", + "dev": "cross-env NODE_ENV=development MOCKS=true node server/index.ts", + "dev:no-mocks": "cross-env NODE_ENV=development node ./server/index.ts", "format": "prettier --write .", "lint": "eslint .", "setup": "npm run build && prisma migrate deploy && prisma generate --sql && playwright install", - "start": "cross-env NODE_ENV=production node .", - "start:mocks": "cross-env NODE_ENV=production MOCKS=true tsx .", + "start": "cross-env NODE_ENV=production node index.ts", + "start:mocks": "cross-env NODE_ENV=production MOCKS=true node index.ts", "test": "vitest", "coverage": "vitest run --coverage", "test:e2e": "npm run test:e2e:dev --silent", @@ -34,8 +32,7 @@ "/node_modules", "/build", "/public/build", - "/playwright-report", - "/server-build" + "/playwright-report" ], "dependencies": { "@conform-to/react": "^1.5.0", @@ -157,7 +154,7 @@ "vitest": "^3.1.3" }, "engines": { - "node": "22" + "node": "^22.18.0" }, "prisma": { "seed": "tsx prisma/seed.ts" diff --git a/remix.init/gitignore b/remix.init/gitignore index 2345034e4..42cfbbdcc 100644 --- a/remix.init/gitignore +++ b/remix.init/gitignore @@ -2,7 +2,6 @@ node_modules .DS_store /build -/server-build .env .cache diff --git a/server/app.ts b/server/app.ts new file mode 100644 index 000000000..d31988989 --- /dev/null +++ b/server/app.ts @@ -0,0 +1,23 @@ +/* eslint-disable import/no-duplicates */ +import 'react-router' +import { createRequestHandler } from '@react-router/express' +import express from 'express' +import { type ServerBuild } from 'react-router' + +declare module 'react-router' { + interface AppLoadContext { + serverBuild: ServerBuild + } +} + +export const app = express() + +app.use( + createRequestHandler({ + mode: process.env.NODE_ENV ?? 'development', + build: () => import('virtual:react-router/server-build'), + getLoadContext: async () => ({ + serverBuild: await import('virtual:react-router/server-build'), + }), + }), +) diff --git a/server/dev-server.js b/server/dev-server.js deleted file mode 100644 index 026131986..000000000 --- a/server/dev-server.js +++ /dev/null @@ -1,18 +0,0 @@ -import { execa } from 'execa' - -if (process.env.NODE_ENV === 'production') { - await import('../server-build/index.js') -} else { - const command = - 'tsx watch --clear-screen=false --ignore ".cache/**" --ignore "app/**" --ignore "vite.config.ts.timestamp-*" --ignore "build/**" --ignore "node_modules/**" --inspect ./index.js' - execa(command, { - stdio: ['ignore', 'inherit', 'inherit'], - shell: true, - env: { - FORCE_COLOR: true, - ...process.env, - }, - // https://github.com/sindresorhus/execa/issues/433 - windowsHide: false, - }) -} diff --git a/server/index.ts b/server/index.ts index b3103284c..ae587435b 100644 --- a/server/index.ts +++ b/server/index.ts @@ -1,6 +1,5 @@ import { styleText } from 'node:util' import { helmet } from '@nichtsam/helmet/node-http' -import { createRequestHandler } from '@react-router/express' import * as Sentry from '@sentry/react-router' import { ip as ipAddress } from 'address' import closeWithGrace from 'close-with-grace' @@ -9,33 +8,59 @@ import express from 'express' import rateLimit from 'express-rate-limit' import getPort, { portNumbers } from 'get-port' import morgan from 'morgan' -import { type ServerBuild } from 'react-router' const MODE = process.env.NODE_ENV ?? 'development' const IS_PROD = MODE === 'production' const IS_DEV = MODE === 'development' const ALLOW_INDEXING = process.env.ALLOW_INDEXING !== 'false' const SENTRY_ENABLED = IS_PROD && process.env.SENTRY_DSN +const BUILD_PATH = '../build/server/index.js' if (SENTRY_ENABLED) { - void import('./utils/monitoring.js').then(({ init }) => init()) + void import('./utils/monitoring.ts').then(({ init }) => init()) } -const viteDevServer = IS_PROD - ? undefined - : await import('vite').then((vite) => - vite.createServer({ - server: { - middlewareMode: true, - }, - // We tell Vite we are running a custom app instead of - // the SPA default so it doesn't run HTML middleware - appType: 'custom', - }), - ) - const app = express() +if (IS_DEV) { + console.log('Starting development server') + const viteDevServer = await import('vite').then((vite) => + vite.createServer({ + server: { middlewareMode: true }, + // We tell Vite we are running a custom app instead of + // the SPA default so it doesn't run HTML middleware + appType: 'custom', + }), + ) + app.use(viteDevServer.middlewares) + app.use(async (req, res, next) => { + try { + const source = await viteDevServer.ssrLoadModule('./server/app.ts') + return await source.app(req, res, next) + } catch (error) { + if (typeof error === 'object' && error instanceof Error) { + viteDevServer.ssrFixStacktrace(error) + } + next(error) + } + }) +} else { + console.log('Starting production server') + // React Router fingerprints its assets so we can cache forever. + app.use( + '/assets', + express.static('build/client/assets', { + immutable: true, + maxAge: '1y', + fallthrough: false, + }), + ) + // 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.use(await import(BUILD_PATH).then((mod) => mod.app)) +} + const getHost = (req: { get: (key: string) => string | undefined }) => req.get('X-Forwarded-Host') ?? req.get('host') ?? '' @@ -78,20 +103,6 @@ app.use((_, res, next) => { next() }) -if (viteDevServer) { - app.use(viteDevServer.middlewares) -} else { - // Remix fingerprints its assets so we can cache forever. - app.use( - '/assets', - express.static('build/client/assets', { immutable: true, maxAge: '1y', fallthrough: false }), - ) - - // 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. @@ -175,21 +186,6 @@ app.use((req, res, next) => { return generalRateLimit(req, res, next) }) -async function getBuild() { - try { - const build = viteDevServer - ? await viteDevServer.ssrLoadModule('virtual:react-router/server-build') - : // @ts-expect-error - the file might not exist yet but it will - await import('../build/server/index.js') - - return { build: build as unknown as ServerBuild, error: null } - } catch (error) { - // Catch error and return null to make express happy and avoid an unrecoverable crash - console.error('Error creating build:', error) - return { error: error, build: null as unknown as ServerBuild } - } -} - if (!ALLOW_INDEXING) { app.use((_, res, next) => { res.set('X-Robots-Tag', 'noindex, nofollow') @@ -197,22 +193,6 @@ if (!ALLOW_INDEXING) { }) } -app.all( - '*', - createRequestHandler({ - getLoadContext: () => ({ serverBuild: getBuild() }), - mode: MODE, - build: async () => { - const { error, build } = await getBuild() - // gracefully "catch" the error - if (error) { - throw error - } - return build - }, - }), -) - const desiredPort = Number(process.env.PORT || 3000) const portToUse = await getPort({ port: portNumbers(desiredPort, desiredPort + 100), diff --git a/vite.config.ts b/vite.config.ts index 1f10a67c9..81ccb55de 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -17,6 +17,7 @@ export default defineConfig((config) => ({ cssMinify: MODE === 'production', rollupOptions: { + input: config.isSsrBuild ? "./server/app.ts" : undefined, external: [/node:.*/, 'fsevents'], }, @@ -81,7 +82,7 @@ const sentryConfig: SentryReactRouterBuildOptions = { }, }, sourcemaps: { - filesToDeleteAfterUpload: ['./build/**/*.map', '.server-build/**/*.map'], + filesToDeleteAfterUpload: ['./build/**/*.map'], }, }, }