199 行
7.4 KiB
TypeScript
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 ()
|
|
}
|
|
})
|
|
}
|