Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 23 additions & 6 deletions server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import * as _schemas from '~/api/schemas'
import * as _uploads from '~/api/uploads'
import * as _versions from '~/api/versions'
import { auth } from '~/lib/auth'
import { getSessionUser } from '~/lib/auth.server'
import { getMirrorConfig } from '~/lib/mirror-config'

const isProd = process.env.NODE_ENV === 'production'
Expand Down Expand Up @@ -230,6 +231,18 @@ app.get('/api/blog/:slug', (c) => {
return c.html(typeof html === 'string' ? html : '')
})

// --- App context (consumed by root route loader) ---
app.get('/api/context', async (c) => {
const user = await getSessionUser(c.req.raw)
const config = getMirrorConfig()
return c.json({
currentUser: user,
mirrorConfig: config,
kfAccountUrl: process.env.OIDC_ACCOUNT_URL ?? 'http://localhost:3001',
kfAuthUrl: process.env.OIDC_ISSUER_URL ?? 'http://localhost:3000',
})
})

// API 404 catch-all
app.all('/api/*', (c) => {
return c.json({ error: 'API route not found', statusCode: 404 }, 404)
Expand All @@ -256,17 +269,19 @@ if (isProd) {
const { render } = await import(ssrBundle as string)

app.get('*', async (c) => {
const { html, ssrData, redirect, statusCode, title, description } = await render(c.req.raw)
const { html, hydrationData, redirect, statusCode, title, description } = await render(
c.req.raw,
)

if (redirect) {
return c.redirect(redirect, 302)
return c.redirect(redirect, statusCode ?? 302)
}

let page = template
.replace('<!--ssr-outlet-->', html)
.replace(
'<!--ssr-data-->',
`<script>window.__SSR_DATA__=${JSON.stringify(ssrData).replace(/</g, '\\u003c')}</script>`,
`<script>window.__staticRouterHydrationData=${hydrationData}</script>`,
)

if (title) {
Expand Down Expand Up @@ -305,17 +320,19 @@ if (isProd) {
template = await vite!.transformIndexHtml(url, template)

const { render } = await vite!.ssrLoadModule('/src/entry-server.tsx')
const { html, ssrData, redirect, statusCode, title, description } = await render(c.req.raw)
const { html, hydrationData, redirect, statusCode, title, description } = await render(
c.req.raw,
)

if (redirect) {
return c.redirect(redirect, 302)
return c.redirect(redirect, statusCode ?? 302)
}

let page = template
.replace('<!--ssr-outlet-->', html)
.replace(
'<!--ssr-data-->',
`<script>window.__SSR_DATA__=${JSON.stringify(ssrData).replace(/</g, '\\u003c')}</script>`,
`<script>window.__staticRouterHydrationData=${hydrationData}</script>`,
)

if (title) {
Expand Down
63 changes: 41 additions & 22 deletions src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,27 +1,46 @@
import { lazy, Suspense } from 'react'
import { Route, Routes } from 'react-router'
import type { RouteObject } from 'react-router'

import { AppErrorBoundary } from '~/components/NotFound'
import { buildRoutes } from '~/route-gen'
import Root from '~/components/Root'
import type { AppContext } from '~/lib/app-context'
import { buildDataRoutes } from '~/route-gen'

const modules = import.meta.glob<{ default: React.ComponentType }>('./routes/**/[!_]*.tsx')
const routes = buildRoutes(modules)
const modules = import.meta.glob<{ default: React.ComponentType; handle?: unknown }>(
'./routes/**/[!_]*.tsx',
)

const componentMap = new Map(routes.map((r) => [r.path, lazy(modules[r.filePath]!)]))

export { routes }
const EMPTY_CONTEXT: AppContext = {
currentUser: null,
mirrorConfig: { enabled: false, upstream: '', nodeName: '', syncSchedule: '', apiKey: '' },
kfAccountUrl: '',
kfAuthUrl: '',
}

export default function App() {
return (
<AppErrorBoundary>
<Suspense>
<Routes>
{routes.map((r) => {
const Page = componentMap.get(r.path)
return Page ? <Route key={r.path} path={r.path} element={<Page />} /> : null
})}
</Routes>
</Suspense>
</AppErrorBoundary>
)
async function rootLoader({
request,
context,
}: {
request: Request
context?: { loadAppContext: (req: Request) => Promise<AppContext> }
}): Promise<AppContext> {
// SSR: server passes loadAppContext via requestContext — direct DB access, no HTTP
if (context?.loadAppContext) {
return context.loadAppContext(request)
}
// Client navigation: fetch from server
const res = await fetch(new URL('/api/context', request.url), {
headers: { Cookie: request.headers.get('Cookie') ?? '' },
})
if (!res.ok) return EMPTY_CONTEXT
return res.json()
}

const NotFound = () => import('~/routes/404').then((m) => ({ Component: m.default }))

export const routes: RouteObject[] = [
{
id: 'root',
Component: Root,
loader: rootLoader,
children: [...buildDataRoutes(modules), { path: '*', lazy: NotFound }],
},
]
11 changes: 2 additions & 9 deletions src/components/BaseLayout.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,10 @@
import { Link } from 'react-router'

import UserMenu from '~/components/UserMenu'
import { useSSRData } from '~/lib/ssr-data'

interface MirrorConfig {
enabled: boolean
nodeName: string
upstream: string
}
import { useAppContext } from '~/lib/app-context'

export default function BaseLayout({ children }: { children: React.ReactNode }) {
const currentUser = useSSRData<any>('currentUser')
const mirrorConfig = useSSRData<MirrorConfig>('mirrorConfig')
const { currentUser, mirrorConfig } = useAppContext()

return (
<>
Expand Down
11 changes: 11 additions & 0 deletions src/components/Root.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { Outlet } from 'react-router'

import { AppErrorBoundary } from '~/components/NotFound'

export default function Root() {
return (
<AppErrorBoundary>
<Outlet />
</AppErrorBoundary>
)
}
44 changes: 32 additions & 12 deletions src/entry-client.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,38 @@
import { hydrateRoot } from 'react-dom/client'
import { BrowserRouter } from 'react-router'
import { createBrowserRouter, matchRoutes, RouterProvider } from 'react-router'

import App from '~/App'
import { getClientSSRData, SSRDataProvider } from '~/lib/ssr-data'
import { routes } from '~/App'
import { extractRouteMeta } from '~/lib/route-meta'

import '~/global.css'

const ssrData = getClientSSRData()
// Resolve matched lazy routes before hydrating to prevent server/client mismatch.
// On the server, createStaticHandler.query() resolves lazy routes before rendering.
// The client must do the same before hydrateRoot, otherwise RouterProvider renders
// null while lazy modules load, causing React to see a mismatch and double-render.
const matched = matchRoutes(routes, window.location)
if (matched) {
await Promise.all(
matched.map(async (m) => {
if (m.route.lazy && typeof m.route.lazy === 'function') {
const resolved = await (m.route.lazy as () => Promise<Record<string, unknown>>)()
Object.assign(m.route, resolved)
delete m.route.lazy
}
}),
)
}

hydrateRoot(
document.getElementById('root')!,
<BrowserRouter>
<SSRDataProvider data={ssrData}>
<App />
</SSRDataProvider>
</BrowserRouter>,
)
const hydrationData = (window as any).__staticRouterHydrationData

const router = createBrowserRouter(routes, { hydrationData })

router.subscribe((state) => {
const { title } = extractRouteMeta(
state.matches as Array<{ params: Record<string, string>; route: { handle?: unknown } }>,
(state as any).loaderData,
)
if (title) document.title = title
})

hydrateRoot(document.getElementById('root')!, <RouterProvider router={router} />)
128 changes: 61 additions & 67 deletions src/entry-server.tsx
Original file line number Diff line number Diff line change
@@ -1,105 +1,99 @@
import { PassThrough } from 'node:stream'

import { renderToPipeableStream } from 'react-dom/server'
import { StaticRouter } from 'react-router'
import { createStaticHandler, createStaticRouter, StaticRouterProvider } from 'react-router'

import App, { routes } from '~/App'
import { SSRDataProvider } from '~/lib/ssr-data'
import { runLoaders } from '~/loaders.server'
import { routes } from '~/App'
import type { AppContext } from '~/lib/app-context'
import { getSessionUser } from '~/lib/auth.server'
import { getMirrorConfig } from '~/lib/mirror-config'
import { extractRouteMeta } from '~/lib/route-meta'

function matchPath(pattern: string, pathname: string): Record<string, string> | null {
const patternParts = pattern.split('/').filter(Boolean)
const pathParts = pathname.split('/').filter(Boolean)
const handler = createStaticHandler(routes)

if (patternParts.length !== pathParts.length) return null

const params: Record<string, string> = {}
for (let i = 0; i < patternParts.length; i++) {
const pat = patternParts[i]!
const val = pathParts[i]!
if (pat.startsWith(':')) {
params[pat.slice(1)] = val
} else if (pat !== val) {
return null
}
}
return params
}

function matchRoutes(url: string) {
const pathname = new URL(url, 'http://localhost').pathname
const matched: { path: string; params: Record<string, string> }[] = []

for (const route of routes) {
const params = matchPath(route.path, pathname)
if (params !== null) {
matched.push({ path: route.path, params })
break // first match wins
}
async function loadAppContext(request: Request): Promise<AppContext> {
const user = await getSessionUser(request)
const config = getMirrorConfig()
return {
currentUser: user,
mirrorConfig: config,
kfAccountUrl: process.env.OIDC_ACCOUNT_URL ?? 'http://localhost:3001',
kfAuthUrl: process.env.OIDC_ISSUER_URL ?? 'http://localhost:3000',
}
return matched
}

export async function render(request: Request): Promise<{
html: string
ssrData: Record<string, unknown>
hydrationData: string
redirect?: string
statusCode?: number
title?: string
description?: string
}> {
const url = request.url
const pathname = new URL(url, 'http://localhost').pathname
const matchedRoutes = matchRoutes(url)

let ssrData: Record<string, unknown>
let redirect: string | undefined
let statusCode: number | undefined
let title: string | undefined
let description: string | undefined
const context = await handler.query(request, {
requestContext: { loadAppContext },
})

try {
const result = await runLoaders(matchedRoutes, request)
ssrData = result.data
redirect = result.redirect
statusCode = result.statusCode
title = result.title
description = result.description
} catch (err) {
console.error('Loader error:', err)
ssrData = {}
statusCode = 500
if (context instanceof Response) {
const location = context.headers.get('Location')
return {
html: '',
hydrationData: '{}',
redirect: location ?? '/',
statusCode: context.status,
}
}

if (redirect) {
return { html: '', ssrData: {}, redirect, statusCode: statusCode ?? 302 }
// Check for auth redirects via route handle metadata
for (const match of context.matches) {
const handle = match.route.handle as { requireAuth?: boolean } | undefined
if (handle?.requireAuth) {
const rootData = context.loaderData?.root as { currentUser: unknown } | undefined
if (!rootData?.currentUser) {
return {
html: '',
hydrationData: '{}',
redirect: '/login',
statusCode: 302,
}
}
}
}

const { title, description } = extractRouteMeta(
context.matches as Array<{ params: Record<string, string>; route: { handle?: unknown } }>,
context.loaderData,
)

const router = createStaticRouter(handler.dataRoutes, context)

return new Promise((resolve, reject) => {
let html = ''
const passthrough = new PassThrough()
passthrough.on('data', (chunk) => {
passthrough.on('data', (chunk: Buffer) => {
html += chunk.toString()
})

const { pipe } = renderToPipeableStream(
<StaticRouter location={pathname}>
<SSRDataProvider data={ssrData}>
<App />
</SSRDataProvider>
</StaticRouter>,
<StaticRouterProvider router={router} context={context} />,
{
onAllReady() {
pipe(passthrough)
passthrough.on('end', () =>
passthrough.on('end', () => {
const hydrationData = JSON.stringify({
loaderData: context.loaderData,
actionData: context.actionData ?? null,
errors: context.errors ?? null,
}).replace(/</g, '\\u003c')

resolve({
html,
ssrData,
...(statusCode !== undefined && { statusCode }),
hydrationData,
statusCode: context.statusCode,
...(title !== undefined && { title }),
...(description !== undefined && { description }),
}),
)
})
})
},
onError: reject,
},
Expand Down
Loading
Loading