ファイル
2026-10-31 20:44:28 +09:00

199 行
7.4 KiB
TypeScript

import { QueryClient, QueryClientProvider, dehydrate, skipToken } from '@tanstack/react-query'
import { PassThrough } from 'node:stream'
import { renderToPipeableStream } from 'react-dom/server'
import { HelmetProvider } from 'react-helmet-async'
import { I18nextProvider } from 'react-i18next'
import { createStaticHandler, createStaticRouter,
StaticRouterProvider } from 'react-router-dom/server'
import { AppFrame, appRoutes, resolvePageRoute } from '@/App'
import { apiGet, isApiError } from '@/lib/api'
import { createUII18n } from '@/lib/i18n'
import { fetchLocales, isLocaleCode, legacyLocalePath } from '@/lib/locales'
import { localesKeys, sessionKeys } from '@/lib/queryKeys'
import { withRenderContext } from '@/lib/renderContext.server'
import type { HelmetServerState } from 'react-helmet-async'
import type { ReactNode } from 'react'
import type { QueryObserverOptions } from '@tanstack/react-query'
import type { RenderContext } from '@/lib/renderContext'
import type { User } from '@/types'
export { renderSitemap } from '@/lib/sitemap.server'
export { isCountsPath } from '@/lib/routeLocales'
type RenderedPage = {
status: number
html: string
head: string
locale: string
bootstrap: string
setCookies?: string[] }
type RouteResponse = { status: 404 | 308; location?: string }
const renderDocument = (element: ReactNode): Promise<string> =>
new Promise ((resolve, reject) => {
const output = new PassThrough ()
const chunks: Buffer[] = []
output.on ('data', chunk => chunks.push (Buffer.from (chunk)))
output.on ('error', reject)
output.on ('end', () => resolve (Buffer.concat (chunks).toString ('utf8')))
const timeout = setTimeout (() => {
stream.abort ()
reject (new Error ('SSR rendering timed out'))
}, 30_000)
const stream = renderToPipeableStream (element, {
onAllReady: () => {
clearTimeout (timeout)
stream.pipe (output)
},
onShellError: error => {
clearTimeout (timeout)
reject (error)
},
onError: error => {
clearTimeout (timeout)
reject (error)
} })
})
export const render = async (
address: string,
origin: string,
apiBaseURL: string,
authentication: Pick<RenderContext, 'cookie' | 'forwardedFor'> = { },
): Promise<RenderedPage | RouteResponse> => {
const url = new URL (address, origin)
const legacy = legacyLocalePath (
{ pathname: url.pathname, search: url.search, hash: url.hash },
pathname => resolvePageRoute (pathname) != null)
const pathname = legacy == null ? url.pathname : new URL (legacy, origin).pathname
const segment = pathname.split ('/')[1]
const known = resolvePageRoute (pathname)
if (legacy != null && known != null)
return { status: 308, location: legacy }
const locale = isLocaleCode (segment) ? segment : 'ja'
if (known?.route.handle?.redirectTo != null)
return { status: 308,
location: `/${locale}${known.route.handle.redirectTo}${url.search}` }
const requestContext: RenderContext & { status: number } = {
pathname: url.pathname, origin, apiBaseURL,
...authentication, status: 200 }
return withRenderContext (requestContext, async () => {
const client = new QueryClient ({ defaultOptions: {
queries: { retry: false, gcTime: Infinity, staleTime: Infinity } } })
try
{
const locales = await client.fetchQuery ({
queryKey: localesKeys.index, queryFn: fetchLocales })
const active = locales.locales.some (entry => entry.code === locale)
if (authentication.cookie != null)
{
try
{
const user = await apiGet<Pick<User, 'id' | 'name' | 'role'>> ('/users/session')
client.setQueryData (sessionKeys.current, {
id: user.id, name: user.name, role: user.role, inheritanceCode: '' })
}
catch (error)
{
if (!isApiError (error) || error.response?.status !== 401)
throw error
delete requestContext.cookie
}
}
const prepare = known?.route.handle?.prepare
let status = known == null || !active || !isLocaleCode (segment) ? 404 : 200
if (status === 200 && known?.route.handle?.authentication != null
&& client.getQueryData (sessionKeys.current) == null)
status = 401
if (prepare != null && known != null && status === 200)
status = await prepare ({ client, params: known.params, locale,
searchParams: url.searchParams })
const instance = createUII18n (locale)
const handler = createStaticHandler (appRoutes)
const context = await handler.query (new Request (url))
if (context instanceof Response)
throw new Error (`Unexpected router response: ${context.status}`)
const router = createStaticRouter (handler.dataRoutes, context)
let helmetContext: { helmet?: HelmetServerState } = { }
const page = () => (
<HelmetProvider context={helmetContext}>
<QueryClientProvider client={client}>
<I18nextProvider i18n={instance}>
<AppFrame>
<StaticRouterProvider router={router} context={context} hydrate={false}/>
</AppFrame>
</I18nextProvider>
</QueryClientProvider>
</HelmetProvider>)
let html = ''
const deadline = Date.now () + 30_000
// Each pass discovers the queries used by the actual page, including dependent queries.
// Streaming resolves lazy components; only the last, fully prepared pass is returned.
for (;;)
{
if (Date.now () >= deadline)
throw new Error ('SSR data preparation timed out')
helmetContext = { }
requestContext.status = status
html = await renderDocument (page ())
// Client-only queries must neither run here nor enter the bootstrap below.
const pending = client.getQueryCache ().getAll ().filter (query => {
const options = query.options as QueryObserverOptions
return query.state.status === 'pending'
&& query.meta?.clientOnly !== true
&& options.queryFn != null && options.queryFn !== skipToken
&& options.enabled !== false
&& (typeof options.enabled !== 'function' || options.enabled (query))
})
if (pending.length === 0)
{
status = requestContext.status
break
}
await Promise.all (pending.map (async query => {
try
{
await client.fetchQuery ({ ...query.options, queryKey: query.queryKey })
}
catch (error)
{
const code = isApiError (error) ? error.response?.status : null
if (code === 404 || code === 401 || code === 403)
status = code
else
throw error
}
}))
}
const helmet = helmetContext.helmet!
const head = [helmet.title, helmet.meta, helmet.link, helmet.style]
.map (entry => entry.toString ()).join ('')
const dehydrated = dehydrate (client, { shouldDehydrateQuery: query =>
query.state.status !== 'pending'
&& query.meta?.clientOnly !== true
&& !(query.queryKey[0] === 'help' && query.queryKey[1] === 'edition') })
const state = { ...dehydrated, queries: dehydrated.queries.map (query => ({
...query, state: { ...query.state, fetchFailureReason: null,
error: query.state.error == null ? null : {
isAxiosError: true, response: {
status: isApiError (query.state.error) ? query.state.error.response?.status : 500 } } }
})) }
const bootstrap = JSON.stringify ({ state, status, locale,
url: url.pathname + url.search })
.replace (/</g, '\\u003c').replace (/\u2028/g, '\\u2028')
.replace (/\u2029/g, '\\u2029')
return { status, html, head, locale, bootstrap,
setCookies: requestContext.setCookies }
}
finally
{
client.clear ()
}
})
}