From cc05ff3fc63f5e49eb0f66c63acde05bda116cea Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 8 Jul 2026 00:15:20 +0000 Subject: [PATCH 01/12] Add server-rendered Remix hydration Co-authored-by: Kent C. Dodds --- client/app-root.tsx | 26 +++++++++ client/app.tsx | 49 +++++++++++------ client/client-router.tsx | 88 +++++++++++++++++++++++-------- client/entry.tsx | 33 +++++++++--- client/router-location.tsx | 57 ++++++++++++++++++++ client/routes/account.tsx | 5 +- client/routes/chat.tsx | 13 ++--- client/routes/home.tsx | 4 +- client/routes/index.tsx | 17 +++--- client/routes/login.tsx | 78 ++++++++++++++++----------- client/routes/oauth-authorize.tsx | 31 +++++++---- client/routes/oauth-callback.tsx | 24 ++++++--- client/routes/reset-password.tsx | 22 +++++--- server/handlers/account.ts | 43 +++++++-------- server/handlers/auth-page.ts | 14 +++-- server/handlers/chat.ts | 37 ++++++------- server/handlers/home.ts | 18 ++++--- server/handlers/login.ts | 7 +-- server/handlers/password-reset.ts | 14 +++++ server/handlers/signup.ts | 7 +-- server/router.ts | 37 +++++++------ server/routes.ts | 3 ++ server/ssr-document.tsx | 56 ++++++++++++++++++++ server/ssr-render.test.ts | 45 ++++++++++++++++ server/ssr-render.tsx | 51 ++++++++++++++++++ worker/index.ts | 2 +- worker/oauth-handlers.test.ts | 13 +++-- worker/oauth-handlers.ts | 25 ++++++--- 28 files changed, 614 insertions(+), 205 deletions(-) create mode 100644 client/app-root.tsx create mode 100644 client/router-location.tsx create mode 100644 server/ssr-document.tsx create mode 100644 server/ssr-render.test.ts create mode 100644 server/ssr-render.tsx diff --git a/client/app-root.tsx b/client/app-root.tsx new file mode 100644 index 0000000..29d2e54 --- /dev/null +++ b/client/app-root.tsx @@ -0,0 +1,26 @@ +import { clientEntry, type EntryComponent, type Handle } from 'remix/ui' +import { App } from './app.tsx' +import { RouterLocationProvider } from './router-location.tsx' +import { type SessionInfo } from './session.ts' + +export const APP_ROOT_ENTRY_ID = '/client-entry.js#AppRoot' + +export type AppRootProps = { + url: string + session: SessionInfo | null + notFound?: boolean +} + +export const AppRoot: EntryComponent = clientEntry( + APP_ROOT_ENTRY_ID, + function AppRoot(handle: Handle) { + return () => ( + + + + ) + }, +) diff --git a/client/app.tsx b/client/app.tsx index b415dce..45654b1 100644 --- a/client/app.tsx +++ b/client/app.tsx @@ -1,4 +1,5 @@ import { css, type Handle } from 'remix/ui' +import { routes } from '#server/routes.ts' import { clientRoutes } from './routes/index.tsx' import { getPathname, @@ -12,12 +13,18 @@ import { } from './session.ts' import { buildAuthLink } from './auth-links.ts' import { colors, mq, spacing, typography } from './styles/tokens.ts' -export function App(handle: Handle) { - let session: SessionInfo | null = null - let sessionStatus: SessionStatus = 'idle' + +type AppProps = { + embeddedSession?: SessionInfo | null + notFound?: boolean +} + +export function App(handle: Handle) { + let session: SessionInfo | null = handle.props?.embeddedSession ?? null + let sessionStatus: SessionStatus = 'ready' let sessionRefreshInFlight = false let sessionRefreshQueued = false - let currentPathname = getPathname() + let currentPathname = getPathname(handle) function queueSessionRefresh() { sessionRefreshQueued = true if (sessionRefreshInFlight) return @@ -43,11 +50,13 @@ export function App(handle: Handle) { handle.update() } } - handle.queueTask(() => { - queueSessionRefresh() - }) + if (typeof window !== 'undefined') { + handle.queueTask(() => { + queueSessionRefresh() + }) + } listenToRouterNavigation(handle, () => { - currentPathname = getPathname() + currentPathname = getPathname(handle) queueSessionRefresh() handle.update() }) @@ -85,11 +94,12 @@ export function App(handle: Handle) { const isLoggedIn = isSessionReady && Boolean(sessionEmail) const showAuthLinks = isSessionReady && !isLoggedIn const oauthRedirectTo = - typeof window !== 'undefined' && currentPathname === '/oauth/authorize' + typeof window !== 'undefined' && + currentPathname === routes.oauthAuthorize.href() ? `${currentPathname}${window.location.search}` : null - const loginHref = buildAuthLink('/login', oauthRedirectTo) - const signupHref = buildAuthLink('/signup', oauthRedirectTo) + const loginHref = buildAuthLink(routes.login.href(), oauthRedirectTo) + const signupHref = buildAuthLink(routes.signup.href(), oauthRedirectTo) return (
- + - + Chat - + {sessionEmail} -
+ @@ -169,6 +187,7 @@ export function App(handle: Handle) {

fallback?: JSX.Element + notFound?: boolean } type FormMethod = 'get' | 'post' @@ -56,6 +63,11 @@ type FormSubmitDetails = { } export const routerEvents = new EventTarget() +const clientRouteOrigin = 'https://epicflare.local' +const routeMatchers = new WeakMap< + Record, + ReturnType +>() let routerInitialized = false function notify() { @@ -73,28 +85,30 @@ function isSameOriginUrl(url: URL) { return url.origin === window.location.origin } -function compileRoutePattern(pattern: string) { - const regexPattern = pattern - .replace(/:([^/]+)/g, '([^/]+)') - .replace(/\*/g, '.*') - - return { - pattern: new RegExp(`^${regexPattern}$`), +function createRouteMatcher(routes: Record) { + const matcher = createMultiMatcher() + for (const [pattern, routeElement] of Object.entries(routes)) { + matcher.add(pattern, routeElement) } + return matcher } -function matchRoute( +function getRouteMatcher(routes: Record) { + const existing = routeMatchers.get(routes) + if (existing) return existing + const matcher = createRouteMatcher(routes) + routeMatchers.set(routes, matcher) + return matcher +} + +export function matchRoute( path: string, routes: Record, ): JSX.Element | null { - for (const [pattern, routeElement] of Object.entries(routes)) { - const { pattern: compiled } = compileRoutePattern(pattern) - const result = compiled.exec(path) - if (!result) continue - return routeElement - } - - return null + return ( + getRouteMatcher(routes).match(new URL(path, clientRouteOrigin))?.data ?? + null + ) } function shouldHandleClick(event: MouseEvent, anchor: HTMLAnchorElement) { @@ -367,13 +381,21 @@ export function listenToRouterNavigation( handle: Handle, listener: () => void, ) { + if (typeof document === 'undefined') return ensureRouter() addEventListeners(routerEvents, handle.signal, { navigate: () => listener(), }) } -export function getPathname() { +export function getPathname(handle?: Pick) { + if (handle) { + try { + return readRouterPathname(handle) + } catch { + // Router location context is unavailable outside the app tree. + } + } if (typeof window === 'undefined') return '/' return window.location.pathname } @@ -404,13 +426,35 @@ export function navigate(to: string) { notify() } -export function Router(handle: Handle) { - listenToRouterNavigation(handle, () => { - void handle.update() - }) +type RouterHandle = Pick & { + props: RouterSetup +} + +function normalizeHref(href: string) { + const url = new URL(href, clientRouteOrigin) + return `${url.pathname}${url.search}${url.hash}` +} + +function isOnSsrUrl(handle: Pick) { + return ( + normalizeHref(readRouterUrl(handle)) === + normalizeHref(readSsrRouterUrl(handle)) + ) +} + +export function Router(handle: RouterHandle) { + if (typeof document !== 'undefined') { + listenToRouterNavigation(handle as Handle, () => { + void handle.update() + }) + } return () => { - const path = getPathname() + if (handle.props.notFound && isOnSsrUrl(handle)) { + return handle.props.fallback ?? null + } + + const path = getPathname(handle) const routeElement = matchRoute(path, handle.props.routes) if (routeElement) return routeElement return handle.props.fallback ?? null diff --git a/client/entry.tsx b/client/entry.tsx index 3cd4ede..5e18215 100644 --- a/client/entry.tsx +++ b/client/entry.tsx @@ -1,9 +1,28 @@ -import { createRoot } from 'remix/ui' -import { App } from './app.tsx' +import { run } from 'remix/ui' +import { APP_ROOT_ENTRY_ID, AppRoot } from './app-root.tsx' -const rootElement = document.getElementById('root') ?? document.body -if (rootElement.childNodes.length > 0) { - // Remix alpha.3 auto-hydrates non-empty containers. We render from scratch. - rootElement.replaceChildren() +const clientRegistry: Record = { + AppRoot, } -createRoot(rootElement).render() + +const app = run({ + loadModule(moduleUrl, exportName) { + const expectedHref = APP_ROOT_ENTRY_ID.split('#')[0] + if (moduleUrl !== expectedHref) { + throw new Error(`Unknown client module URL: ${moduleUrl}`) + } + + const component = clientRegistry[exportName] + if (!component) { + throw new Error(`Unknown client export: ${exportName}`) + } + + return component + }, +}) + +app.addEventListener('error', (event) => { + console.error('Client hydration error:', event.error) +}) + +void app.ready() diff --git a/client/router-location.tsx b/client/router-location.tsx new file mode 100644 index 0000000..756c9da --- /dev/null +++ b/client/router-location.tsx @@ -0,0 +1,57 @@ +import { addEventListeners, type Handle, type RemixNode } from 'remix/ui' +import { routerEvents } from './client-router.tsx' + +export type RouterLocationValue = { + url: string + ssrUrl: string +} + +function getClientUrl() { + if (typeof window === 'undefined') return '/' + return `${window.location.pathname}${window.location.search}${window.location.hash}` +} + +export function RouterLocationProvider( + handle: Handle<{ url: string; children?: RemixNode }, RouterLocationValue>, +) { + const ssrUrl = handle.props.url + let currentUrl = ssrUrl + + if (typeof window !== 'undefined') { + handle.queueTask(() => { + currentUrl = getClientUrl() + handle.context.set({ url: currentUrl, ssrUrl }) + handle.update() + }) + + addEventListeners(routerEvents, handle.signal, { + navigate() { + const nextUrl = getClientUrl() + if (nextUrl === currentUrl) return + currentUrl = nextUrl + handle.context.set({ url: currentUrl, ssrUrl }) + handle.update() + }, + }) + } + + handle.context.set({ url: currentUrl, ssrUrl }) + + return () => handle.props.children +} + +export function readRouterUrl(handle: Pick) { + return handle.context.get(RouterLocationProvider).url +} + +export function readSsrRouterUrl(handle: Pick) { + return handle.context.get(RouterLocationProvider).ssrUrl +} + +export function readRouterPathname(handle: Pick) { + return new URL(readRouterUrl(handle), 'https://epicflare.local').pathname +} + +export function readRouterSearch(handle: Pick) { + return new URL(readRouterUrl(handle), 'https://epicflare.local').search +} diff --git a/client/routes/account.tsx b/client/routes/account.tsx index e49ae70..6b07450 100644 --- a/client/routes/account.tsx +++ b/client/routes/account.tsx @@ -1,5 +1,6 @@ import { css, type Handle } from 'remix/ui' import { colors, spacing, typography } from '#client/styles/tokens.ts' +import { routes } from '#server/routes.ts' type AccountStatus = 'idle' | 'loading' | 'ready' | 'error' export function AccountRoute(handle: Handle) { let status: AccountStatus = 'loading' @@ -21,7 +22,7 @@ export function AccountRoute(handle: Handle) { ? payload.session.email.trim() : '' if (!sessionEmail) { - window.location.assign('/login') + window.location.assign(routes.login.href()) return } email = sessionEmail @@ -36,7 +37,7 @@ export function AccountRoute(handle: Handle) { } } return () => { - if (status === 'loading') { + if (typeof window !== 'undefined' && status === 'loading') { handle.queueTask(loadAccount) } return ( diff --git a/client/routes/chat.tsx b/client/routes/chat.tsx index ea5ebcd..dbb01eb 100644 --- a/client/routes/chat.tsx +++ b/client/routes/chat.tsx @@ -14,6 +14,7 @@ import { scrollToEdge, } from '#client/scroll-container.ts' import { createSpinDelay } from '#client/spin-delay.ts' +import { routes } from '#server/routes.ts' import { breakpoints, colors, @@ -33,13 +34,13 @@ import { type ThreadStatus = 'idle' | 'loading' | 'ready' | 'error' function getSelectedThreadIdFromLocation() { if (typeof window === 'undefined') return null - const prefix = '/chat/' + const prefix = `${routes.chat.href()}/` if (!window.location.pathname.startsWith(prefix)) return null const threadId = window.location.pathname.slice(prefix.length).trim() return threadId || null } function buildThreadHref(threadId: string) { - return `/chat/${threadId}` + return routes.chatThread.href({ threadId }) } function isMobileViewport() { return ( @@ -655,7 +656,7 @@ export function ChatRoute(handle: Handle) { setMessageScrollFades(false, false) update() if (locationThreadId) { - navigate('/chat') + navigate(routes.chat.href()) } return } @@ -792,7 +793,7 @@ export function ChatRoute(handle: Handle) { navigate(buildThreadHref(nextThread.id)) await connectThread(nextThread.id) } else { - navigate('/chat') + navigate(routes.chat.href()) } } catch (error) { actionError = @@ -865,7 +866,7 @@ export function ChatRoute(handle: Handle) { } } return () => { - if (threadStatus === 'loading') { + if (typeof window !== 'undefined' && threadStatus === 'loading') { handle.queueTask(refreshThreads) } const threads = getThreads() @@ -1291,7 +1292,7 @@ export function ChatRoute(handle: Handle) { ]} > Remix 3

- Remix 3 components running on the client, backed by Remix 3 - routing in the worker. + Remix 3 components server-rendered by the worker, then hydrated + for client navigation.

diff --git a/client/routes/index.tsx b/client/routes/index.tsx index f0c7aa8..7b6b327 100644 --- a/client/routes/index.tsx +++ b/client/routes/index.tsx @@ -1,3 +1,4 @@ +import { routes } from '#server/routes.ts' import { AccountRoute } from './account.tsx' import { ChatRoute } from './chat.tsx' import { HomeRoute } from './home.tsx' @@ -7,13 +8,13 @@ import { OAuthCallbackRoute } from './oauth-callback.tsx' import { ResetPasswordRoute } from './reset-password.tsx' export const clientRoutes = { - '/': , - '/chat': , + [routes.home.href()]: , + [routes.chat.href()]: , '/chat/:threadId': , - '/account': , - '/login': , - '/signup': , - '/reset-password': , - '/oauth/authorize': , - '/oauth/callback': , + [routes.account.href()]: , + [routes.login.href()]: , + [routes.signup.href()]: , + [routes.resetPassword.href()]: , + [routes.oauthAuthorize.href()]: , + [routes.oauthCallback.href()]: , } diff --git a/client/routes/login.tsx b/client/routes/login.tsx index ed312b9..24b2b16 100644 --- a/client/routes/login.tsx +++ b/client/routes/login.tsx @@ -4,6 +4,8 @@ import { getPathname, listenToRouterNavigation, } from '#client/client-router.tsx' +import { readRouterSearch } from '#client/router-location.tsx' +import { routes } from '#server/routes.ts' import { fetchSessionInfo, type SessionStatus } from '#client/session.ts' import { colors, @@ -15,10 +17,16 @@ import { } from '#client/styles/tokens.ts' type AuthMode = 'login' | 'signup' type AuthStatus = 'idle' | 'submitting' | 'success' | 'error' -function getSearchParams() { - return typeof window === 'undefined' - ? new URLSearchParams() - : new URLSearchParams(window.location.search) +function getSearchParams(handle: Handle) { + if (typeof window !== 'undefined') { + return new URLSearchParams(window.location.search) + } + + try { + return new URLSearchParams(readRouterSearch(handle)) + } catch { + return new URLSearchParams() + } } function normalizeRedirectTo(value: string | null) { if (!value) return null @@ -27,24 +35,24 @@ function normalizeRedirectTo(value: string | null) { return value } function buildAuthPath(mode: AuthMode, redirectTo: string | null) { - const path = mode === 'signup' ? '/signup' : '/login' + const path = mode === 'signup' ? routes.signup.href() : routes.login.href() return buildAuthLink(path, redirectTo) } function getAuthModeFromPathname(pathname: string): AuthMode { - return pathname === '/signup' ? 'signup' : 'login' + return pathname === routes.signup.href() ? 'signup' : 'login' } -function getCurrentAuthMode() { - return getAuthModeFromPathname(getPathname()) +function getCurrentAuthMode(handle: Handle) { + return getAuthModeFromPathname(getPathname(handle)) } -function getCurrentRedirectTo() { - return normalizeRedirectTo(getSearchParams().get('redirectTo')) +function getCurrentRedirectTo(handle: Handle) { + return normalizeRedirectTo(getSearchParams(handle).get('redirectTo')) } export function LoginRoute(handle: Handle) { let status: AuthStatus = 'idle' let message: string | null = null let sessionStatus: SessionStatus = 'idle' let sessionEmail = '' - let activeMode = getCurrentAuthMode() + let activeMode = getCurrentAuthMode(handle) let routePath: string | null = null function setState(nextStatus: AuthStatus, nextMessage: string | null = null) { status = nextStatus @@ -57,30 +65,34 @@ export function LoginRoute(handle: Handle) { } listenToRouterNavigation(handle, () => { if (!routePath) return - if (getPathname() !== routePath) { + if (getPathname(handle) !== routePath) { resetAuthState() } }) - handle.queueTask(async (signal) => { - if (sessionStatus !== 'idle') return - sessionStatus = 'loading' - const session = await fetchSessionInfo(signal) - if (signal.aborted) return - sessionEmail = session?.email ?? '' - sessionStatus = 'ready' - if (sessionEmail && typeof window !== 'undefined') { - window.location.assign(getCurrentRedirectTo() ?? '/account') - return - } - handle.update() - }) + if (typeof window !== 'undefined') { + handle.queueTask(async (signal) => { + if (sessionStatus !== 'idle') return + sessionStatus = 'loading' + const session = await fetchSessionInfo(signal) + if (signal.aborted) return + sessionEmail = session?.email ?? '' + sessionStatus = 'ready' + if (sessionEmail) { + window.location.assign( + getCurrentRedirectTo(handle) ?? routes.account.href(), + ) + return + } + handle.update() + }) + } async function handleSubmit(event: SubmitEvent) { event.preventDefault() if (!(event.currentTarget instanceof HTMLFormElement)) return const formData = new FormData(event.currentTarget) const email = String(formData.get('email') ?? '').trim() const password = String(formData.get('password') ?? '') - const mode = getCurrentAuthMode() + const mode = getCurrentAuthMode(handle) const rememberMe = mode === 'login' && formData.get('rememberMe') === 'on' if (!email || !password) { setState('error', 'Email and password are required.') @@ -104,22 +116,24 @@ export function LoginRoute(handle: Handle) { return } if (typeof window !== 'undefined') { - window.location.assign(getCurrentRedirectTo() ?? '/account') + window.location.assign( + getCurrentRedirectTo(handle) ?? routes.account.href(), + ) } } catch { setState('error', 'Network error. Please try again.') } } return () => { - const mode = getCurrentAuthMode() + const mode = getCurrentAuthMode(handle) if (!routePath) { - routePath = getPathname() + routePath = getPathname(handle) } if (mode !== activeMode) { activeMode = mode resetAuthState() } - const redirectTo = getCurrentRedirectTo() + const redirectTo = getCurrentRedirectTo(handle) const isSignup = mode === 'signup' const isSubmitting = status === 'submitting' const title = isSignup ? 'Create your account' : 'Welcome back' @@ -344,7 +358,7 @@ export function LoginRoute(handle: Handle) {
{!isSignup ? ( ) : null} { - const currentSearch = - typeof window === 'undefined' ? '' : window.location.search - if (currentSearch !== lastSearch) { + const currentSearch = getSearch(handle) + if (typeof window !== 'undefined' && currentSearch !== lastSearch) { lastSearch = currentSearch void loadInfo() } - if (sessionStatus === 'idle') { + if (typeof window !== 'undefined' && sessionStatus === 'idle') { void loadSession() } const clientLabel = info?.client?.name ?? 'Unknown client' @@ -422,7 +431,7 @@ export function OAuthAuthorizeRoute(handle: Handle) { { - const params = - typeof window === 'undefined' - ? new URLSearchParams() - : new URLSearchParams(window.location.search) + const params = getSearchParams(handle) const error = params.get('error') const description = params.get('error_description') const code = params.get('code') @@ -61,7 +73,7 @@ export function OAuthCallbackRoute(_handle: Handle) {

) : null}
{ - const searchParams = getSearchParams() + const searchParams = getSearchParams(handle) const token = String(searchParams.get('token') ?? '').trim() const mode = token ? 'confirm' : 'request' const isSubmitting = status === 'submitting' @@ -242,7 +250,7 @@ export function ResetPasswordRoute(handle: Handle) {
+ }) + }, + } satisfies Action +} diff --git a/server/handlers/auth-page.ts b/server/handlers/auth-page.ts index 38c2a62..da507a3 100644 --- a/server/handlers/auth-page.ts +++ b/server/handlers/auth-page.ts @@ -1,6 +1,9 @@ -import { readAuthSessionResult } from '#server/auth-session.ts' -import { Layout } from '#server/layout.ts' -import { render } from '#server/render.ts' +import { + readAuthSessionResult, + setAuthSessionSecret, +} from '#server/auth-session.ts' +import { renderAppPage } from '#server/ssr-render.tsx' +import { type AppEnv } from '#types/env-schema.ts' function normalizeRedirectTo(value: string | null) { if (!value) return null @@ -9,10 +12,11 @@ function normalizeRedirectTo(value: string | null) { return value } -export function createAuthPageHandler() { +export function createAuthPageHandler(appEnv: AppEnv) { return { middleware: [], async handler({ request }: { request: Request }) { + setAuthSessionSecret(appEnv.COOKIE_SECRET) const { session, setCookie } = await readAuthSessionResult(request) if (session) { const url = new URL(request.url) @@ -34,7 +38,7 @@ export function createAuthPageHandler() { return Response.redirect(redirectUrl, 302) } - return render(Layout({})) + return renderAppPage({ request, appEnv }) }, } } diff --git a/server/handlers/chat.ts b/server/handlers/chat.ts index a4c41ff..3198f0d 100644 --- a/server/handlers/chat.ts +++ b/server/handlers/chat.ts @@ -1,24 +1,25 @@ import { type Action } from 'remix/router' -import { readAuthSessionResult } from '#server/auth-session.ts' +import { type AppEnv } from '#types/env-schema.ts' +import { + readAuthSessionResult, + setAuthSessionSecret, +} from '#server/auth-session.ts' import { redirectToLogin } from '#server/auth-redirect.ts' -import { Layout } from '#server/layout.ts' -import { render } from '#server/render.ts' import { type routes } from '#server/routes.ts' +import { renderAppPage } from '#server/ssr-render.tsx' -export const chat = { - middleware: [], - async handler({ request }) { - const { session, setCookie } = await readAuthSessionResult(request) +export function createChatHandler(appEnv: AppEnv) { + return { + middleware: [], + async handler({ request }) { + setAuthSessionSecret(appEnv.COOKIE_SECRET) + const { session } = await readAuthSessionResult(request) - if (!session) { - return redirectToLogin(request) - } + if (!session) { + return redirectToLogin(request) + } - const response = render(Layout({})) - if (setCookie) { - response.headers.set('Set-Cookie', setCookie) - } - - return response - }, -} satisfies Action + return renderAppPage({ request, appEnv, title: 'Chat' }) + }, + } satisfies Action +} diff --git a/server/handlers/home.ts b/server/handlers/home.ts index 968518b..954b04f 100644 --- a/server/handlers/home.ts +++ b/server/handlers/home.ts @@ -1,11 +1,13 @@ import { type Action } from 'remix/router' -import { Layout } from '#server/layout.ts' -import { render } from '#server/render.ts' +import { type AppEnv } from '#types/env-schema.ts' import { type routes } from '#server/routes.ts' +import { renderAppPage } from '#server/ssr-render.tsx' -export const home = { - middleware: [], - async handler() { - return render(Layout({})) - }, -} satisfies Action +export function createHomeHandler(appEnv: AppEnv) { + return { + middleware: [], + async handler({ request }) { + return renderAppPage({ request, appEnv }) + }, + } satisfies Action +} diff --git a/server/handlers/login.ts b/server/handlers/login.ts index f01c5ee..f01333f 100644 --- a/server/handlers/login.ts +++ b/server/handlers/login.ts @@ -1,7 +1,8 @@ import { type Action } from 'remix/router' +import { type AppEnv } from '#types/env-schema.ts' import { type routes } from '#server/routes.ts' import { createAuthPageHandler } from './auth-page.ts' -export const login = createAuthPageHandler() satisfies Action< - typeof routes.login -> +export function createLoginHandler(appEnv: AppEnv) { + return createAuthPageHandler(appEnv) satisfies Action +} diff --git a/server/handlers/password-reset.ts b/server/handlers/password-reset.ts index 5921840..f4c69e7 100644 --- a/server/handlers/password-reset.ts +++ b/server/handlers/password-reset.ts @@ -8,10 +8,24 @@ import { toHex } from '#server/hex.ts' import { normalizeEmail } from '#server/normalize-email.ts' import { createPasswordHash } from '#server/password-hash.ts' import { type routes } from '#server/routes.ts' +import { renderAppPage } from '#server/ssr-render.tsx' const resetTokenBytes = 32 const resetTokenExpiryMs = 60 * 60 * 1000 +export function createResetPasswordPageHandler(appEnv: AppEnv) { + return { + middleware: [], + async handler({ request }) { + return renderAppPage({ + request, + appEnv, + title: 'Reset password', + }) + }, + } satisfies Action +} + const resetRequestSchema = object({ email: string(), }) diff --git a/server/handlers/signup.ts b/server/handlers/signup.ts index 4e971e0..3f361c6 100644 --- a/server/handlers/signup.ts +++ b/server/handlers/signup.ts @@ -1,7 +1,8 @@ import { type Action } from 'remix/router' +import { type AppEnv } from '#types/env-schema.ts' import { type routes } from '#server/routes.ts' import { createAuthPageHandler } from './auth-page.ts' -export const signup = createAuthPageHandler() satisfies Action< - typeof routes.signup -> +export function createSignupHandler(appEnv: AppEnv) { + return createAuthPageHandler(appEnv) satisfies Action +} diff --git a/server/router.ts b/server/router.ts index 52cf054..dfac987 100644 --- a/server/router.ts +++ b/server/router.ts @@ -1,50 +1,57 @@ import { createRouter } from 'remix/router' import { type AppEnv } from '#types/env-schema.ts' -import { account } from './handlers/account.ts' +import { createAccountHandler } from './handlers/account.ts' import { createAuthHandler } from './handlers/auth.ts' -import { chat } from './handlers/chat.ts' +import { createChatHandler } from './handlers/chat.ts' import { createChatThreadsHandler, createDeleteChatThreadHandler, createUpdateChatThreadHandler, } from './handlers/chat-threads.ts' import { createHealthHandler } from './handlers/health.ts' -import { home } from './handlers/home.ts' -import { login } from './handlers/login.ts' +import { createHomeHandler } from './handlers/home.ts' +import { createLoginHandler } from './handlers/login.ts' import { logout } from './handlers/logout.ts' import { createPasswordResetConfirmHandler, + createResetPasswordPageHandler, createPasswordResetRequestHandler, } from './handlers/password-reset.ts' import { session } from './handlers/session.ts' -import { signup } from './handlers/signup.ts' -import { Layout } from './layout.ts' -import { render } from './render.ts' +import { createSignupHandler } from './handlers/signup.ts' +import { renderAppPage } from './ssr-render.tsx' import { routes } from './routes.ts' export function createAppRouter(appEnv: AppEnv) { const router = createRouter({ middleware: [], - async defaultHandler() { - return render(Layout({})) + async defaultHandler({ request }) { + return renderAppPage({ + request, + appEnv, + title: 'Not Found', + notFound: true, + status: 404, + }) }, }) const chatThreadsHandler = createChatThreadsHandler(appEnv) - router.map(routes.home, home) - router.map(routes.chat, chat) - router.map(routes.chatThread, chat) + router.map(routes.home, createHomeHandler(appEnv)) + router.map(routes.chat, createChatHandler(appEnv)) + router.map(routes.chatThread, createChatHandler(appEnv)) router.map(routes.chatThreads, chatThreadsHandler) router.map(routes.chatThreadsCreate, chatThreadsHandler) router.map(routes.chatThreadsUpdate, createUpdateChatThreadHandler(appEnv)) router.map(routes.chatThreadsDelete, createDeleteChatThreadHandler(appEnv)) router.map(routes.health, createHealthHandler(appEnv)) - router.map(routes.login, login) - router.map(routes.signup, signup) - router.map(routes.account, account) + router.map(routes.login, createLoginHandler(appEnv)) + router.map(routes.signup, createSignupHandler(appEnv)) + router.map(routes.account, createAccountHandler(appEnv)) router.map(routes.auth, createAuthHandler(appEnv)) router.map(routes.session, session) router.map(routes.logout, logout) + router.map(routes.resetPassword, createResetPasswordPageHandler(appEnv)) router.map( routes.passwordResetRequest, createPasswordResetRequestHandler(appEnv), diff --git a/server/routes.ts b/server/routes.ts index 26646d8..2f10947 100644 --- a/server/routes.ts +++ b/server/routes.ts @@ -12,6 +12,9 @@ export const routes = route({ login: '/login', signup: '/signup', account: '/account', + resetPassword: '/reset-password', + oauthAuthorize: '/oauth/authorize', + oauthCallback: '/oauth/callback', auth: post('/auth'), session: '/session', logout: post('/logout'), diff --git a/server/ssr-document.tsx b/server/ssr-document.tsx new file mode 100644 index 0000000..52d502d --- /dev/null +++ b/server/ssr-document.tsx @@ -0,0 +1,56 @@ +import { type Handle } from 'remix/ui' +import { + APP_ROOT_ENTRY_ID, + AppRoot, + type AppRootProps, +} from '#client/app-root.tsx' + +export type SsrDocumentProps = AppRootProps & { + title?: string +} + +const clientEntryHref = APP_ROOT_ENTRY_ID.split('#')[0] ?? '/client-entry.js' + +export function SsrDocument(handle: Handle) { + return () => ( + + + + + + + + + + + {handle.props.title ?? 'epicflare'} + + + + +
+ +
+ + + + ) +} diff --git a/server/ssr-render.test.ts b/server/ssr-render.test.ts new file mode 100644 index 0000000..3a5240a --- /dev/null +++ b/server/ssr-render.test.ts @@ -0,0 +1,45 @@ +/// +import { expect, test } from 'vitest' +import { type AppEnv } from '#types/env-schema.ts' +import { renderAppPage } from './ssr-render.tsx' + +const testAppEnv = { + COOKIE_SECRET: 'test-cookie-secret-0123456789abcdef0123456789', + APP_DB: {} as D1Database, +} as AppEnv + +test('renderAppPage server-renders route content and hydration metadata', async () => { + const response = await renderAppPage({ + request: new Request('https://example.com/reset-password?token=abc123'), + appEnv: testAppEnv, + title: 'Reset password', + }) + + expect(response.status).toBe(200) + expect(response.headers.get('Content-Type')).toContain('text/html') + expect(response.headers.get('Cache-Control')).toBe('no-store') + + const html = await response.text() + expect(html).toContain('') + expect(html).not.toContain('loading-spinner') +}) + +test('renderAppPage preserves 404 state in the server-rendered document', async () => { + const response = await renderAppPage({ + request: new Request('https://example.com/missing'), + appEnv: testAppEnv, + title: 'Not Found', + notFound: true, + status: 404, + }) + + expect(response.status).toBe(404) + + const html = await response.text() + expect(html).toContain('Not Found') + expect(html).toContain('"notFound":true') +}) diff --git a/server/ssr-render.tsx b/server/ssr-render.tsx new file mode 100644 index 0000000..74e7ca7 --- /dev/null +++ b/server/ssr-render.tsx @@ -0,0 +1,51 @@ +import { renderToStream } from 'remix/ui/server' +import { type AppEnv } from '#types/env-schema.ts' +import { readAuthSessionResult, setAuthSessionSecret } from './auth-session.ts' +import { SsrDocument } from './ssr-document.tsx' + +export type RenderAppPageInput = { + request: Request + appEnv: AppEnv + title?: string + notFound?: boolean + status?: number +} + +function getRequestUrl(request: Request) { + const url = new URL(request.url) + return `${url.pathname}${url.search}` +} + +export async function renderAppPage(input: RenderAppPageInput) { + const { request, appEnv, title, notFound, status } = input + + setAuthSessionSecret(appEnv.COOKIE_SECRET) + const { session, setCookie } = await readAuthSessionResult(request) + const stream = renderToStream( + , + { + frameSrc: request.url, + onError(error) { + console.error('SSR render error:', error) + }, + }, + ) + + const headers = new Headers({ + 'Cache-Control': 'no-store', + 'Content-Type': 'text/html; charset=utf-8', + }) + if (setCookie) { + headers.append('Set-Cookie', setCookie) + } + + return new Response(stream, { + status: status ?? 200, + headers, + }) +} diff --git a/worker/index.ts b/worker/index.ts index 3d9d7c2..8e2b26c 100644 --- a/worker/index.ts +++ b/worker/index.ts @@ -47,7 +47,7 @@ const appHandler = withCors({ } if (url.pathname === oauthPaths.callback) { - return handleOAuthCallback(request) + return handleOAuthCallback(request, env) } if (url.pathname === '/.well-known/appspecific/com.chrome.devtools.json') { diff --git a/worker/oauth-handlers.test.ts b/worker/oauth-handlers.test.ts index 974ea37..c067cb2 100644 --- a/worker/oauth-handlers.test.ts +++ b/worker/oauth-handlers.test.ts @@ -107,7 +107,7 @@ function createFormRequest( }) } -test('authorize page returns SPA shell', async () => { +test('authorize page returns server-rendered page', async () => { const response = await handleAuthorizeRequest( new Request('https://example.com/oauth/authorize'), createEnv(createHelpers()), @@ -116,7 +116,8 @@ test('authorize page returns SPA shell', async () => { expect(response.status).toBe(200) const body = await response.text() expect(body).toContain('client-entry.js') - expect(body).toContain('app-shell') + expect(body).toContain('Authorize access') + expect(body).toContain('