Skip to content
Merged
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
23 changes: 22 additions & 1 deletion server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -253,7 +253,17 @@ if (isProd) {
await runMigrations()

const template = readFileSync(clientHtml, 'utf-8')
const { render } = await import(ssrBundle as string)
const { render, loadData } = await import(ssrBundle as string)

app.get('/__data', async (c) => {
const path = new URL(c.req.url).searchParams.get('path')
if (!path) return c.json({ error: 'Missing path param' }, 400)
const fakeUrl = new URL(path, c.req.url)
const req = new Request(fakeUrl, { headers: c.req.raw.headers })
const result = await loadData(req)
if (result.redirect) return c.json({ redirect: result.redirect }, 200)
return c.json(result)
})

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

app.get('/__data', async (c) => {
const path = new URL(c.req.url).searchParams.get('path')
if (!path) return c.json({ error: 'Missing path param' }, 400)
const { loadData } = await vite!.ssrLoadModule('/src/entry-server.tsx')
const fakeUrl = new URL(path, c.req.url)
const req = new Request(fakeUrl, { headers: c.req.raw.headers })
const result = await loadData(req)
if (result.redirect) return c.json({ redirect: result.redirect }, 200)
return c.json(result)
})

app.get('*', async (c) => {
const url = c.req.url
let template = readFileSync(resolve('index.html'), 'utf-8')
Expand Down
81 changes: 25 additions & 56 deletions src/entry-server.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,38 +6,18 @@ import { StaticRouter } from 'react-router'
import App, { routes } from '~/App'
import { SSRDataProvider } from '~/lib/ssr-data'
import { runLoaders } from '~/loaders.server'
import { matchRoutes } from '~/route-gen'

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

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
type LoaderResult = {
data: Record<string, unknown>
redirect?: string
statusCode?: number
title?: string
description?: string
}

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
}
}
return matched
export async function loadData(request: Request): Promise<LoaderResult> {
return runLoaders(matchRoutes(routes, request.url), request)
}

export async function render(request: Request): Promise<{
Expand All @@ -48,31 +28,20 @@ export async function render(request: Request): Promise<{
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 pathname = new URL(request.url, 'http://localhost').pathname

try {
const result = await runLoaders(matchedRoutes, request)
ssrData = result.data
redirect = result.redirect
statusCode = result.statusCode
title = result.title
description = result.description
} catch (err) {
const result = await loadData(request).catch((err: unknown) => {
console.error('Loader error:', err)
ssrData = {}
statusCode = 500
}
return { data: {}, statusCode: 500 } as LoaderResult
})

if (redirect) {
return { html: '', ssrData: {}, redirect, statusCode: statusCode ?? 302 }
if (result.redirect) {
return {
html: '',
ssrData: {},
redirect: result.redirect,
statusCode: result.statusCode ?? 302,
}
}

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

const { pipe } = renderToPipeableStream(
<StaticRouter location={pathname}>
<SSRDataProvider data={ssrData}>
<SSRDataProvider data={result.data}>
<App />
</SSRDataProvider>
</StaticRouter>,
Expand All @@ -94,10 +63,10 @@ export async function render(request: Request): Promise<{
passthrough.on('end', () =>
resolve({
html,
ssrData,
...(statusCode !== undefined && { statusCode }),
...(title !== undefined && { title }),
...(description !== undefined && { description }),
ssrData: result.data,
...(result.statusCode !== undefined && { statusCode: result.statusCode }),
...(result.title !== undefined && { title: result.title }),
...(result.description !== undefined && { description: result.description }),
}),
)
},
Expand Down
109 changes: 109 additions & 0 deletions src/lib/collections.server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import { and, eq, sql } from 'drizzle-orm'

import { db, schema } from '~/db/client.server'
import { buildArkUrl, DEFAULT_NAAN } from '~/lib/ark'

export async function getCollectionPageData(owner: string, slug: string, userId?: string) {
const [result] = await db
.select({
id: schema.collections.id,
slug: schema.collections.slug,
name: schema.collections.name,
description: schema.collections.description,
public: schema.collections.public,
ownerSlug: schema.organization.slug,
ownerName: schema.organization.name,
createdAt: schema.collections.createdAt,
updatedAt: schema.collections.updatedAt,
})
.from(schema.collections)
.innerJoin(schema.organization, eq(schema.collections.organizationId, schema.organization.id))
.where(and(eq(schema.organization.slug, owner), eq(schema.collections.slug, slug)))
.limit(1)

if (!result) return null

if (!result.public) {
const [org] = await db
.select({ id: schema.organization.id })
.from(schema.organization)
.where(eq(schema.organization.slug, owner))
.limit(1)

if (!org) return null

let hasAccess = false
if (userId) {
const [membership] = await db
.select()
.from(schema.member)
.where(and(eq(schema.member.organizationId, org.id), eq(schema.member.userId, userId)))
.limit(1)
hasAccess = !!membership
}
if (!hasAccess) return null
}

const [latestVersion] = await db
.select({
id: schema.versions.id,
number: schema.versions.number,
semver: schema.versions.semver,
recordCount: schema.versions.recordCount,
fileCount: schema.versions.fileCount,
totalBytes: schema.versions.totalBytes,
createdAt: schema.versions.createdAt,
message: schema.versions.message,
readme: schema.versions.readme,
})
.from(schema.versions)
.where(eq(schema.versions.collectionId, result.id))
.orderBy(sql`${schema.versions.number} desc`)
.limit(1)

let typeCounts: { type: string; count: number }[] = []
if (latestVersion) {
const rows = await db
.select({
type: schema.records.type,
count: sql<number>`count(*)::int`,
})
.from(schema.records)
.where(eq(schema.records.versionId, latestVersion.id))
.groupBy(schema.records.type)
typeCounts = rows.map((r) => ({ type: r.type, count: r.count }))
}

let ark: string | null = null
try {
const [arkRow] = await db
.select({
arkId: schema.arkCollections.arkId,
enabled: schema.arkCollections.enabled,
shoulder: schema.arkShoulders.shoulder,
ownerNaan: schema.organization.arkNaan,
})
.from(schema.arkCollections)
.innerJoin(schema.collections, eq(schema.arkCollections.collectionId, schema.collections.id))
.innerJoin(schema.organization, eq(schema.collections.organizationId, schema.organization.id))
.innerJoin(
schema.arkShoulders,
eq(schema.arkShoulders.organizationId, schema.organization.id),
)
.where(eq(schema.arkCollections.collectionId, result.id))
.limit(1)
if (arkRow?.enabled) {
ark = buildArkUrl(arkRow.ownerNaan ?? DEFAULT_NAAN, arkRow.shoulder, arkRow.arkId)
}
} catch {
// Non-fatal
}

const { id: _id, ...collectionData } = result
const { id: _vid, ...latestVersionData } = latestVersion ?? { id: undefined }
return {
...collectionData,
ark,
latestVersion: latestVersion ? { ...latestVersionData, typeCounts } : null,
}
}
61 changes: 57 additions & 4 deletions src/lib/ssr-data.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,69 @@
import { createContext, useContext } from 'react'
import { createContext, useContext, useEffect, useRef, useState } from 'react'
import { useLocation } from 'react-router'

type SSRData = Record<string, unknown>

const SSRDataContext = createContext<SSRData>({})
const SSRNavigatingContext = createContext<boolean>(false)

export function SSRDataProvider({ data, children }: { data: SSRData; children: React.ReactNode }) {
return <SSRDataContext.Provider value={data}>{children}</SSRDataContext.Provider>
const location = useLocation()
const [currentData, setCurrentData] = useState(data)
const [dataPath, setDataPath] = useState(location.pathname)
const isInitial = useRef(true)
const abortRef = useRef<AbortController | null>(null)

// Computed synchronously during render — true when the route changed but data hasn't arrived
const navigating = !isInitial.current && location.pathname !== dataPath

useEffect(() => {
if (isInitial.current) {
isInitial.current = false
return
}
if (location.pathname === dataPath) return

abortRef.current?.abort()
const controller = new AbortController()
abortRef.current = controller

fetch(`/__data?path=${encodeURIComponent(location.pathname)}`, {
credentials: 'include',
signal: controller.signal,
})
.then((r) => r.json())
.then((result) => {
if (controller.signal.aborted) return
if (result.redirect) {
window.location.href = result.redirect
return
}
setCurrentData(result.data)
setDataPath(location.pathname)
if (result.title) document.title = result.title
})
.catch((err) => {
if (err instanceof DOMException && err.name === 'AbortError') return
console.error('Failed to load route data:', err)
setDataPath(location.pathname)
})

return () => controller.abort()
}, [location.pathname, dataPath])

return (
<SSRDataContext.Provider value={currentData}>
<SSRNavigatingContext.Provider value={navigating}>{children}</SSRNavigatingContext.Provider>
</SSRDataContext.Provider>
)
}

export function useSSRData<T>(key: string): T {
const data = useContext(SSRDataContext)
return data[key] as T
return useContext(SSRDataContext)[key] as T
}

export function useSSRNavigating(): boolean {
return useContext(SSRNavigatingContext)
}

export function getClientSSRData(): SSRData {
Expand Down
Loading
Loading