Reviewed-on: #433 Co-authored-by: miteruzo <miteruzo@naver.com>
このコミットはプルリクエスト #433 でマージされました。
このコミットが含まれているのは:
@@ -0,0 +1,192 @@
|
||||
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'
|
||||
|
||||
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: '' })
|
||||
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 ())
|
||||
const pending = client.getQueryCache ().getAll ().filter (query => {
|
||||
const options = query.options as QueryObserverOptions
|
||||
return query.state.status === 'pending'
|
||||
&& 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.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 ()
|
||||
}
|
||||
})
|
||||
}
|
||||
新しいイシューから参照
ユーザーをブロックする