diff --git a/app/context.ts b/app/context.ts new file mode 100644 index 000000000..ead665b34 --- /dev/null +++ b/app/context.ts @@ -0,0 +1,4 @@ +import { createContext } from 'react-router' + +// Holds the authenticated user's ID for routes protected by middleware +export const userIdContext = createContext(null) diff --git a/app/middleware.server.ts b/app/middleware.server.ts new file mode 100644 index 000000000..a4f473751 --- /dev/null +++ b/app/middleware.server.ts @@ -0,0 +1,39 @@ +import { redirect, type MiddlewareFunction } from 'react-router' +import { userIdContext } from '#app/context.ts' +import { prisma } from '#app/utils/db.server.ts' +import { authSessionStorage } from '#app/utils/session.server.ts' + +export const requireUserMiddleware: MiddlewareFunction = async ({ + request, + context, +}) => { + const cookie = request.headers.get('cookie') + const session = await authSessionStorage.getSession(cookie) + const sessionId = session.get('sessionId') as string | undefined + if (!sessionId) + throw redirect( + `/login?redirectTo=${encodeURIComponent(new URL(request.url).pathname)}`, + ) + + const sessionRecord = await prisma.session.findUnique({ + select: { userId: true, expirationDate: true }, + where: { id: sessionId }, + }) + + if (!sessionRecord || sessionRecord.expirationDate < new Date()) { + throw redirect( + `/login?redirectTo=${encodeURIComponent(new URL(request.url).pathname)}`, + ) + } + + context.set(userIdContext, sessionRecord.userId) +} + +export const requireAnonymousMiddleware: MiddlewareFunction = async ({ + request, +}) => { + const cookie = request.headers.get('cookie') + const session = await authSessionStorage.getSession(cookie) + const sessionId = session.get('sessionId') as string | undefined + if (sessionId) throw redirect('/') +} diff --git a/app/routes/_auth+/auth.$provider.callback.test.ts b/app/routes/_auth+/auth.$provider.callback.test.ts index 643e4534c..c160e7b96 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 { RouterContextProvider } from 'react-router' import { afterEach, expect, test } from 'vitest' import { twoFAVerificationType } from '#app/routes/settings+/profile.two-factor.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: new RouterContextProvider() as any, + }).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: new RouterContextProvider() as any, + }).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: new RouterContextProvider() as any, + }) 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: new RouterContextProvider() as any, + }) 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: new RouterContextProvider() as any, + }) 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: new RouterContextProvider() as any, + }) 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: new RouterContextProvider() as any, + }) 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: new RouterContextProvider() as any, + }) const searchParams = new URLSearchParams({ type: twoFAVerificationType, target: userId, diff --git a/app/routes/_auth+/login.server.ts b/app/routes/_auth+/login.server.ts index 9d5cf0bfb..b27f494c5 100644 --- a/app/routes/_auth+/login.server.ts +++ b/app/routes/_auth+/login.server.ts @@ -1,6 +1,7 @@ import { invariant } from '@epic-web/invariant' import { redirect } from 'react-router' import { safeRedirect } from 'remix-utils/safe-redirect' +import { requireAnonymousMiddleware } from '#app/middleware.server.ts' import { twoFAVerificationType } from '#app/routes/settings+/profile.two-factor.tsx' import { getUserId, sessionKey } from '#app/utils/auth.server.ts' import { prisma } from '#app/utils/db.server.ts' @@ -10,6 +11,8 @@ import { redirectWithToast } from '#app/utils/toast.server.ts' import { verifySessionStorage } from '#app/utils/verification.server.ts' import { getRedirectToUrl, type VerifyFunctionArgs } from './verify.server.ts' +export const middleware = [requireAnonymousMiddleware] + const verifiedTimeKey = 'verified-time' const unverifiedSessionIdKey = 'unverified-session-id' const rememberKey = 'remember' diff --git a/app/routes/_auth+/login.tsx b/app/routes/_auth+/login.tsx index fc12b1cca..c60f272d8 100644 --- a/app/routes/_auth+/login.tsx +++ b/app/routes/_auth+/login.tsx @@ -11,7 +11,7 @@ import { CheckboxField, ErrorList, Field } from '#app/components/forms.tsx' import { Spacer } from '#app/components/spacer.tsx' import { Icon } from '#app/components/ui/icon.tsx' import { StatusButton } from '#app/components/ui/status-button.tsx' -import { login, requireAnonymous } from '#app/utils/auth.server.ts' +import { login } from '#app/utils/auth.server.ts' import { ProviderConnectionForm, providerNames, @@ -38,12 +38,10 @@ const AuthenticationOptionsSchema = z.object({ }) satisfies z.ZodType<{ options: PublicKeyCredentialRequestOptionsJSON }> export async function loader({ request }: Route.LoaderArgs) { - await requireAnonymous(request) return {} } export async function action({ request }: Route.ActionArgs) { - await requireAnonymous(request) const formData = await request.formData() await checkHoneypot(formData) const submission = await parseWithZod(formData, { diff --git a/app/routes/_auth+/onboarding.server.ts b/app/routes/_auth+/onboarding.server.ts index 502ef8078..d6b547cbd 100644 --- a/app/routes/_auth+/onboarding.server.ts +++ b/app/routes/_auth+/onboarding.server.ts @@ -1,9 +1,12 @@ import { invariant } from '@epic-web/invariant' import { redirect } from 'react-router' +import { requireAnonymousMiddleware } from '#app/middleware.server.ts' import { verifySessionStorage } from '#app/utils/verification.server.ts' import { onboardingEmailSessionKey } from './onboarding.tsx' import { type VerifyFunctionArgs } from './verify.server.ts' +export const middleware = [requireAnonymousMiddleware] + export async function handleVerification({ submission }: VerifyFunctionArgs) { invariant( submission.status === 'success', diff --git a/app/routes/_auth+/onboarding.tsx b/app/routes/_auth+/onboarding.tsx index a9d05a8e6..227a109fa 100644 --- a/app/routes/_auth+/onboarding.tsx +++ b/app/routes/_auth+/onboarding.tsx @@ -9,7 +9,6 @@ import { Spacer } from '#app/components/spacer.tsx' import { StatusButton } from '#app/components/ui/status-button.tsx' import { checkIsCommonPassword, - requireAnonymous, sessionKey, signup, } from '#app/utils/auth.server.ts' @@ -42,7 +41,6 @@ const SignupFormSchema = z .and(PasswordAndConfirmPasswordSchema) async function requireOnboardingEmail(request: Request) { - await requireAnonymous(request) const verifySession = await verifySessionStorage.getSession( request.headers.get('cookie'), ) diff --git a/app/routes/_auth+/onboarding_.$provider.server.ts b/app/routes/_auth+/onboarding_.$provider.server.ts index 502ef8078..faec312f1 100644 --- a/app/routes/_auth+/onboarding_.$provider.server.ts +++ b/app/routes/_auth+/onboarding_.$provider.server.ts @@ -1,19 +1,3 @@ -import { invariant } from '@epic-web/invariant' -import { redirect } from 'react-router' -import { verifySessionStorage } from '#app/utils/verification.server.ts' -import { onboardingEmailSessionKey } from './onboarding.tsx' -import { type VerifyFunctionArgs } from './verify.server.ts' +import { requireAnonymousMiddleware } from '#app/middleware.server.ts' -export async function handleVerification({ submission }: VerifyFunctionArgs) { - invariant( - submission.status === 'success', - 'Submission should be successful by now', - ) - const verifySession = await verifySessionStorage.getSession() - verifySession.set(onboardingEmailSessionKey, submission.value.target) - return redirect('/onboarding', { - headers: { - 'set-cookie': await verifySessionStorage.commitSession(verifySession), - }, - }) -} +export const middleware = [requireAnonymousMiddleware] diff --git a/app/routes/_auth+/onboarding_.$provider.tsx b/app/routes/_auth+/onboarding_.$provider.tsx index 65aeafa00..4975975be 100644 --- a/app/routes/_auth+/onboarding_.$provider.tsx +++ b/app/routes/_auth+/onboarding_.$provider.tsx @@ -17,11 +17,7 @@ import { z } from 'zod' import { CheckboxField, ErrorList, Field } from '#app/components/forms.tsx' import { Spacer } from '#app/components/spacer.tsx' import { StatusButton } from '#app/components/ui/status-button.tsx' -import { - sessionKey, - signupWithConnection, - requireAnonymous, -} from '#app/utils/auth.server.ts' +import { sessionKey, signupWithConnection } from '#app/utils/auth.server.ts' import { ProviderNameSchema } from '#app/utils/connections.tsx' import { prisma } from '#app/utils/db.server.ts' import { useIsPending } from '#app/utils/misc.tsx' @@ -53,7 +49,6 @@ async function requireData({ request: Request params: Params }) { - await requireAnonymous(request) const verifySession = await verifySessionStorage.getSession( request.headers.get('cookie'), ) diff --git a/app/routes/_auth+/reset-password.server.ts b/app/routes/_auth+/reset-password.server.ts index f5b2d5072..a820a7872 100644 --- a/app/routes/_auth+/reset-password.server.ts +++ b/app/routes/_auth+/reset-password.server.ts @@ -1,10 +1,13 @@ import { invariant } from '@epic-web/invariant' import { data, redirect } from 'react-router' +import { requireAnonymousMiddleware } from '#app/middleware.server.ts' import { prisma } from '#app/utils/db.server.ts' import { verifySessionStorage } from '#app/utils/verification.server.ts' import { resetPasswordUsernameSessionKey } from './reset-password.tsx' import { type VerifyFunctionArgs } from './verify.server.ts' +export const middleware = [requireAnonymousMiddleware] + export async function handleVerification({ submission }: VerifyFunctionArgs) { invariant( submission.status === 'success', diff --git a/app/routes/_auth+/reset-password.tsx b/app/routes/_auth+/reset-password.tsx index 1280e1b7c..023f3d469 100644 --- a/app/routes/_auth+/reset-password.tsx +++ b/app/routes/_auth+/reset-password.tsx @@ -7,7 +7,6 @@ import { ErrorList, Field } from '#app/components/forms.tsx' import { StatusButton } from '#app/components/ui/status-button.tsx' import { checkIsCommonPassword, - requireAnonymous, resetUserPassword, } from '#app/utils/auth.server.ts' import { useIsPending } from '#app/utils/misc.tsx' @@ -24,7 +23,6 @@ export const resetPasswordUsernameSessionKey = 'resetPasswordUsername' const ResetPasswordSchema = PasswordAndConfirmPasswordSchema async function requireResetPasswordUsername(request: Request) { - await requireAnonymous(request) const verifySession = await verifySessionStorage.getSession( request.headers.get('cookie'), ) diff --git a/app/routes/_auth+/signup.server.ts b/app/routes/_auth+/signup.server.ts new file mode 100644 index 000000000..faec312f1 --- /dev/null +++ b/app/routes/_auth+/signup.server.ts @@ -0,0 +1,3 @@ +import { requireAnonymousMiddleware } from '#app/middleware.server.ts' + +export const middleware = [requireAnonymousMiddleware] diff --git a/app/routes/_auth+/signup.tsx b/app/routes/_auth+/signup.tsx index 095a23380..55ed8910b 100644 --- a/app/routes/_auth+/signup.tsx +++ b/app/routes/_auth+/signup.tsx @@ -8,7 +8,6 @@ import { z } from 'zod' import { GeneralErrorBoundary } from '#app/components/error-boundary.tsx' import { ErrorList, Field } from '#app/components/forms.tsx' import { StatusButton } from '#app/components/ui/status-button.tsx' -import { requireAnonymous } from '#app/utils/auth.server.ts' import { ProviderConnectionForm, providerNames, @@ -30,7 +29,6 @@ const SignupSchema = z.object({ }) export async function loader({ request }: Route.LoaderArgs) { - await requireAnonymous(request) return null } diff --git a/app/routes/_seo+/sitemap[.]xml.ts b/app/routes/_seo+/sitemap[.]xml.ts index 04d37c31e..0f40278a7 100644 --- a/app/routes/_seo+/sitemap[.]xml.ts +++ b/app/routes/_seo+/sitemap[.]xml.ts @@ -1,10 +1,21 @@ import { generateSitemap } from '@nasa-gcn/remix-seo' -import { type ServerBuild } from 'react-router' +import { + type ServerBuild, + type RouterContextProvider, + createContext, +} from 'react-router' import { getDomainUrl } from '#app/utils/misc.tsx' import { type Route } from './+types/sitemap[.]xml.ts' +// recreate context key to match the one set in server getLoadContext +export const serverBuildContext = createContext | null>(null) export async function loader({ request, context }: Route.LoaderArgs) { - const serverBuild = (await context.serverBuild) as { build: ServerBuild } + const serverBuild = (await (context as Readonly).get( + serverBuildContext, + )) 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 diff --git a/app/routes/settings+/profile.server.ts b/app/routes/settings+/profile.server.ts new file mode 100644 index 000000000..64ce05a2a --- /dev/null +++ b/app/routes/settings+/profile.server.ts @@ -0,0 +1,3 @@ +import { requireUserMiddleware } from '#app/middleware.server.ts' + +export const middleware = [requireUserMiddleware] diff --git a/app/routes/settings+/profile.tsx b/app/routes/settings+/profile.tsx index f7ab8fcf0..8775e0c8c 100644 --- a/app/routes/settings+/profile.tsx +++ b/app/routes/settings+/profile.tsx @@ -4,7 +4,7 @@ import { Link, Outlet, useMatches } from 'react-router' import { z } from 'zod' import { Spacer } from '#app/components/spacer.tsx' import { Icon } from '#app/components/ui/icon.tsx' -import { requireUserId } from '#app/utils/auth.server.ts' +import { userIdContext } from '#app/context.ts' import { prisma } from '#app/utils/db.server.ts' import { cn } from '#app/utils/misc.tsx' import { useUser } from '#app/utils/user.ts' @@ -18,10 +18,11 @@ export const handle: BreadcrumbHandle & SEOHandle = { getSitemapEntries: () => null, } -export async function loader({ request }: Route.LoaderArgs) { - const userId = await requireUserId(request) +export async function loader({ context }: Route.LoaderArgs) { + const userId = context.get(userIdContext) as string | null + invariantResponse(Boolean(userId), 'Unauthorized', { status: 401 }) const user = await prisma.user.findUnique({ - where: { id: userId }, + where: { id: userId as string }, select: { username: true }, }) invariantResponse(user, 'User not found', { status: 404 }) diff --git a/app/utils/cache.server.ts b/app/utils/cache.server.ts index b10f2b631..85d52b459 100644 --- a/app/utils/cache.server.ts +++ b/app/utils/cache.server.ts @@ -1,5 +1,6 @@ import fs from 'node:fs' import path from 'node:path' +import { DatabaseSync } from 'node:sqlite' import { cachified as baseCachified, verboseReporter, @@ -13,7 +14,6 @@ import { } from '@epic-web/cachified' import { remember } from '@epic-web/remember' import { LRUCache } from 'lru-cache' -import { DatabaseSync } from 'node:sqlite' import { z } from 'zod' import { updatePrimaryCacheValue } from '#app/routes/admin+/cache_.sqlite.server.ts' import { getInstanceInfo, getInstanceInfoSync } from './litefs.server.ts' diff --git a/docs/database.md b/docs/database.md index 746714d37..f9d69c322 100644 --- a/docs/database.md +++ b/docs/database.md @@ -300,7 +300,6 @@ You've got a few options: re-generating the migration after fixing the error. 3. If you do care about the data and don't have a backup, you can follow these steps: - 1. Comment out the [`exec` section from `litefs.yml` file](https://github.com/epicweb-dev/epic-stack/blob/main/other/litefs.yml#L31-L37). diff --git a/docs/decisions/039-passkeys.md b/docs/decisions/039-passkeys.md index af37ca62e..32916b52f 100644 --- a/docs/decisions/039-passkeys.md +++ b/docs/decisions/039-passkeys.md @@ -11,7 +11,6 @@ username/password and OAuth providers. While these methods are widely used, they come with various security challenges: 1. Password-based authentication: - - Users often reuse passwords across services - Passwords can be phished or stolen - Password management is a burden for users @@ -39,14 +38,12 @@ using: The authentication flow works as follows: 1. Registration: - - Server generates a challenge and sends registration options - Client creates a new key pair and signs the challenge with the private key - Public key and metadata are sent to the server for storage - Private key remains securely stored in the authenticator 2. Authentication: - - Server generates a new challenge - Client signs it with the stored private key - Server verifies the signature using the stored public key @@ -64,19 +61,16 @@ While passkeys represent the future of authentication, we maintain support for password and OAuth authentication because: 1. Adoption and Transition: - - Passkey support is still rolling out across platforms and browsers - Users need time to become comfortable with the new technology - Organizations may have existing requirements for specific auth methods 2. Fallback Options: - - Some users may not have compatible devices - Enterprise environments might restrict biometric authentication - Backup authentication methods provide reliability 3. User Choice: - - Different users have different security/convenience preferences - Some scenarios may require specific authentication types - Supporting multiple methods maximizes accessibility @@ -112,20 +106,17 @@ We chose SimpleWebAuthn because: ### Positive: 1. Enhanced Security for Users: - - Phishing-resistant authentication adds protection against common attacks - Hardware-backed security provides stronger guarantees than passwords alone - Biometric authentication reduces risk of credential sharing 2. Improved User Experience Options: - - Users can choose between password, OAuth, or passkey based on their needs - Native biometric flows provide fast and familiar authentication - Password manager integration enables seamless cross-device access - Multiple authentication methods increase accessibility 3. Future-Proofing Authentication: - - Adoption of web standard - Gradual transition path as passkey support grows - Meeting evolving security best practices @@ -133,14 +124,12 @@ We chose SimpleWebAuthn because: ### Negative: 1. Implementation Complexity: - - WebAuthn is a complex specification - Need to handle various device capabilities - Must maintain backward compatibility - Need to maintain password-based auth as fallback 2. User Education: - - New technology requires user education - Some users may be hesitant to adopt - Need clear documentation and UI guidance @@ -148,7 +137,6 @@ We chose SimpleWebAuthn because: ### Neutral: 1. Data Storage: - - New database model for passkeys - Additional storage requirements per user - Migration path for existing users diff --git a/docs/decisions/043-pwnedpasswords.md b/docs/decisions/043-pwnedpasswords.md index 366105e6f..e457f537d 100644 --- a/docs/decisions/043-pwnedpasswords.md +++ b/docs/decisions/043-pwnedpasswords.md @@ -22,14 +22,12 @@ However, we wanted to implement this in a way that: We will integrate the HaveIBeenPwned Password API with the following approach: 1. **Progressive Enhancement** - - The password check is implemented as a non-blocking enhancement - If the check fails or times out (>1s), we allow the password - This ensures users can still set passwords even if the service is unavailable 2. **Development Experience** - - The API calls are mocked during development and testing using MSW (Mock Service Worker) - This prevents unnecessary API calls during development @@ -37,7 +35,6 @@ We will integrate the HaveIBeenPwned Password API with the following approach: - Follows our pattern of mocking external services 3. **Error Handling** - - Timeout after 1 second to prevent blocking users - Graceful fallback if the service is unavailable - Warning logs for monitoring service health diff --git a/docs/decisions/044-rr-devtools.md b/docs/decisions/044-rr-devtools.md index 904051aba..60b4c52b4 100644 --- a/docs/decisions/044-rr-devtools.md +++ b/docs/decisions/044-rr-devtools.md @@ -6,34 +6,33 @@ Status: accepted ## Context -Epic Stack uses React Router for routing. React Router is a powerful -library, but it can be difficult to debug and visualize the routing -in your application. This is especially true when you have a complex -routing structure with nested routes, dynamic routes, and you rely -on data functions like loaders and actions, which the Epic Stack does. - -It is also hard to know which routes are currently active -(which ones are rendered) and if any if the loaders are triggered -when you expect them to be. This can lead to confusion and frustration -and the use of console.log statements to debug the routing in your -application. - -This is where the React Router DevTools come in. The React -Router DevTools are a set of tools that do all of these things for you. - -React Router has a set of DevTools that help debug and visualize the -routing in your application. The DevTools allow you to see the -current route information, including the current location, the matched -routes, and the route hierarchy. This can be very helpful when debugging -your applications. The DevTools also hook into your server-side by -wrapping loaders and actions, allowing you to get extensive -information about the data being loaded and the actions being dispatched. +Epic Stack uses React Router for routing. React Router is a powerful library, +but it can be difficult to debug and visualize the routing in your application. +This is especially true when you have a complex routing structure with nested +routes, dynamic routes, and you rely on data functions like loaders and actions, +which the Epic Stack does. + +It is also hard to know which routes are currently active (which ones are +rendered) and if any if the loaders are triggered when you expect them to be. +This can lead to confusion and frustration and the use of console.log statements +to debug the routing in your application. + +This is where the React Router DevTools come in. The React Router DevTools are a +set of tools that do all of these things for you. + +React Router has a set of DevTools that help debug and visualize the routing in +your application. The DevTools allow you to see the current route information, +including the current location, the matched routes, and the route hierarchy. +This can be very helpful when debugging your applications. The DevTools also +hook into your server-side by wrapping loaders and actions, allowing you to get +extensive information about the data being loaded and the actions being +dispatched. ## Decision -We will add the React Router DevTools to the Epic Stack. The DevTools -will be added to the project as a development dependency. The DevTools -will be used in development mode only. +We will add the React Router DevTools to the Epic Stack. The DevTools will be +added to the project as a development dependency. The DevTools will be used in +development mode only. The DevTools will be used to enhance the following: @@ -45,19 +44,18 @@ The DevTools will be used to enhance the following: 6. See cache information returned via headers from your loaders 7. See which loaders/actions are triggered when you navigate to a route 8. and a lot more! - ## Consequences With the addition of the React Router DevTools, you will not have to rely on -console.log statements to debug your routing. The DevTools will provide you -with the tools to ship your applications faster and with more confidence. +console.log statements to debug your routing. The DevTools will provide you with +the tools to ship your applications faster and with more confidence. -The DevTools will also help you visualize the routing in your application, -which can be very helpful in understanding routing in general, and figuring -out if your routes are set up correctly. +The DevTools will also help you visualize the routing in your application, which +can be very helpful in understanding routing in general, and figuring out if +your routes are set up correctly. -They are not included in the production build by default, so you will not -have to worry about them being included in your production bundle. -They are only included in development mode, so you can use them without -any negative performance impact in production. \ No newline at end of file +They are not included in the production build by default, so you will not have +to worry about them being included in your production bundle. They are only +included in development mode, so you can use them without any negative +performance impact in production. diff --git a/package-lock.json b/package-lock.json index 02c417d12..77094c54f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30,9 +30,9 @@ "@radix-ui/react-toast": "^1.2.11", "@radix-ui/react-tooltip": "^1.2.4", "@react-email/components": "0.0.38", - "@react-router/express": "^7.8.1", - "@react-router/node": "^7.8.1", - "@react-router/remix-routes-option-adapter": "^7.8.1", + "@react-router/express": "^7.9.0", + "@react-router/node": "^7.9.0", + "@react-router/remix-routes-option-adapter": "^7.9.0", "@remix-run/server-runtime": "^2.16.5", "@sentry/profiling-node": "^9.32.0", "@sentry/react-router": "^9.32.0", @@ -67,7 +67,7 @@ "qrcode": "^1.5.4", "react": "^19.1.0", "react-dom": "^19.1.0", - "react-router": "^7.8.1", + "react-router": "^7.9.0", "remix-auth": "^4.2.0", "remix-auth-github": "^3.0.2", "remix-utils": "^8.5.0", @@ -85,7 +85,7 @@ "@epic-web/config": "^1.20.1", "@faker-js/faker": "^9.7.0", "@playwright/test": "^1.52.0", - "@react-router/dev": "^7.8.1", + "@react-router/dev": "^7.9.0", "@sly-cli/sly": "^2.1.1", "@testing-library/dom": "^10.4.0", "@testing-library/jest-dom": "^6.6.3", @@ -2595,7 +2595,8 @@ "node_modules/@mjackson/node-fetch-server": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/@mjackson/node-fetch-server/-/node-fetch-server-0.2.0.tgz", - "integrity": "sha512-EMlH1e30yzmTpGLQjlFmaDAjyOeZhng1/XCd7DExR8PNAnG/G1tyruZxEoUe11ClnwGhGrtsdnyyUx1frSzjng==" + "integrity": "sha512-EMlH1e30yzmTpGLQjlFmaDAjyOeZhng1/XCd7DExR8PNAnG/G1tyruZxEoUe11ClnwGhGrtsdnyyUx1frSzjng==", + "license": "MIT" }, "node_modules/@mswjs/interceptors": { "version": "0.39.2", @@ -4706,10 +4707,11 @@ } }, "node_modules/@react-router/dev": { - "version": "7.8.1", - "resolved": "https://registry.npmjs.org/@react-router/dev/-/dev-7.8.1.tgz", - "integrity": "sha512-ESFe7DbMvCvl7e8N7L9NmI64VJGNCc60/VX1DUZYw/jFfzA5098/6D1aUojcxyVYBbMbVTfw0xmEvD4CsJzy1Q==", + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/@react-router/dev/-/dev-7.9.0.tgz", + "integrity": "sha512-7Jo4PTfcQHNxYEvSU3QSBSG3QwDKgYIw1uD/jz7n3fyL9k6KCB0zgFsMyGDnhrDh9AYL35WC9LF+PcQhGbs//w==", "dev": true, + "license": "MIT", "dependencies": { "@babel/core": "^7.27.7", "@babel/generator": "^7.27.5", @@ -4719,8 +4721,7 @@ "@babel/traverse": "^7.27.7", "@babel/types": "^7.27.7", "@npmcli/package-json": "^4.0.1", - "@react-router/node": "7.8.1", - "@vitejs/plugin-rsc": "0.4.11", + "@react-router/node": "7.9.0", "arg": "^5.0.1", "babel-dead-code-elimination": "^1.0.6", "chokidar": "^4.0.0", @@ -4747,8 +4748,9 @@ "node": ">=20.0.0" }, "peerDependencies": { - "@react-router/serve": "^7.8.1", - "react-router": "^7.8.1", + "@react-router/serve": "^7.9.0", + "@vitejs/plugin-rsc": "*", + "react-router": "^7.9.0", "typescript": "^5.1.0", "vite": "^5.1.0 || ^6.0.0 || ^7.0.0", "wrangler": "^3.28.2 || ^4.0.0" @@ -4757,6 +4759,9 @@ "@react-router/serve": { "optional": true }, + "@vitejs/plugin-rsc": { + "optional": true + }, "typescript": { "optional": true }, @@ -4766,18 +4771,19 @@ } }, "node_modules/@react-router/express": { - "version": "7.8.1", - "resolved": "https://registry.npmjs.org/@react-router/express/-/express-7.8.1.tgz", - "integrity": "sha512-Oq+l1eOex6TE1uAixM177YGF0yhYCqMoqvLQIjAGz4bfpCui6UewSDR6FSBNm+vub2OB06B5ARk6W4BOzf2ZcQ==", + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/@react-router/express/-/express-7.9.0.tgz", + "integrity": "sha512-YftJtPhLazjM2k5h+2Be9U+GZ4MtH7FSF1X2GNEEqoEmiHZDnh2nneXqNkEGkZT9EDWbGLjcRhLcZhA6Q2+dag==", + "license": "MIT", "dependencies": { - "@react-router/node": "7.8.1" + "@react-router/node": "7.9.0" }, "engines": { "node": ">=20.0.0" }, "peerDependencies": { "express": "^4.17.1 || ^5", - "react-router": "7.8.1", + "react-router": "7.9.0", "typescript": "^5.1.0" }, "peerDependenciesMeta": { @@ -4787,9 +4793,10 @@ } }, "node_modules/@react-router/node": { - "version": "7.8.1", - "resolved": "https://registry.npmjs.org/@react-router/node/-/node-7.8.1.tgz", - "integrity": "sha512-NC8eVQir2CRdcokzyyBsfxdq85Yu8B5XynDt581CzjBOreHAFfqIsNjGnqmg+aqBLiknQb2De9fH/TjyeYNeqw==", + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/@react-router/node/-/node-7.9.0.tgz", + "integrity": "sha512-sqi4JAaJN9AO/Aq+wEfu2YLrrN/gKGN9YPm4cL415GtC7ush5V+yrLBISzyaSpz9isRaeNy16mp7MiCrfIWRwg==", + "license": "MIT", "dependencies": { "@mjackson/node-fetch-server": "^0.2.0" }, @@ -4797,7 +4804,7 @@ "node": ">=20.0.0" }, "peerDependencies": { - "react-router": "7.8.1", + "react-router": "7.9.0", "typescript": "^5.1.0" }, "peerDependenciesMeta": { @@ -4807,14 +4814,15 @@ } }, "node_modules/@react-router/remix-routes-option-adapter": { - "version": "7.8.1", - "resolved": "https://registry.npmjs.org/@react-router/remix-routes-option-adapter/-/remix-routes-option-adapter-7.8.1.tgz", - "integrity": "sha512-++7gmQlGL/7Y0I3YTvYLJTeXEa0kZ0yhecBCTdherFIg63zAzioWAAyiP0iNGEoS78Sz4JxiGjZUgO/eOh98gg==", + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/@react-router/remix-routes-option-adapter/-/remix-routes-option-adapter-7.9.0.tgz", + "integrity": "sha512-C5FNI25/AiAv7b9cqUB8nnwTVVHwW7VUhZuIB/NQAcmT4AGwxylVHc/+kEPQHbrCImGVIpv8lIL/ENXLvbdSpg==", + "license": "MIT", "engines": { "node": ">=20.0.0" }, "peerDependencies": { - "@react-router/dev": "^7.8.1", + "@react-router/dev": "^7.9.0", "typescript": "^5.1.0" }, "peerDependenciesMeta": { @@ -7445,47 +7453,6 @@ "node": ">=0.10.0" } }, - "node_modules/@vitejs/plugin-rsc": { - "version": "0.4.11", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-rsc/-/plugin-rsc-0.4.11.tgz", - "integrity": "sha512-+4H4wLi+Y9yF58znBfKgGfX8zcqUGt8ngnmNgzrdGdF1SVz7EO0sg7WnhK5fFVHt6fUxsVEjmEabsCWHKPL1Tw==", - "dev": true, - "dependencies": { - "@mjackson/node-fetch-server": "^0.7.0", - "es-module-lexer": "^1.7.0", - "estree-walker": "^3.0.3", - "magic-string": "^0.30.17", - "periscopic": "^4.0.2", - "turbo-stream": "^3.1.0", - "vitefu": "^1.1.1" - }, - "peerDependencies": { - "react": "*", - "react-dom": "*", - "vite": "*" - } - }, - "node_modules/@vitejs/plugin-rsc/node_modules/@mjackson/node-fetch-server": { - "version": "0.7.0", - "resolved": "https://registry.npmjs.org/@mjackson/node-fetch-server/-/node-fetch-server-0.7.0.tgz", - "integrity": "sha512-un8diyEBKU3BTVj3GzlTPA1kIjCkGdD+AMYQy31Gf9JCkfoZzwgJ79GUtHrF2BN3XPNMLpubbzPcxys+a3uZEw==", - "dev": true - }, - "node_modules/@vitejs/plugin-rsc/node_modules/magic-string": { - "version": "0.30.17", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz", - "integrity": "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==", - "dev": true, - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0" - } - }, - "node_modules/@vitejs/plugin-rsc/node_modules/turbo-stream": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/turbo-stream/-/turbo-stream-3.1.0.tgz", - "integrity": "sha512-tVI25WEXl4fckNEmrq70xU1XumxUwEx/FZD5AgEcV8ri7Wvrg2o7GEq8U7htrNx3CajciGm+kDyhRf5JB6t7/A==", - "dev": true - }, "node_modules/@vitest/coverage-v8": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-3.2.4.tgz", @@ -12087,15 +12054,6 @@ "dev": true, "license": "MIT" }, - "node_modules/is-reference": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", - "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", - "dev": true, - "dependencies": { - "@types/estree": "^1.0.6" - } - }, "node_modules/is-regex": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", @@ -14483,17 +14441,6 @@ "url": "https://ko-fi.com/killymxi" } }, - "node_modules/periscopic": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/periscopic/-/periscopic-4.0.2.tgz", - "integrity": "sha512-sqpQDUy8vgB7ycLkendSKS6HnVz1Rneoc3Rc+ZBUCe2pbqlVuCC5vF52l0NJ1aiMg/r1qfYF9/myz8CZeI2rjA==", - "dev": true, - "dependencies": { - "@types/estree": "*", - "is-reference": "^3.0.2", - "zimmerframe": "^1.0.0" - } - }, "node_modules/pg-int8": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", @@ -15451,9 +15398,10 @@ } }, "node_modules/react-router": { - "version": "7.8.1", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.8.1.tgz", - "integrity": "sha512-5cy/M8DHcG51/KUIka1nfZ2QeylS4PJRs6TT8I4PF5axVsI5JUxp0hC0NZ/AEEj8Vw7xsEoD7L/6FY+zoYaOGA==", + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.9.0.tgz", + "integrity": "sha512-gmmc2UNj8oS8Z2JGpfAmhLv+j5O9Xciv2HAGZN0rV//ycoe1E40xN3ovqLZD7PsMDkoJvsbASE8TjAY+Xm7DKQ==", + "license": "MIT", "dependencies": { "cookie": "^1.0.1", "set-cookie-parser": "^2.6.0" @@ -18164,20 +18112,6 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/vitefu": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.1.tgz", - "integrity": "sha512-B/Fegf3i8zh0yFbpzZ21amWzHmuNlLlmJT6n7bu5e+pCHUKQIfXSYokrqOBGEMMe9UG2sostKQF9mml/vYaWJQ==", - "dev": true, - "peerDependencies": { - "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0-beta.0" - }, - "peerDependenciesMeta": { - "vite": { - "optional": true - } - } - }, "node_modules/vitest": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", @@ -18791,12 +18725,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/zimmerframe": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.2.tgz", - "integrity": "sha512-rAbqEGa8ovJy4pyBxZM70hg4pE6gDgaQ0Sl9M3enG3I0d6H4XSAM3GeNGLKnsBpuijUow064sf7ww1nutC5/3w==", - "dev": true - }, "node_modules/zod": { "version": "3.25.67", "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.67.tgz", diff --git a/package.json b/package.json index ef4672bd7..8691b49e9 100644 --- a/package.json +++ b/package.json @@ -61,9 +61,9 @@ "@radix-ui/react-toast": "^1.2.11", "@radix-ui/react-tooltip": "^1.2.4", "@react-email/components": "0.0.38", - "@react-router/express": "^7.8.1", - "@react-router/node": "^7.8.1", - "@react-router/remix-routes-option-adapter": "^7.8.1", + "@react-router/express": "^7.9.0", + "@react-router/node": "^7.9.0", + "@react-router/remix-routes-option-adapter": "^7.9.0", "@remix-run/server-runtime": "^2.16.5", "@sentry/profiling-node": "^9.32.0", "@sentry/react-router": "^9.32.0", @@ -98,7 +98,7 @@ "qrcode": "^1.5.4", "react": "^19.1.0", "react-dom": "^19.1.0", - "react-router": "^7.8.1", + "react-router": "^7.9.0", "remix-auth": "^4.2.0", "remix-auth-github": "^3.0.2", "remix-utils": "^8.5.0", @@ -116,7 +116,7 @@ "@epic-web/config": "^1.20.1", "@faker-js/faker": "^9.7.0", "@playwright/test": "^1.52.0", - "@react-router/dev": "^7.8.1", + "@react-router/dev": "^7.9.0", "@sly-cli/sly": "^2.1.1", "@testing-library/dom": "^10.4.0", "@testing-library/jest-dom": "^6.6.3", diff --git a/prisma/dev.db b/prisma/dev.db new file mode 100644 index 000000000..262f76afb Binary files /dev/null and b/prisma/dev.db differ diff --git a/react-router.config.ts b/react-router.config.ts index 875073799..25a434013 100644 --- a/react-router.config.ts +++ b/react-router.config.ts @@ -11,6 +11,7 @@ export default { future: { unstable_optimizeDeps: true, + v8_middleware: true, }, buildEnd: async ({ viteConfig, reactRouterConfig, buildManifest }) => { diff --git a/server/index.ts b/server/index.ts index b55f22cf1..13c39c847 100644 --- a/server/index.ts +++ b/server/index.ts @@ -9,7 +9,12 @@ 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' +import { + RouterContextProvider, + type ServerBuild, + createContext, +} from 'react-router' +import { serverBuildContext } from './router-context' const MODE = process.env.NODE_ENV ?? 'development' const IS_PROD = MODE === 'production' @@ -28,7 +33,7 @@ const viteDevServer = IS_PROD server: { middlewareMode: true, }, - // We tell Vite we are running a custom app instead of + // We tell Vite we are running a custom app instead of // the SPA default so it doesn't run HTML middleware appType: 'custom', }), @@ -200,7 +205,11 @@ if (!ALLOW_INDEXING) { app.all( '*', createRequestHandler({ - getLoadContext: () => ({ serverBuild: getBuild() }), + getLoadContext: () => { + const ctx = new RouterContextProvider() + ctx.set(serverBuildContext, getBuild()) + return ctx + }, mode: MODE, build: async () => { const { error, build } = await getBuild() diff --git a/server/router-context.ts b/server/router-context.ts new file mode 100644 index 000000000..bec9463d4 --- /dev/null +++ b/server/router-context.ts @@ -0,0 +1,7 @@ +import { createContext, type ServerBuild } from 'react-router' + +// Shared context key for passing the built routes into loaders (e.g. sitemap) +export const serverBuildContext = createContext< + Promise<{ error: unknown; build: ServerBuild }> | null +>(null) + diff --git a/tests/e2e/search.test.ts b/tests/e2e/search.test.ts index 35b23c91f..6efbd95aa 100644 --- a/tests/e2e/search.test.ts +++ b/tests/e2e/search.test.ts @@ -12,11 +12,9 @@ test('Search from home page', async ({ page, navigate, insertNewUser }) => { const userList = page.getByRole('main').getByRole('list') await expect(userList.getByRole('listitem')).toHaveCount(1) await expect( - userList - .getByRole('listitem') - .getByRole('link', { - name: `${newUser.name || newUser.username} profile`, - }), + userList.getByRole('listitem').getByRole('link', { + name: `${newUser.name || newUser.username} profile`, + }), ).toBeVisible() // Search for a non-existing user.