Skip to content

Commit ba4a5a2

Browse files
authored
dev: Improve SSR (#18)
1 parent 54d5f2a commit ba4a5a2

7 files changed

Lines changed: 349 additions & 330 deletions

File tree

server.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -253,7 +253,17 @@ if (isProd) {
253253
await runMigrations()
254254

255255
const template = readFileSync(clientHtml, 'utf-8')
256-
const { render } = await import(ssrBundle as string)
256+
const { render, loadData } = await import(ssrBundle as string)
257+
258+
app.get('/__data', async (c) => {
259+
const path = new URL(c.req.url).searchParams.get('path')
260+
if (!path) return c.json({ error: 'Missing path param' }, 400)
261+
const fakeUrl = new URL(path, c.req.url)
262+
const req = new Request(fakeUrl, { headers: c.req.raw.headers })
263+
const result = await loadData(req)
264+
if (result.redirect) return c.json({ redirect: result.redirect }, 200)
265+
return c.json(result)
266+
})
257267

258268
app.get('*', async (c) => {
259269
const { html, ssrData, redirect, statusCode, title, description } = await render(c.req.raw)
@@ -299,6 +309,17 @@ if (isProd) {
299309
})
300310
})
301311

312+
app.get('/__data', async (c) => {
313+
const path = new URL(c.req.url).searchParams.get('path')
314+
if (!path) return c.json({ error: 'Missing path param' }, 400)
315+
const { loadData } = await vite!.ssrLoadModule('/src/entry-server.tsx')
316+
const fakeUrl = new URL(path, c.req.url)
317+
const req = new Request(fakeUrl, { headers: c.req.raw.headers })
318+
const result = await loadData(req)
319+
if (result.redirect) return c.json({ redirect: result.redirect }, 200)
320+
return c.json(result)
321+
})
322+
302323
app.get('*', async (c) => {
303324
const url = c.req.url
304325
let template = readFileSync(resolve('index.html'), 'utf-8')

src/entry-server.tsx

Lines changed: 25 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -6,38 +6,18 @@ import { StaticRouter } from 'react-router'
66
import App, { routes } from '~/App'
77
import { SSRDataProvider } from '~/lib/ssr-data'
88
import { runLoaders } from '~/loaders.server'
9+
import { matchRoutes } from '~/route-gen'
910

10-
function matchPath(pattern: string, pathname: string): Record<string, string> | null {
11-
const patternParts = pattern.split('/').filter(Boolean)
12-
const pathParts = pathname.split('/').filter(Boolean)
13-
14-
if (patternParts.length !== pathParts.length) return null
15-
16-
const params: Record<string, string> = {}
17-
for (let i = 0; i < patternParts.length; i++) {
18-
const pat = patternParts[i]!
19-
const val = pathParts[i]!
20-
if (pat.startsWith(':')) {
21-
params[pat.slice(1)] = val
22-
} else if (pat !== val) {
23-
return null
24-
}
25-
}
26-
return params
11+
type LoaderResult = {
12+
data: Record<string, unknown>
13+
redirect?: string
14+
statusCode?: number
15+
title?: string
16+
description?: string
2717
}
2818

29-
function matchRoutes(url: string) {
30-
const pathname = new URL(url, 'http://localhost').pathname
31-
const matched: { path: string; params: Record<string, string> }[] = []
32-
33-
for (const route of routes) {
34-
const params = matchPath(route.path, pathname)
35-
if (params !== null) {
36-
matched.push({ path: route.path, params })
37-
break // first match wins
38-
}
39-
}
40-
return matched
19+
export async function loadData(request: Request): Promise<LoaderResult> {
20+
return runLoaders(matchRoutes(routes, request.url), request)
4121
}
4222

4323
export async function render(request: Request): Promise<{
@@ -48,31 +28,20 @@ export async function render(request: Request): Promise<{
4828
title?: string
4929
description?: string
5030
}> {
51-
const url = request.url
52-
const pathname = new URL(url, 'http://localhost').pathname
53-
const matchedRoutes = matchRoutes(url)
54-
55-
let ssrData: Record<string, unknown>
56-
let redirect: string | undefined
57-
let statusCode: number | undefined
58-
let title: string | undefined
59-
let description: string | undefined
31+
const pathname = new URL(request.url, 'http://localhost').pathname
6032

61-
try {
62-
const result = await runLoaders(matchedRoutes, request)
63-
ssrData = result.data
64-
redirect = result.redirect
65-
statusCode = result.statusCode
66-
title = result.title
67-
description = result.description
68-
} catch (err) {
33+
const result = await loadData(request).catch((err: unknown) => {
6934
console.error('Loader error:', err)
70-
ssrData = {}
71-
statusCode = 500
72-
}
35+
return { data: {}, statusCode: 500 } as LoaderResult
36+
})
7337

74-
if (redirect) {
75-
return { html: '', ssrData: {}, redirect, statusCode: statusCode ?? 302 }
38+
if (result.redirect) {
39+
return {
40+
html: '',
41+
ssrData: {},
42+
redirect: result.redirect,
43+
statusCode: result.statusCode ?? 302,
44+
}
7645
}
7746

7847
return new Promise((resolve, reject) => {
@@ -84,7 +53,7 @@ export async function render(request: Request): Promise<{
8453

8554
const { pipe } = renderToPipeableStream(
8655
<StaticRouter location={pathname}>
87-
<SSRDataProvider data={ssrData}>
56+
<SSRDataProvider data={result.data}>
8857
<App />
8958
</SSRDataProvider>
9059
</StaticRouter>,
@@ -94,10 +63,10 @@ export async function render(request: Request): Promise<{
9463
passthrough.on('end', () =>
9564
resolve({
9665
html,
97-
ssrData,
98-
...(statusCode !== undefined && { statusCode }),
99-
...(title !== undefined && { title }),
100-
...(description !== undefined && { description }),
66+
ssrData: result.data,
67+
...(result.statusCode !== undefined && { statusCode: result.statusCode }),
68+
...(result.title !== undefined && { title: result.title }),
69+
...(result.description !== undefined && { description: result.description }),
10170
}),
10271
)
10372
},

src/lib/collections.server.ts

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import { and, eq, sql } from 'drizzle-orm'
2+
3+
import { db, schema } from '~/db/client.server'
4+
import { buildArkUrl, DEFAULT_NAAN } from '~/lib/ark'
5+
6+
export async function getCollectionPageData(owner: string, slug: string, userId?: string) {
7+
const [result] = await db
8+
.select({
9+
id: schema.collections.id,
10+
slug: schema.collections.slug,
11+
name: schema.collections.name,
12+
description: schema.collections.description,
13+
public: schema.collections.public,
14+
ownerSlug: schema.organization.slug,
15+
ownerName: schema.organization.name,
16+
createdAt: schema.collections.createdAt,
17+
updatedAt: schema.collections.updatedAt,
18+
})
19+
.from(schema.collections)
20+
.innerJoin(schema.organization, eq(schema.collections.organizationId, schema.organization.id))
21+
.where(and(eq(schema.organization.slug, owner), eq(schema.collections.slug, slug)))
22+
.limit(1)
23+
24+
if (!result) return null
25+
26+
if (!result.public) {
27+
const [org] = await db
28+
.select({ id: schema.organization.id })
29+
.from(schema.organization)
30+
.where(eq(schema.organization.slug, owner))
31+
.limit(1)
32+
33+
if (!org) return null
34+
35+
let hasAccess = false
36+
if (userId) {
37+
const [membership] = await db
38+
.select()
39+
.from(schema.member)
40+
.where(and(eq(schema.member.organizationId, org.id), eq(schema.member.userId, userId)))
41+
.limit(1)
42+
hasAccess = !!membership
43+
}
44+
if (!hasAccess) return null
45+
}
46+
47+
const [latestVersion] = await db
48+
.select({
49+
id: schema.versions.id,
50+
number: schema.versions.number,
51+
semver: schema.versions.semver,
52+
recordCount: schema.versions.recordCount,
53+
fileCount: schema.versions.fileCount,
54+
totalBytes: schema.versions.totalBytes,
55+
createdAt: schema.versions.createdAt,
56+
message: schema.versions.message,
57+
readme: schema.versions.readme,
58+
})
59+
.from(schema.versions)
60+
.where(eq(schema.versions.collectionId, result.id))
61+
.orderBy(sql`${schema.versions.number} desc`)
62+
.limit(1)
63+
64+
let typeCounts: { type: string; count: number }[] = []
65+
if (latestVersion) {
66+
const rows = await db
67+
.select({
68+
type: schema.records.type,
69+
count: sql<number>`count(*)::int`,
70+
})
71+
.from(schema.records)
72+
.where(eq(schema.records.versionId, latestVersion.id))
73+
.groupBy(schema.records.type)
74+
typeCounts = rows.map((r) => ({ type: r.type, count: r.count }))
75+
}
76+
77+
let ark: string | null = null
78+
try {
79+
const [arkRow] = await db
80+
.select({
81+
arkId: schema.arkCollections.arkId,
82+
enabled: schema.arkCollections.enabled,
83+
shoulder: schema.arkShoulders.shoulder,
84+
ownerNaan: schema.organization.arkNaan,
85+
})
86+
.from(schema.arkCollections)
87+
.innerJoin(schema.collections, eq(schema.arkCollections.collectionId, schema.collections.id))
88+
.innerJoin(schema.organization, eq(schema.collections.organizationId, schema.organization.id))
89+
.innerJoin(
90+
schema.arkShoulders,
91+
eq(schema.arkShoulders.organizationId, schema.organization.id),
92+
)
93+
.where(eq(schema.arkCollections.collectionId, result.id))
94+
.limit(1)
95+
if (arkRow?.enabled) {
96+
ark = buildArkUrl(arkRow.ownerNaan ?? DEFAULT_NAAN, arkRow.shoulder, arkRow.arkId)
97+
}
98+
} catch {
99+
// Non-fatal
100+
}
101+
102+
const { id: _id, ...collectionData } = result
103+
const { id: _vid, ...latestVersionData } = latestVersion ?? { id: undefined }
104+
return {
105+
...collectionData,
106+
ark,
107+
latestVersion: latestVersion ? { ...latestVersionData, typeCounts } : null,
108+
}
109+
}

src/lib/ssr-data.tsx

Lines changed: 57 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,69 @@
1-
import { createContext, useContext } from 'react'
1+
import { createContext, useContext, useEffect, useRef, useState } from 'react'
2+
import { useLocation } from 'react-router'
23

34
type SSRData = Record<string, unknown>
45

56
const SSRDataContext = createContext<SSRData>({})
7+
const SSRNavigatingContext = createContext<boolean>(false)
68

79
export function SSRDataProvider({ data, children }: { data: SSRData; children: React.ReactNode }) {
8-
return <SSRDataContext.Provider value={data}>{children}</SSRDataContext.Provider>
10+
const location = useLocation()
11+
const [currentData, setCurrentData] = useState(data)
12+
const [dataPath, setDataPath] = useState(location.pathname)
13+
const isInitial = useRef(true)
14+
const abortRef = useRef<AbortController | null>(null)
15+
16+
// Computed synchronously during render — true when the route changed but data hasn't arrived
17+
const navigating = !isInitial.current && location.pathname !== dataPath
18+
19+
useEffect(() => {
20+
if (isInitial.current) {
21+
isInitial.current = false
22+
return
23+
}
24+
if (location.pathname === dataPath) return
25+
26+
abortRef.current?.abort()
27+
const controller = new AbortController()
28+
abortRef.current = controller
29+
30+
fetch(`/__data?path=${encodeURIComponent(location.pathname)}`, {
31+
credentials: 'include',
32+
signal: controller.signal,
33+
})
34+
.then((r) => r.json())
35+
.then((result) => {
36+
if (controller.signal.aborted) return
37+
if (result.redirect) {
38+
window.location.href = result.redirect
39+
return
40+
}
41+
setCurrentData(result.data)
42+
setDataPath(location.pathname)
43+
if (result.title) document.title = result.title
44+
})
45+
.catch((err) => {
46+
if (err instanceof DOMException && err.name === 'AbortError') return
47+
console.error('Failed to load route data:', err)
48+
setDataPath(location.pathname)
49+
})
50+
51+
return () => controller.abort()
52+
}, [location.pathname, dataPath])
53+
54+
return (
55+
<SSRDataContext.Provider value={currentData}>
56+
<SSRNavigatingContext.Provider value={navigating}>{children}</SSRNavigatingContext.Provider>
57+
</SSRDataContext.Provider>
58+
)
959
}
1060

1161
export function useSSRData<T>(key: string): T {
12-
const data = useContext(SSRDataContext)
13-
return data[key] as T
62+
return useContext(SSRDataContext)[key] as T
63+
}
64+
65+
export function useSSRNavigating(): boolean {
66+
return useContext(SSRNavigatingContext)
1467
}
1568

1669
export function getClientSSRData(): SSRData {

0 commit comments

Comments
 (0)